feat: pinned users event

This commit is contained in:
codytseng 2025-12-01 00:05:09 +08:00
parent ad016aba35
commit 7ec4835c61
10 changed files with 303 additions and 136 deletions

View file

@ -14,6 +14,7 @@ import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvid
import { MuteListProvider } from '@/providers/MuteListProvider' import { MuteListProvider } from '@/providers/MuteListProvider'
import { NostrProvider } from '@/providers/NostrProvider' import { NostrProvider } from '@/providers/NostrProvider'
import { PinListProvider } from '@/providers/PinListProvider' import { PinListProvider } from '@/providers/PinListProvider'
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
import { ReplyProvider } from '@/providers/ReplyProvider' import { ReplyProvider } from '@/providers/ReplyProvider'
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider' import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
import { ThemeProvider } from '@/providers/ThemeProvider' import { ThemeProvider } from '@/providers/ThemeProvider'
@ -40,16 +41,18 @@ export default function App(): JSX.Element {
<BookmarksProvider> <BookmarksProvider>
<EmojiPackProvider> <EmojiPackProvider>
<PinListProvider> <PinListProvider>
<FeedProvider> <PinnedUsersProvider>
<ReplyProvider> <FeedProvider>
<MediaUploadServiceProvider> <ReplyProvider>
<KindFilterProvider> <MediaUploadServiceProvider>
<PageManager /> <KindFilterProvider>
<Toaster /> <PageManager />
</KindFilterProvider> <Toaster />
</MediaUploadServiceProvider> </KindFilterProvider>
</ReplyProvider> </MediaUploadServiceProvider>
</FeedProvider> </ReplyProvider>
</FeedProvider>
</PinnedUsersProvider>
</PinListProvider> </PinListProvider>
</EmojiPackProvider> </EmojiPackProvider>
</BookmarksProvider> </BookmarksProvider>

View file

@ -11,6 +11,7 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { usePinnedUsers } from '@/providers/PinnedUsersProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import userAggregationService, { TUserAggregation } from '@/services/user-aggregation.service' import userAggregationService, { TUserAggregation } from '@/services/user-aggregation.service'
@ -53,6 +54,7 @@ const UserAggregationList = forwardRef<
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { hideUntrustedNotes, isUserTrusted } = useUserTrust() const { hideUntrustedNotes, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { pinnedPubkeySet } = usePinnedUsers()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix()) const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix())
@ -64,9 +66,6 @@ const UserAggregationList = forwardRef<
const [showCount, setShowCount] = useState(SHOW_COUNT) const [showCount, setShowCount] = useState(SHOW_COUNT)
const [hasMore, setHasMore] = useState(true) const [hasMore, setHasMore] = useState(true)
const supportTouch = useMemo(() => isTouchDevice(), []) const supportTouch = useMemo(() => isTouchDevice(), [])
const [pinnedPubkeys, setPinnedPubkeys] = useState<Set<string>>(
new Set(userAggregationService.getPinnedPubkeys())
)
const feedId = useMemo(() => { const feedId = useMemo(() => {
return userAggregationService.getFeedId(subRequests, showKinds) return userAggregationService.getFeedId(subRequests, showKinds)
}, [JSON.stringify(subRequests), JSON.stringify(showKinds)]) }, [JSON.stringify(subRequests), JSON.stringify(showKinds)])
@ -97,7 +96,6 @@ const UserAggregationList = forwardRef<
useEffect(() => { useEffect(() => {
if (!subRequests.length) return if (!subRequests.length) return
setPinnedPubkeys(new Set(userAggregationService.getPinnedPubkeys()))
setSince(dayjs().subtract(1, 'day').unix()) setSince(dayjs().subtract(1, 'day').unix())
setHasMore(true) setHasMore(true)
@ -223,28 +221,24 @@ const UserAggregationList = forwardRef<
const aggregations = useMemo(() => { const aggregations = useMemo(() => {
const aggs = userAggregationService.aggregateByUser(filteredEvents) const aggs = userAggregationService.aggregateByUser(filteredEvents)
userAggregationService.saveAggregations(feedId, aggs) userAggregationService.saveAggregations(feedId, aggs)
return aggs
}, [feedId, filteredEvents])
const pinned: TUserAggregation[] = [] const pinnedAggregations = useMemo(() => {
const unpinned: TUserAggregation[] = [] return aggregations.filter((agg) => pinnedPubkeySet.has(agg.pubkey))
}, [aggregations, pinnedPubkeySet])
aggs.forEach((agg) => { const normalAggregations = useMemo(() => {
if (pinnedPubkeys.has(agg.pubkey)) { return aggregations.filter((agg) => !pinnedPubkeySet.has(agg.pubkey))
pinned.push(agg) }, [aggregations, pinnedPubkeySet])
} else {
unpinned.push(agg)
}
})
return [...pinned, ...unpinned] const displayedNormalAggregations = useMemo(() => {
}, [feedId, filteredEvents, pinnedPubkeys]) return normalAggregations.slice(0, showCount)
}, [normalAggregations, showCount])
const displayedAggregations = useMemo(() => {
return aggregations.slice(0, showCount)
}, [aggregations, showCount])
const hasMoreToDisplay = useMemo(() => { const hasMoreToDisplay = useMemo(() => {
return aggregations.length > displayedAggregations.length return normalAggregations.length > displayedNormalAggregations.length
}, [aggregations, displayedAggregations]) }, [normalAggregations, displayedNormalAggregations])
useEffect(() => { useEffect(() => {
const options = { const options = {
@ -294,7 +288,7 @@ const UserAggregationList = forwardRef<
const list = ( const list = (
<div className="min-h-screen"> <div className="min-h-screen">
{displayedAggregations.map((agg) => ( {pinnedAggregations.map((agg) => (
<UserAggregationItem <UserAggregationItem
key={agg.pubkey} key={agg.pubkey}
feedId={feedId} feedId={feedId}
@ -302,11 +296,21 @@ const UserAggregationList = forwardRef<
onClick={() => handleViewUser(agg)} onClick={() => handleViewUser(agg)}
/> />
))} ))}
{normalAggregations.map((agg) => (
<UserAggregationItem
key={agg.pubkey}
feedId={feedId}
aggregation={agg}
onClick={() => handleViewUser(agg)}
/>
))}
{loading || hasMoreToDisplay ? ( {loading || hasMoreToDisplay ? (
<div ref={bottomRef}> <div ref={bottomRef}>
<UserAggregationItemSkeleton /> <UserAggregationItemSkeleton />
</div> </div>
) : displayedAggregations.length === 0 ? ( ) : aggregations.length === 0 ? (
<div className="flex justify-center w-full mt-2"> <div className="flex justify-center w-full mt-2">
<Button size="lg" onClick={() => setRefreshCount((count) => count + 1)}> <Button size="lg" onClick={() => setRefreshCount((count) => count + 1)}>
{t('Reload')} {t('Reload')}
@ -373,7 +377,7 @@ function UserAggregationItem({
const { t } = useTranslation() const { t } = useTranslation()
const supportTouch = useMemo(() => isTouchDevice(), []) const supportTouch = useMemo(() => isTouchDevice(), [])
const [hasNewEvents, setHasNewEvents] = useState(true) const [hasNewEvents, setHasNewEvents] = useState(true)
const [isPinned, setIsPinned] = useState(userAggregationService.isPinned(aggregation.pubkey)) const { isPinned, togglePin } = usePinnedUsers()
useEffect(() => { useEffect(() => {
const update = () => { const update = () => {
@ -396,13 +400,7 @@ function UserAggregationItem({
const onTogglePin = (e: React.MouseEvent) => { const onTogglePin = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (isPinned) { togglePin(aggregation.pubkey)
userAggregationService.unpinUser(aggregation.pubkey)
setIsPinned(false)
} else {
userAggregationService.pinUser(aggregation.pubkey)
setIsPinned(true)
}
} }
const onToggleViewed = (e: React.MouseEvent) => { const onToggleViewed = (e: React.MouseEvent) => {
@ -459,13 +457,17 @@ function UserAggregationItem({
size="icon" size="icon"
onClick={onTogglePin} onClick={onTogglePin}
className={`flex-shrink-0 ${ className={`flex-shrink-0 ${
isPinned isPinned(aggregation.pubkey)
? 'text-primary hover:text-primary/80' ? 'text-primary hover:text-primary/80'
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
}`} }`}
title={isPinned ? t('Unpin') : t('Pin')} title={isPinned(aggregation.pubkey) ? t('Unpin') : t('Pin')}
> >
{isPinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />} {isPinned(aggregation.pubkey) ? (
<PinOff className="w-4 h-4" />
) : (
<Pin className="w-4 h-4" />
)}
</Button> </Button>
<button <button

View file

@ -41,7 +41,7 @@ export const StorageKey = {
ENABLE_SINGLE_COLUMN_LAYOUT: 'enableSingleColumnLayout', ENABLE_SINGLE_COLUMN_LAYOUT: 'enableSingleColumnLayout',
FAVICON_URL_TEMPLATE: 'faviconUrlTemplate', FAVICON_URL_TEMPLATE: 'faviconUrlTemplate',
FILTER_OUT_ONION_RELAYS: 'filterOutOnionRelays', FILTER_OUT_ONION_RELAYS: 'filterOutOnionRelays',
PINNED_PUBKEYS: 'pinnedPubkeys', PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
@ -80,6 +80,7 @@ export const ExtendedKind = {
COMMENT: 1111, COMMENT: 1111,
VOICE: 1222, VOICE: 1222,
VOICE_COMMENT: 1244, VOICE_COMMENT: 1244,
PINNED_USERS: 10010,
FAVORITE_RELAYS: 10012, FAVORITE_RELAYS: 10012,
BLOSSOM_SERVER_LIST: 10063, BLOSSOM_SERVER_LIST: 10063,
FOLLOW_PACK: 39089, FOLLOW_PACK: 39089,

View file

@ -54,25 +54,30 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
}, [publicMutePubkeySet, privateMutePubkeySet]) }, [publicMutePubkeySet, privateMutePubkeySet])
const [changing, setChanging] = useState(false) const [changing, setChanging] = useState(false)
const getPrivateTags = async (muteListEvent: Event) => { const getPrivateTags = useCallback(
if (!muteListEvent.content) return [] async (muteListEvent: Event) => {
if (!muteListEvent.content) return []
const storedDecryptedTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id)
if (storedDecryptedTags) {
return storedDecryptedTags
} else {
try { try {
const plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content) const storedPlainText = await indexedDb.getDecryptedContent(muteListEvent.id)
let plainText: string
if (storedPlainText) {
plainText = storedPlainText
} else {
plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content)
await indexedDb.putDecryptedContent(muteListEvent.id, plainText)
}
const privateTags = z.array(z.array(z.string())).parse(JSON.parse(plainText)) const privateTags = z.array(z.array(z.string())).parse(JSON.parse(plainText))
await indexedDb.putMuteDecryptedTags(muteListEvent.id, privateTags)
return privateTags return privateTags
} catch (error) { } catch (error) {
console.error('Failed to decrypt mute list content', error) console.error('Failed to decrypt mute list content', error)
return [] return []
} }
} },
} [nip04Decrypt]
)
useEffect(() => { useEffect(() => {
const updateMuteTags = async () => { const updateMuteTags = async () => {

View file

@ -56,6 +56,7 @@ type TNostrContext = {
favoriteRelaysEvent: Event | null favoriteRelaysEvent: Event | null
userEmojiListEvent: Event | null userEmojiListEvent: Event | null
pinListEvent: Event | null pinListEvent: Event | null
pinnedUsersEvent: Event | null
notificationsSeenAt: number notificationsSeenAt: number
account: TAccountPointer | null account: TAccountPointer | null
accounts: TAccountPointer[] accounts: TAccountPointer[]
@ -88,6 +89,7 @@ type TNostrContext = {
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void> updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updateUserEmojiListEvent: (userEmojiListEvent: Event) => Promise<void> updateUserEmojiListEvent: (userEmojiListEvent: Event) => Promise<void>
updatePinListEvent: (pinListEvent: Event) => Promise<void> updatePinListEvent: (pinListEvent: Event) => Promise<void>
updatePinnedUsersEvent: (pinnedUsersEvent: Event, privateTags?: string[][]) => Promise<void>
updateNotificationsSeenAt: (skipPublish?: boolean) => Promise<void> updateNotificationsSeenAt: (skipPublish?: boolean) => Promise<void>
} }
@ -119,6 +121,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [relayList, setRelayList] = useState<TRelayList | null>(null) const [relayList, setRelayList] = useState<TRelayList | null>(null)
const [followListEvent, setFollowListEvent] = useState<Event | null>(null) const [followListEvent, setFollowListEvent] = useState<Event | null>(null)
const [muteListEvent, setMuteListEvent] = useState<Event | null>(null) const [muteListEvent, setMuteListEvent] = useState<Event | null>(null)
const [pinnedUsersEvent, setPinnedUsersEvent] = useState<Event | null>(null)
const [bookmarkListEvent, setBookmarkListEvent] = useState<Event | null>(null) const [bookmarkListEvent, setBookmarkListEvent] = useState<Event | null>(null)
const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(null) const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(null)
const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null) const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null)
@ -195,7 +198,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
storedBookmarkListEvent, storedBookmarkListEvent,
storedFavoriteRelaysEvent, storedFavoriteRelaysEvent,
storedUserEmojiListEvent, storedUserEmojiListEvent,
storedPinListEvent storedPinListEvent,
storedPinnedUsersEvent
] = await Promise.all([ ] = await Promise.all([
indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList), indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata), indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata),
@ -204,7 +208,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
indexedDb.getReplaceableEvent(account.pubkey, kinds.BookmarkList), indexedDb.getReplaceableEvent(account.pubkey, kinds.BookmarkList),
indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.FAVORITE_RELAYS), 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) indexedDb.getReplaceableEvent(account.pubkey, kinds.Pinlist),
indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.PINNED_USERS)
]) ])
if (storedRelayListEvent) { if (storedRelayListEvent) {
setRelayList(getRelayListFromEvent(storedRelayListEvent, storage.getFilterOutOnionRelays())) setRelayList(getRelayListFromEvent(storedRelayListEvent, storage.getFilterOutOnionRelays()))
@ -231,6 +236,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (storedPinListEvent) { if (storedPinListEvent) {
setPinListEvent(storedPinListEvent) setPinListEvent(storedPinListEvent)
} }
if (storedPinnedUsersEvent !== undefined) {
setPinnedUsersEvent(storedPinnedUsersEvent)
}
const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, { const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, {
kinds: [kinds.RelayList], kinds: [kinds.RelayList],
@ -254,7 +262,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
ExtendedKind.FAVORITE_RELAYS, ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOSSOM_SERVER_LIST, ExtendedKind.BLOSSOM_SERVER_LIST,
kinds.UserEmojiList, kinds.UserEmojiList,
kinds.Pinlist kinds.Pinlist,
ExtendedKind.PINNED_USERS
], ],
authors: [account.pubkey] authors: [account.pubkey]
}, },
@ -280,6 +289,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
getReplaceableEventIdentifier(e) === ApplicationDataKey.NOTIFICATIONS_SEEN_AT getReplaceableEventIdentifier(e) === ApplicationDataKey.NOTIFICATIONS_SEEN_AT
) )
const pinnedNotesEvent = sortedEvents.find((e) => e.kind === kinds.Pinlist) const pinnedNotesEvent = sortedEvents.find((e) => e.kind === kinds.Pinlist)
const pinnedUsersEvent = sortedEvents.find((e) => e.kind === ExtendedKind.PINNED_USERS)
if (profileEvent) { if (profileEvent) {
const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent) const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent)
if (updatedProfileEvent.id === profileEvent.id) { if (updatedProfileEvent.id === profileEvent.id) {
@ -332,6 +343,15 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setPinListEvent(updatedPinnedNotesEvent) setPinListEvent(updatedPinnedNotesEvent)
} }
} }
if (pinnedUsersEvent) {
const updatedPinnedUsersEvent = await indexedDb.putReplaceableEvent(pinnedUsersEvent)
if (updatedPinnedUsersEvent.id === pinnedUsersEvent.id) {
setPinnedUsersEvent(updatedPinnedUsersEvent)
}
} else {
await indexedDb.putNullReplaceableEvent(account.pubkey, ExtendedKind.PINNED_USERS)
setPinnedUsersEvent(null)
}
const notificationsSeenAt = Math.max( const notificationsSeenAt = Math.max(
notificationsSeenAtEvent?.created_at ?? 0, notificationsSeenAtEvent?.created_at ?? 0,
@ -726,7 +746,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const newMuteListEvent = await indexedDb.putReplaceableEvent(muteListEvent) const newMuteListEvent = await indexedDb.putReplaceableEvent(muteListEvent)
if (newMuteListEvent.id !== muteListEvent.id) return if (newMuteListEvent.id !== muteListEvent.id) return
await indexedDb.putMuteDecryptedTags(muteListEvent.id, privateTags) await indexedDb.putDecryptedContent(muteListEvent.id, JSON.stringify(privateTags))
setMuteListEvent(muteListEvent) setMuteListEvent(muteListEvent)
} }
@ -758,6 +778,16 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setPinListEvent(newPinListEvent) setPinListEvent(newPinListEvent)
} }
const updatePinnedUsersEvent = async (pinnedUsersEvent: Event, privateTags?: string[][]) => {
const newPinnedUsersEvent = await indexedDb.putReplaceableEvent(pinnedUsersEvent)
if (newPinnedUsersEvent.id !== pinnedUsersEvent.id) return
if (privateTags) {
await indexedDb.putDecryptedContent(pinnedUsersEvent.id, JSON.stringify(privateTags))
}
setPinnedUsersEvent(newPinnedUsersEvent)
}
const updateNotificationsSeenAt = async (skipPublish = false) => { const updateNotificationsSeenAt = async (skipPublish = false) => {
if (!account) return if (!account) return
@ -794,6 +824,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
favoriteRelaysEvent, favoriteRelaysEvent,
userEmojiListEvent, userEmojiListEvent,
pinListEvent, pinListEvent,
pinnedUsersEvent,
notificationsSeenAt, notificationsSeenAt,
account, account,
accounts, accounts,
@ -823,6 +854,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
updateFavoriteRelaysEvent, updateFavoriteRelaysEvent,
updateUserEmojiListEvent, updateUserEmojiListEvent,
updatePinListEvent, updatePinListEvent,
updatePinnedUsersEvent,
updateNotificationsSeenAt updateNotificationsSeenAt
}} }}
> >

View file

@ -0,0 +1,171 @@
import { ExtendedKind } from '@/constants'
import { getPubkeysFromPTags } from '@/lib/tag'
import indexedDb from '@/services/indexed-db.service'
import { Event } from 'nostr-tools'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { z } from 'zod'
import { useNostr } from './NostrProvider'
type TPinnedUsersContext = {
pinnedPubkeySet: Set<string>
isPinned: (pubkey: string) => boolean
pinUser: (pubkey: string) => Promise<void>
unpinUser: (pubkey: string) => Promise<void>
togglePin: (pubkey: string) => Promise<void>
}
const PinnedUsersContext = createContext<TPinnedUsersContext | undefined>(undefined)
export const usePinnedUsers = () => {
const context = useContext(PinnedUsersContext)
if (!context) {
throw new Error('usePinnedUsers must be used within a PinnedUsersProvider')
}
return context
}
function createPinnedUsersListDraftEvent(tags: string[][], content = '') {
return {
kind: ExtendedKind.PINNED_USERS,
content,
tags,
created_at: Math.floor(Date.now() / 1000)
}
}
export function PinnedUsersProvider({ children }: { children: React.ReactNode }) {
const {
pubkey: accountPubkey,
pinnedUsersEvent,
updatePinnedUsersEvent,
publish,
nip04Decrypt,
nip04Encrypt
} = useNostr()
const [privateTags, setPrivateTags] = useState<string[][]>([])
const pinnedPubkeySet = useMemo(() => {
if (!pinnedUsersEvent) return new Set<string>()
return new Set(getPubkeysFromPTags(pinnedUsersEvent.tags.concat(privateTags)))
}, [pinnedUsersEvent, privateTags])
useEffect(() => {
const updatePrivateTags = async () => {
if (!pinnedUsersEvent) {
setPrivateTags([])
return
}
const privateTags = await getPrivateTags(pinnedUsersEvent).catch(() => {
return []
})
setPrivateTags(privateTags)
}
updatePrivateTags()
}, [pinnedUsersEvent])
const getPrivateTags = useCallback(
async (event: Event) => {
if (!event.content) return []
try {
const storedPlainText = await indexedDb.getDecryptedContent(event.id)
let plainText: string
if (storedPlainText) {
plainText = storedPlainText
} else {
plainText = await nip04Decrypt(event.pubkey, event.content)
await indexedDb.putDecryptedContent(event.id, plainText)
}
const privateTags = z.array(z.array(z.string())).parse(JSON.parse(plainText))
return privateTags
} catch (error) {
console.error('Failed to decrypt pinned users content', error)
return []
}
},
[nip04Decrypt]
)
const isPinned = useCallback(
(pubkey: string) => {
return pinnedPubkeySet.has(pubkey)
},
[pinnedPubkeySet]
)
const pinUser = useCallback(
async (pubkey: string) => {
if (!accountPubkey || isPinned(pubkey)) return
try {
const newTags = [...(pinnedUsersEvent?.tags ?? []), ['p', pubkey]]
const draftEvent = createPinnedUsersListDraftEvent(newTags, pinnedUsersEvent?.content ?? '')
const newEvent = await publish(draftEvent)
await updatePinnedUsersEvent(newEvent, privateTags)
} catch (error) {
console.error('Failed to pin user:', error)
}
},
[accountPubkey, isPinned, pinnedUsersEvent, publish, updatePinnedUsersEvent, privateTags]
)
const unpinUser = useCallback(
async (pubkey: string) => {
if (!accountPubkey || !pinnedUsersEvent || !isPinned(pubkey)) return
try {
const newTags = pinnedUsersEvent.tags.filter(
([tagName, tagValue]) => tagName !== 'p' || tagValue !== pubkey
)
const newPrivateTags = privateTags.filter(
([tagName, tagValue]) => tagName !== 'p' || tagValue !== pubkey
)
let newContent = pinnedUsersEvent.content
if (newPrivateTags.length !== privateTags.length) {
newContent = await nip04Encrypt(pinnedUsersEvent.pubkey, JSON.stringify(newPrivateTags))
}
const draftEvent = createPinnedUsersListDraftEvent(newTags, newContent)
const newEvent = await publish(draftEvent)
await updatePinnedUsersEvent(newEvent, newPrivateTags)
} catch (error) {
console.error('Failed to unpin user:', error)
}
},
[
accountPubkey,
isPinned,
pinnedUsersEvent,
publish,
updatePinnedUsersEvent,
privateTags,
nip04Encrypt
]
)
const togglePin = useCallback(
async (pubkey: string) => {
if (isPinned(pubkey)) {
await unpinUser(pubkey)
} else {
await pinUser(pubkey)
}
},
[isPinned, pinUser, unpinUser]
)
return (
<PinnedUsersContext.Provider
value={{
pinnedPubkeySet,
isPinned,
pinUser,
unpinUser,
togglePin
}}
>
{children}
</PinnedUsersContext.Provider>
)
}

View file

@ -1397,6 +1397,10 @@ class ClientService extends EventTarget {
return this.fetchReplaceableEvent(pubkey, kinds.UserEmojiList) return this.fetchReplaceableEvent(pubkey, kinds.UserEmojiList)
} }
async fetchPinnedUsersList(pubkey: string) {
return this.fetchReplaceableEvent(pubkey, ExtendedKind.PINNED_USERS)
}
async updateBlossomServerListEventCache(evt: NEvent) { async updateBlossomServerListEventCache(evt: NEvent) {
await this.updateReplaceableEventCache(evt) await this.updateReplaceableEventCache(evt)
} }

View file

@ -16,7 +16,6 @@ const StoreNames = {
MUTE_LIST_EVENTS: 'muteListEvents', MUTE_LIST_EVENTS: 'muteListEvents',
BOOKMARK_LIST_EVENTS: 'bookmarkListEvents', BOOKMARK_LIST_EVENTS: 'bookmarkListEvents',
BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents', BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents',
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents', USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents',
EMOJI_SET_EVENTS: 'emojiSetEvents', EMOJI_SET_EVENTS: 'emojiSetEvents',
PIN_LIST_EVENTS: 'pinListEvents', PIN_LIST_EVENTS: 'pinListEvents',
@ -24,6 +23,9 @@ const StoreNames = {
RELAY_SETS: 'relaySets', RELAY_SETS: 'relaySets',
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays', FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays',
RELAY_INFOS: 'relayInfos', RELAY_INFOS: 'relayInfos',
DECRYPTED_CONTENTS: 'decryptedContents',
PINNED_USERS_EVENTS: 'pinnedUsersEvents',
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', // deprecated
RELAY_INFO_EVENTS: 'relayInfoEvents' // deprecated RELAY_INFO_EVENTS: 'relayInfoEvents' // deprecated
} }
@ -43,7 +45,7 @@ class IndexedDbService {
init(): Promise<void> { init(): Promise<void> {
if (!this.initPromise) { if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => { this.initPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', 9) const request = window.indexedDB.open('jumble', 10)
request.onerror = (event) => { request.onerror = (event) => {
reject(event) reject(event)
@ -71,8 +73,8 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.BOOKMARK_LIST_EVENTS)) { if (!db.objectStoreNames.contains(StoreNames.BOOKMARK_LIST_EVENTS)) {
db.createObjectStore(StoreNames.BOOKMARK_LIST_EVENTS, { keyPath: 'key' }) db.createObjectStore(StoreNames.BOOKMARK_LIST_EVENTS, { keyPath: 'key' })
} }
if (!db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) { if (!db.objectStoreNames.contains(StoreNames.DECRYPTED_CONTENTS)) {
db.createObjectStore(StoreNames.MUTE_DECRYPTED_TAGS, { keyPath: 'key' }) db.createObjectStore(StoreNames.DECRYPTED_CONTENTS, { keyPath: 'key' })
} }
if (!db.objectStoreNames.contains(StoreNames.FAVORITE_RELAYS)) { if (!db.objectStoreNames.contains(StoreNames.FAVORITE_RELAYS)) {
db.createObjectStore(StoreNames.FAVORITE_RELAYS, { keyPath: 'key' }) db.createObjectStore(StoreNames.FAVORITE_RELAYS, { keyPath: 'key' })
@ -98,9 +100,16 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.PIN_LIST_EVENTS)) { if (!db.objectStoreNames.contains(StoreNames.PIN_LIST_EVENTS)) {
db.createObjectStore(StoreNames.PIN_LIST_EVENTS, { keyPath: 'key' }) db.createObjectStore(StoreNames.PIN_LIST_EVENTS, { keyPath: 'key' })
} }
if (!db.objectStoreNames.contains(StoreNames.PINNED_USERS_EVENTS)) {
db.createObjectStore(StoreNames.PINNED_USERS_EVENTS, { keyPath: 'key' })
}
if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) { if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS) db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS)
} }
if (db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) {
db.deleteObjectStore(StoreNames.MUTE_DECRYPTED_TAGS)
}
this.db = db this.db = db
} }
}) })
@ -268,19 +277,19 @@ class IndexedDbService {
}) })
} }
async getMuteDecryptedTags(id: string): Promise<string[][] | null> { async getDecryptedContent(key: string): Promise<string | null> {
await this.initPromise await this.initPromise
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!this.db) { if (!this.db) {
return reject('database not initialized') return reject('database not initialized')
} }
const transaction = this.db.transaction(StoreNames.MUTE_DECRYPTED_TAGS, 'readonly') const transaction = this.db.transaction(StoreNames.DECRYPTED_CONTENTS, 'readonly')
const store = transaction.objectStore(StoreNames.MUTE_DECRYPTED_TAGS) const store = transaction.objectStore(StoreNames.DECRYPTED_CONTENTS)
const request = store.get(id) const request = store.get(key)
request.onsuccess = () => { request.onsuccess = () => {
transaction.commit() transaction.commit()
resolve((request.result as TValue<string[][]>)?.value) resolve((request.result as TValue<string>)?.value)
} }
request.onerror = (event) => { request.onerror = (event) => {
@ -290,16 +299,16 @@ class IndexedDbService {
}) })
} }
async putMuteDecryptedTags(id: string, tags: string[][]): Promise<void> { async putDecryptedContent(key: string, content: string): Promise<void> {
await this.initPromise await this.initPromise
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!this.db) { if (!this.db) {
return reject('database not initialized') return reject('database not initialized')
} }
const transaction = this.db.transaction(StoreNames.MUTE_DECRYPTED_TAGS, 'readwrite') const transaction = this.db.transaction(StoreNames.DECRYPTED_CONTENTS, 'readwrite')
const store = transaction.objectStore(StoreNames.MUTE_DECRYPTED_TAGS) const store = transaction.objectStore(StoreNames.DECRYPTED_CONTENTS)
const putRequest = store.put(this.formatValue(id, tags)) const putRequest = store.put(this.formatValue(key, content))
putRequest.onsuccess = () => { putRequest.onsuccess = () => {
transaction.commit() transaction.commit()
resolve() resolve()
@ -471,6 +480,8 @@ class IndexedDbService {
return StoreNames.EMOJI_SET_EVENTS return StoreNames.EMOJI_SET_EVENTS
case kinds.Pinlist: case kinds.Pinlist:
return StoreNames.PIN_LIST_EVENTS return StoreNames.PIN_LIST_EVENTS
case ExtendedKind.PINNED_USERS:
return StoreNames.PINNED_USERS_EVENTS
default: default:
return undefined return undefined
} }

View file

@ -57,7 +57,6 @@ class LocalStorageService {
private enableSingleColumnLayout: boolean = true private enableSingleColumnLayout: boolean = true
private faviconUrlTemplate: string = DEFAULT_FAVICON_URL_TEMPLATE private faviconUrlTemplate: string = DEFAULT_FAVICON_URL_TEMPLATE
private filterOutOnionRelays: boolean = !isTorBrowser() private filterOutOnionRelays: boolean = !isTorBrowser()
private pinnedPubkeys: Set<string> = new Set()
constructor() { constructor() {
if (!LocalStorageService.instance) { if (!LocalStorageService.instance) {
@ -231,12 +230,8 @@ class LocalStorageService {
this.filterOutOnionRelays = filterOutOnionRelaysStr !== 'false' this.filterOutOnionRelays = filterOutOnionRelaysStr !== 'false'
} }
const pinnedPubkeysStr = window.localStorage.getItem(StorageKey.PINNED_PUBKEYS)
if (pinnedPubkeysStr) {
this.pinnedPubkeys = new Set(JSON.parse(pinnedPubkeysStr))
}
// Clean up deprecated data // Clean up deprecated data
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
window.localStorage.removeItem(StorageKey.ACCOUNT_RELAY_LIST_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_RELAY_LIST_EVENT_MAP)
@ -564,18 +559,6 @@ class LocalStorageService {
this.filterOutOnionRelays = filterOut this.filterOutOnionRelays = filterOut
window.localStorage.setItem(StorageKey.FILTER_OUT_ONION_RELAYS, filterOut.toString()) window.localStorage.setItem(StorageKey.FILTER_OUT_ONION_RELAYS, filterOut.toString())
} }
getPinnedPubkeys(): Set<string> {
return this.pinnedPubkeys
}
setPinnedPubkeys(pinnedPubkeys: Set<string>) {
this.pinnedPubkeys = pinnedPubkeys
window.localStorage.setItem(
StorageKey.PINNED_PUBKEYS,
JSON.stringify(Array.from(this.pinnedPubkeys))
)
}
} }
const instance = new LocalStorageService() const instance = new LocalStorageService()

View file

@ -1,5 +1,4 @@
import { getEventKey } from '@/lib/event' import { getEventKey } from '@/lib/event'
import storage from '@/services/local-storage.service'
import { TFeedSubRequest } from '@/types' import { TFeedSubRequest } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@ -14,7 +13,6 @@ export type TUserAggregation = {
class UserAggregationService { class UserAggregationService {
static instance: UserAggregationService static instance: UserAggregationService
private pinnedPubkeys: Set<string> = new Set()
private aggregationStore: Map<string, Map<string, Event[]>> = new Map() private aggregationStore: Map<string, Map<string, Event[]>> = new Map()
private listenersMap: Map<string, Set<() => void>> = new Map() private listenersMap: Map<string, Set<() => void>> = new Map()
private lastViewedMap: Map<string, number> = new Map() private lastViewedMap: Map<string, number> = new Map()
@ -24,7 +22,6 @@ class UserAggregationService {
return UserAggregationService.instance return UserAggregationService.instance
} }
UserAggregationService.instance = this UserAggregationService.instance = this
this.pinnedPubkeys = storage.getPinnedPubkeys()
} }
subscribeAggregationChange(feedId: string, pubkey: string, listener: () => void) { subscribeAggregationChange(feedId: string, pubkey: string, listener: () => void) {
@ -64,33 +61,6 @@ class UserAggregationService {
} }
} }
// Pinned users management
getPinnedPubkeys(): string[] {
return [...this.pinnedPubkeys]
}
isPinned(pubkey: string): boolean {
return this.pinnedPubkeys.has(pubkey)
}
pinUser(pubkey: string) {
this.pinnedPubkeys.add(pubkey)
storage.setPinnedPubkeys(this.pinnedPubkeys)
}
unpinUser(pubkey: string) {
this.pinnedPubkeys.delete(pubkey)
storage.setPinnedPubkeys(this.pinnedPubkeys)
}
togglePin(pubkey: string) {
if (this.isPinned(pubkey)) {
this.unpinUser(pubkey)
} else {
this.pinUser(pubkey)
}
}
// Aggregate events by user // Aggregate events by user
aggregateByUser(events: Event[]): TUserAggregation[] { aggregateByUser(events: Event[]): TUserAggregation[] {
const userEventsMap = new Map<string, Event[]>() const userEventsMap = new Map<string, Event[]>()
@ -125,21 +95,6 @@ class UserAggregationService {
}) })
} }
sortWithPinned(aggregations: TUserAggregation[]): TUserAggregation[] {
const pinned: TUserAggregation[] = []
const unpinned: TUserAggregation[] = []
aggregations.forEach((agg) => {
if (this.isPinned(agg.pubkey)) {
pinned.push(agg)
} else {
unpinned.push(agg)
}
})
return [...pinned, ...unpinned]
}
saveAggregations(feedId: string, aggregations: TUserAggregation[]) { saveAggregations(feedId: string, aggregations: TUserAggregation[]) {
const map = new Map<string, Event[]>() const map = new Map<string, Event[]>()
aggregations.forEach((agg) => map.set(agg.pubkey, agg.events)) aggregations.forEach((agg) => map.set(agg.pubkey, agg.events))