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

@@ -12,6 +12,7 @@ import { KindFilterProvider } from '@/providers/KindFilterProvider'
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
import { MuteListProvider } from '@/providers/MuteListProvider'
import { NostrProvider } from '@/providers/NostrProvider'
import { PinListProvider } from '@/providers/PinListProvider'
import { ReplyProvider } from '@/providers/ReplyProvider'
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
import { ThemeProvider } from '@/providers/ThemeProvider'
@@ -35,18 +36,20 @@ export default function App(): JSX.Element {
<MuteListProvider>
<UserTrustProvider>
<BookmarksProvider>
<FeedProvider>
<ReplyProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<UserPreferencesProvider>
<PageManager />
<Toaster />
</UserPreferencesProvider>
</KindFilterProvider>
</MediaUploadServiceProvider>
</ReplyProvider>
</FeedProvider>
<PinListProvider>
<FeedProvider>
<ReplyProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<UserPreferencesProvider>
<PageManager />
<Toaster />
</UserPreferencesProvider>
</KindFilterProvider>
</MediaUploadServiceProvider>
</ReplyProvider>
</FeedProvider>
</PinListProvider>
</BookmarksProvider>
</UserTrustProvider>
</MuteListProvider>

View File

@@ -1,10 +1,12 @@
import { Separator } from '@/components/ui/separator'
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { Event } from 'nostr-tools'
import Collapsible from '../Collapsible'
import Note from '../Note'
import NoteStats from '../NoteStats'
import PinnedButton from './PinnedButton'
import RepostDescription from './RepostDescription'
export default function MainNoteCard({
@@ -12,13 +14,15 @@ export default function MainNoteCard({
className,
reposter,
embedded,
originalNoteId
originalNoteId,
pinned = false
}: {
event: Event
className?: string
reposter?: string
embedded?: boolean
originalNoteId?: string
pinned?: boolean
}) {
const { push } = useSecondaryPage()
@@ -30,8 +34,9 @@ export default function MainNoteCard({
push(toNote(originalNoteId ?? event))
}}
>
<div className={`clickable ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'py-3'}`}>
<div className={cn('clickable', embedded ? 'p-2 sm:p-3 border rounded-lg' : 'py-3')}>
<Collapsible alwaysExpand={embedded}>
{pinned && <PinnedButton event={event} />}
<RepostDescription className={embedded ? '' : 'px-4'} reposter={reposter} />
<Note
className={embedded ? '' : 'px-4'}

View File

@@ -0,0 +1,46 @@
import { Button } from '@/components/ui/button'
import { useNostr } from '@/providers/NostrProvider'
import { usePinList } from '@/providers/PinListProvider'
import { Loader, Pin } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function PinnedButton({ event }: { event: NostrEvent }) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { unpin } = usePinList()
const [hovered, setHovered] = useState(false)
const [unpinning, setUnpinning] = useState(false)
if (event.pubkey !== pubkey) {
return (
<div className="flex gap-1 text-sm items-center text-primary mb-1 px-4 py-0 h-fit">
<Pin size={16} className="shrink-0" />
{t('Pinned')}
</div>
)
}
return (
<Button
className="flex gap-1 text-sm text-muted-foreground items-center mb-1 px-4 py-0.5 h-fit hover:text-foreground"
variant="link"
onClick={(e) => {
e.stopPropagation()
setUnpinning(true)
unpin(event).finally(() => setUnpinning(false))
}}
disabled={unpinning}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{unpinning ? (
<Loader size={16} className="animate-spin shrink-0" />
) : (
<Pin size={16} className="shrink-0" />
)}
{unpinning ? t('Unpinning') : hovered ? t('Unpin') : t('Pinned')}
</Button>
)
}

View File

@@ -10,11 +10,13 @@ import MainNoteCard from './MainNoteCard'
export default function RepostNoteCard({
event,
className,
filterMutedNotes = true
filterMutedNotes = true,
pinned = false
}: {
event: Event
className?: string
filterMutedNotes?: boolean
pinned?: boolean
}) {
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
@@ -71,5 +73,12 @@ export default function RepostNoteCard({
if (!targetEvent || shouldHide) return null
return <MainNoteCard className={className} reposter={event.pubkey} event={targetEvent} />
return (
<MainNoteCard
className={className}
reposter={event.pubkey}
event={targetEvent}
pinned={pinned}
/>
)
}

View File

@@ -10,11 +10,13 @@ import RepostNoteCard from './RepostNoteCard'
export default function NoteCard({
event,
className,
filterMutedNotes = true
filterMutedNotes = true,
pinned = false
}: {
event: Event
className?: string
filterMutedNotes?: boolean
pinned?: boolean
}) {
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
@@ -31,10 +33,15 @@ export default function NoteCard({
if (event.kind === kinds.Repost) {
return (
<RepostNoteCard event={event} className={className} filterMutedNotes={filterMutedNotes} />
<RepostNoteCard
event={event}
className={className}
filterMutedNotes={filterMutedNotes}
pinned={pinned}
/>
)
}
return <MainNoteCard event={event} className={className} />
return <MainNoteCard event={event} className={className} pinned={pinned} />
}
export function NoteCardLoadingSkeleton() {

View File

@@ -43,7 +43,8 @@ const NoteList = forwardRef(
hideReplies = false,
hideUntrustedNotes = false,
areAlgoRelays = false,
showRelayCloseReason = false
showRelayCloseReason = false,
filteredEventHexIdSet = new Set<string>()
}: {
subRequests: TFeedSubRequest[]
showKinds: number[]
@@ -52,6 +53,7 @@ const NoteList = forwardRef(
hideUntrustedNotes?: boolean
areAlgoRelays?: boolean
showRelayCloseReason?: boolean
filteredEventHexIdSet?: Set<string>
},
ref
) => {
@@ -74,6 +76,7 @@ const NoteList = forwardRef(
const shouldHideEvent = useCallback(
(evt: Event) => {
if (filteredEventHexIdSet.has(evt.id)) return true
if (isEventDeleted(evt)) return true
if (hideReplies && isReplyNoteEvent(evt)) return true
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true
@@ -88,7 +91,7 @@ const NoteList = forwardRef(
return false
},
[hideReplies, hideUntrustedNotes, mutePubkeySet, isEventDeleted]
[hideReplies, hideUntrustedNotes, mutePubkeySet, filteredEventHexIdSet, isEventDeleted]
)
const filteredEvents = useMemo(() => {

View File

@@ -6,9 +6,21 @@ import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { usePinList } from '@/providers/PinListProvider'
import client from '@/services/client.service'
import { Bell, BellOff, Code, Copy, Link, SatelliteDish, Trash2, TriangleAlert } from 'lucide-react'
import { Event } from 'nostr-tools'
import {
Bell,
BellOff,
Code,
Copy,
Link,
Pin,
PinOff,
SatelliteDish,
Trash2,
TriangleAlert
} from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -55,6 +67,7 @@ export function useMenuActions({
return Array.from(new Set(currentBrowsingRelayUrls.concat(favoriteRelays)))
}, [currentBrowsingRelayUrls, favoriteRelays])
const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList()
const { pinnedEventHexIdSet, pin, unpin } = usePinList()
const isMuted = useMemo(() => mutePubkeySet.has(event.pubkey), [mutePubkeySet, event])
const broadcastSubMenu: SubMenuAction[] = useMemo(() => {
@@ -195,6 +208,18 @@ export function useMenuActions({
})
}
if (event.pubkey === pubkey && event.kind === kinds.ShortTextNote) {
const pinned = pinnedEventHexIdSet.has(event.id)
actions.push({
icon: pinned ? PinOff : Pin,
label: pinned ? t('Unpin from profile') : t('Pin to profile'),
onClick: async () => {
closeDrawer()
await (pinned ? unpin(event) : pin(event))
}
})
}
if (pubkey && event.pubkey !== pubkey) {
actions.push({
icon: TriangleAlert,
@@ -266,6 +291,7 @@ export function useMenuActions({
isMuted,
isSmallScreen,
broadcastSubMenu,
pinnedEventHexIdSet,
closeDrawer,
showSubMenuActions,
setIsRawEventDialogOpen,

View File

@@ -1,14 +1,18 @@
import KindFilter from '@/components/KindFilter'
import NoteList, { TNoteListRef } from '@/components/NoteList'
import Tabs from '@/components/Tabs'
import { BIG_RELAY_URLS } from '@/constants'
import { BIG_RELAY_URLS, MAX_PINNED_NOTES } from '@/constants'
import { useFetchEvent } from '@/hooks'
import { generateBech32IdFromETag } from '@/lib/tag'
import { isTouchDevice } from '@/lib/utils'
import { useKindFilter } from '@/providers/KindFilterProvider'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import storage from '@/services/local-storage.service'
import { TFeedSubRequest, TNoteListMode } from '@/types'
import { NostrEvent } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
import { RefreshButton } from '../RefreshButton'
export default function ProfileFeed({
@@ -18,12 +22,13 @@ export default function ProfileFeed({
pubkey: string
topSpace?: number
}) {
const { pubkey: myPubkey } = useNostr()
const { pubkey: myPubkey, pinListEvent: myPinListEvent } = useNostr()
const { showKinds } = useKindFilter()
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
const [listMode, setListMode] = useState<TNoteListMode>(() => storage.getNoteListMode())
const noteListRef = useRef<TNoteListRef>(null)
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
const [pinnedEventIds, setPinnedEventIds] = useState<string[]>([])
const [pinnedEventHexIdSet, setPinnedEventHexIdSet] = useState<Set<string>>(new Set())
const tabs = useMemo(() => {
const _tabs = [
{ value: 'posts', label: 'Notes' },
@@ -37,6 +42,41 @@ export default function ProfileFeed({
return _tabs
}, [myPubkey, pubkey])
const supportTouch = useMemo(() => isTouchDevice(), [])
const noteListRef = useRef<TNoteListRef>(null)
const topRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const initPinnedEventIds = async () => {
let evt: NostrEvent | null = null
if (pubkey === myPubkey) {
evt = myPinListEvent
} else {
evt = await client.fetchPinListEvent(pubkey)
}
const hexIdSet = new Set<string>()
const ids =
(evt?.tags
.filter((tag) => tag[0] === 'e')
.reverse()
.slice(0, MAX_PINNED_NOTES)
.map((tag) => {
const [, hexId, relay, _pubkey] = tag
if (!hexId || hexIdSet.has(hexId) || (_pubkey && _pubkey !== pubkey)) {
return undefined
}
const id = generateBech32IdFromETag(['e', hexId, relay ?? '', pubkey])
if (id) {
hexIdSet.add(hexId)
}
return id
})
.filter(Boolean) as string[]) ?? []
setPinnedEventIds(ids)
setPinnedEventHexIdSet(hexIdSet)
}
initPinnedEventIds()
}, [pubkey, myPubkey, myPinListEvent])
useEffect(() => {
const init = async () => {
@@ -85,12 +125,12 @@ export default function ProfileFeed({
const handleListModeChange = (mode: TNoteListMode) => {
setListMode(mode)
noteListRef.current?.scrollToTop('smooth')
topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
const handleShowKindsChange = (newShowKinds: number[]) => {
setTemporaryShowKinds(newShowKinds)
noteListRef.current?.scrollToTop()
topRef.current?.scrollIntoView({ behavior: 'instant', block: 'start' })
}
return (
@@ -109,13 +149,32 @@ export default function ProfileFeed({
</>
}
/>
<div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
{pinnedEventIds.map((eventId) => (
<PinnedNote key={eventId} eventId={eventId} />
))}
<NoteList
ref={noteListRef}
subRequests={subRequests}
showKinds={temporaryShowKinds}
hideReplies={listMode === 'posts'}
filterMutedNotes={false}
filteredEventHexIdSet={pinnedEventHexIdSet}
/>
</>
)
}
function PinnedNote({ eventId }: { eventId: string }) {
const { event, isFetching } = useFetchEvent(eventId)
if (isFetching) {
return <NoteCardLoadingSkeleton />
}
if (!event) {
return null
}
return <NoteCard event={event} className="w-full" pinned />
}

View File

@@ -153,3 +153,5 @@ export const MEDIA_AUTO_LOAD_POLICY = {
WIFI_ONLY: 'wifi-only',
NEVER: 'never'
} as const
export const MAX_PINNED_NOTES = 10

View File

@@ -444,6 +444,17 @@ export default {
'Paste your one-time code here': 'الصق رمز الاستخدام مرة واحدة هنا',
Connect: 'اتصال',
'Set up your wallet to send and receive sats!': 'قم بإعداد محفظتك لإرسال واستقبال الساتس!',
'Set up': 'إعداد'
'Set up': 'إعداد',
Pinned: 'مثبت',
Unpin: 'إلغاء التثبيت',
Unpinning: 'جارٍ إلغاء التثبيت',
'Pinning...': 'جارٍ التثبيت...',
'Pinned!': 'تم التثبيت!',
'Failed to pin: {{error}}': 'فشل في التثبيت: {{error}}',
'Unpinning...': 'جارٍ إلغاء التثبيت...',
'Unpinned!': 'تم إلغاء التثبيت!',
'Failed to unpin: {{error}}': 'فشل في إلغاء التثبيت: {{error}}',
'Unpin from profile': 'إلغاء التثبيت من الملف الشخصي',
'Pin to profile': 'تثبيت في الملف الشخصي'
}
}

View File

@@ -458,6 +458,17 @@ export default {
Connect: 'Verbinden',
'Set up your wallet to send and receive sats!':
'Richte deine Wallet ein, um Sats zu senden und zu empfangen!',
'Set up': 'Einrichten'
'Set up': 'Einrichten',
Pinned: 'Angepinnt',
Unpin: 'Anheften aufheben',
Unpinning: 'Anheften wird aufgehoben',
'Pinning...': 'Wird angepinnt...',
'Pinned!': 'Angepinnt!',
'Failed to pin: {{error}}': 'Fehler beim Anpinnen: {{error}}',
'Unpinning...': 'Anheften wird aufgehoben...',
'Unpinned!': 'Anheften aufgehoben!',
'Failed to unpin: {{error}}': 'Fehler beim Anheften aufheben: {{error}}',
'Unpin from profile': 'Vom Profil lösen',
'Pin to profile': 'An Profil anheften'
}
}

View File

@@ -443,6 +443,17 @@ export default {
'Paste your one-time code here': 'Paste your one-time code here',
Connect: 'Connect',
'Set up your wallet to send and receive sats!': 'Set up your wallet to send and receive sats!',
'Set up': 'Set up'
'Set up': 'Set up',
Pinned: 'Pinned',
Unpin: 'Unpin',
Unpinning: 'Unpinning',
'Pinning...': 'Pinning...',
'Pinned!': 'Pinned!',
'Failed to pin: {{error}}': 'Failed to pin: {{error}}',
'Unpinning...': 'Unpinning...',
'Unpinned!': 'Unpinned!',
'Failed to unpin: {{error}}': 'Failed to unpin: {{error}}',
'Unpin from profile': 'Unpin from profile',
'Pin to profile': 'Pin to profile'
}
}

View File

@@ -452,6 +452,17 @@ export default {
Connect: 'Conectar',
'Set up your wallet to send and receive sats!':
'¡Configura tu billetera para enviar y recibir sats!',
'Set up': 'Configurar'
'Set up': 'Configurar',
Pinned: 'Fijado',
Unpin: 'Desfijar',
Unpinning: 'Desfijando',
'Pinning...': 'Fijando...',
'Pinned!': '¡Fijado!',
'Failed to pin: {{error}}': 'Error al fijar: {{error}}',
'Unpinning...': 'Desfijando...',
'Unpinned!': '¡Desfijado!',
'Failed to unpin: {{error}}': 'Error al desfijar: {{error}}',
'Unpin from profile': 'Desfijar del perfil',
'Pin to profile': 'Fijar al perfil'
}
}

View File

@@ -447,6 +447,17 @@ export default {
Connect: 'اتصال',
'Set up your wallet to send and receive sats!':
'کیف پولت را تنظیم کن تا ساتس ارسال و دریافت کنی!',
'Set up': 'تنظیم'
'Set up': 'تنظیم',
Pinned: 'پین شده',
Unpin: 'لغو پین',
Unpinning: 'در حال لغو پین...',
'Pinning...': 'در حال پین کردن...',
'Pinned!': 'پین شد!',
'Failed to pin: {{error}}': 'پین کردن ناموفق بود: {{error}}',
'Unpinning...': 'در حال لغو پین...',
'Unpinned!': 'لغو پین شد!',
'Failed to unpin: {{error}}': 'لغو پین ناموفق بود: {{error}}',
'Unpin from profile': 'لغو پین از پروفایل',
'Pin to profile': 'پین به پروفایل'
}
}

View File

@@ -457,6 +457,17 @@ export default {
Connect: 'Connecter',
'Set up your wallet to send and receive sats!':
'Configurez votre portefeuille pour envoyer et recevoir des sats !',
'Set up': 'Configurer'
'Set up': 'Configurer',
Pinned: 'Épinglé',
Unpin: 'Retirer lépingle',
Unpinning: 'Retrait de lépingle',
'Pinning...': 'Épinglage en cours...',
'Pinned!': 'Épinglé !',
'Failed to pin: {{error}}': 'Échec de lépinglage : {{error}}',
'Unpinning...': 'Retrait de lépingle en cours...',
'Unpinned!': 'Retrait de lépingle effectué !',
'Failed to unpin: {{error}}': 'Échec du retrait de lépingle : {{error}}',
'Unpin from profile': 'Retirer lépingle du profil',
'Pin to profile': 'Épingler au profil'
}
}

View File

@@ -449,6 +449,17 @@ export default {
Connect: 'कनेक्ट करें',
'Set up your wallet to send and receive sats!':
'सैट्स भेजने और प्राप्त करने के लिए अपना वॉलेट सेट करें!',
'Set up': 'सेट करें'
'Set up': 'सेट करें',
Pinned: 'पिन किया गया',
Unpin: 'पिन हटाएं',
Unpinning: 'पिन हटाया जा रहा है',
'Pinning...': 'पिन कर रहे हैं...',
'Pinned!': 'पिन किया गया!',
'Failed to pin: {{error}}': 'पिन करने में असफल: {{error}}',
'Unpinning...': 'पिन हटाया जा रहा है...',
'Unpinned!': 'पिन हटा दिया गया!',
'Failed to unpin: {{error}}': 'पिन हटाने में असफल: {{error}}',
'Unpin from profile': 'प्रोफ़ाइल से पिन हटाएं',
'Pin to profile': 'प्रोफ़ाइल पर पिन करें'
}
}

View File

@@ -452,6 +452,17 @@ export default {
Connect: 'Connetti',
'Set up your wallet to send and receive sats!':
'Configura il tuo wallet per inviare e ricevere sats!',
'Set up': 'Configura'
'Set up': 'Configura',
Pinned: 'Fissato',
Unpin: 'Rimuovi fissaggio',
Unpinning: 'Rimozione fissaggio',
'Pinning...': 'Fissaggio in corso...',
'Pinned!': 'Fissato!',
'Failed to pin: {{error}}': 'Failed to pin: {{error}}',
'Unpinning...': 'Rimozione fissaggio in corso...',
'Unpinned!': 'Rimosso fissaggio!',
'Failed to unpin: {{error}}': 'Impossibile rimuovere il fissaggio: {{error}}',
'Unpin from profile': 'Rimuovi fissaggio dal profilo',
'Pin to profile': 'Fissa al profilo'
}
}

View File

@@ -448,6 +448,17 @@ export default {
Connect: '接続',
'Set up your wallet to send and receive sats!':
'ウォレットを設定してサッツを送受信しましょう!',
'Set up': '設定する'
'Set up': '設定する',
Pinned: '固定済み',
Unpin: '固定解除',
Unpinning: '固定解除中',
'Pinning...': '固定中...',
'Pinned!': '固定されました!',
'Failed to pin: {{error}}': '固定に失敗しました: {{error}}',
'Unpinning...': '固定解除中...',
'Unpinned!': '固定が解除されました!',
'Failed to unpin: {{error}}': '固定解除に失敗しました: {{error}}',
'Unpin from profile': 'プロフィールから固定解除',
'Pin to profile': 'プロフィールに固定'
}
}

View File

@@ -448,6 +448,17 @@ export default {
Connect: '연결',
'Set up your wallet to send and receive sats!':
'사츠를 보내고 받을 수 있도록 지갑을 설정하세요!',
'Set up': '설정하기'
'Set up': '설정하기',
Pinned: '고정됨',
Unpin: '고정 해제',
Unpinning: '고정 해제 중',
'Pinning...': '고정 중...',
'Pinned!': '고정됨!',
'Failed to pin: {{error}}': '고정 실패: {{error}}',
'Unpinning...': '고정 해제 중...',
'Unpinned!': '고정 해제됨!',
'Failed to unpin: {{error}}': '고정 해제 실패: {{error}}',
'Unpin from profile': '프로필에서 고정 해제',
'Pin to profile': '프로필에 고정'
}
}

View File

@@ -452,6 +452,17 @@ export default {
Connect: 'Połącz',
'Set up your wallet to send and receive sats!':
'Skonfiguruj swój portfel, aby wysyłać i odbierać satsy!',
'Set up': 'Skonfiguruj'
'Set up': 'Skonfiguruj',
Pinned: 'Przypięte',
Unpin: 'Odpiń',
Unpinning: 'Odpinanie',
'Pinning...': 'Przypinanie...',
'Pinned!': 'Przypięte!',
'Failed to pin: {{error}}': 'Nie udało się przypiąć: {{error}}',
'Unpinning...': 'Odpinanie...',
'Unpinned!': 'Odpięte!',
'Failed to unpin: {{error}}': 'Nie udało się przypiąć: {{error}}',
'Unpin from profile': 'Odpiń z profilu',
'Pin to profile': 'Przypnij do profilu'
}
}

View File

@@ -449,6 +449,17 @@ export default {
Connect: 'Conectar',
'Set up your wallet to send and receive sats!':
'Configure sua carteira para enviar e receber sats!',
'Set up': 'Configurar'
'Set up': 'Configurar',
Pinned: 'Fixado',
Unpin: 'Desafixar',
Unpinning: 'Desafixando',
'Pinning...': 'Fixando...',
'Pinned!': 'Fixado!',
'Failed to pin: {{error}}': 'Falha ao fixar: {{error}}',
'Unpinning...': 'Desafixando...',
'Unpinned!': 'Desafixado!',
'Failed to unpin: {{error}}': 'Falha ao desafixar: {{error}}',
'Unpin from profile': 'Desafixar do perfil',
'Pin to profile': 'Fixar no perfil'
}
}

View File

@@ -452,6 +452,17 @@ export default {
Connect: 'Conectar',
'Set up your wallet to send and receive sats!':
'Configure a sua carteira para enviar e receber sats!',
'Set up': 'Configurar'
'Set up': 'Configurar',
Pinned: 'Fixado',
Unpin: 'Desafixar',
Unpinning: 'Desafixando',
'Pinning...': 'Fixando...',
'Pinned!': 'Fixado!',
'Failed to pin: {{error}}': 'Falha ao fixar: {{error}}',
'Unpinning...': 'Desafixando...',
'Unpinned!': 'Desafixado!',
'Failed to unpin: {{error}}': 'Falha ao desafixar: {{error}}',
'Unpin from profile': 'Desafixar do perfil',
'Pin to profile': 'Fixar no perfil'
}
}

View File

@@ -454,6 +454,17 @@ export default {
Connect: 'Подключить',
'Set up your wallet to send and receive sats!':
'Настройте свой кошелёк, чтобы отправлять и получать саты!',
'Set up': 'Настроить'
'Set up': 'Настроить',
Pinned: 'Закреплено',
Unpin: 'Открепить',
Unpinning: 'Открепление',
'Pinning...': 'Закрепление...',
'Pinned!': 'Закреплено!',
'Failed to pin: {{error}}': 'Не удалось закрепить: {{error}}',
'Unpinning...': 'Открепление...',
'Unpinned!': 'Откреплено!',
'Failed to unpin: {{error}}': 'Не удалось открепить: {{error}}',
'Unpin from profile': 'Открепить из профиля',
'Pin to profile': 'Закрепить в профиле'
}
}

View File

@@ -442,6 +442,17 @@ export default {
'Paste your one-time code here': 'วางรหัสใช้ครั้งเดียวของคุณที่นี่',
Connect: 'เชื่อมต่อ',
'Set up your wallet to send and receive sats!': 'ตั้งค่ากระเป๋าของคุณเพื่อส่งและรับ sats!',
'Set up': 'ตั้งค่า'
'Set up': 'ตั้งค่า',
Pinned: 'ปักหมุดแล้ว',
Unpin: 'ยกเลิกปักหมุด',
Unpinning: 'กำลังยกเลิกปักหมุด',
'Pinning...': 'กำลังปักหมุด...',
'Pinned!': 'ปักหมุดแล้ว!',
'Failed to pin: {{error}}': 'ไม่สามารถปักหมุดได้: {{error}}',
'Unpinning...': 'กำลังยกเลิกปักหมุด...',
'Unpinned!': 'ยกเลิกปักหมุดแล้ว!',
'Failed to unpin: {{error}}': 'ไม่สามารถยกเลิกปักหมุดได้: {{error}}',
'Unpin from profile': 'ยกเลิกปักหมุดจากโปรไฟล์',
'Pin to profile': 'ปักหมุดไปที่โปรไฟล์'
}
}

View File

@@ -440,6 +440,17 @@ export default {
'Paste your one-time code here': '将您的一次性代码粘贴到此处',
Connect: '连接',
'Set up your wallet to send and receive sats!': '设置你的钱包以发送和接收 sats',
'Set up': '去设置'
'Set up': '去设置',
Pinned: '已置顶',
Unpin: '取消置顶',
Unpinning: '取消置顶中',
'Pinning...': '置顶中...',
'Pinned!': '已置顶!',
'Failed to pin: {{error}}': '置顶失败: {{error}}',
'Unpinning...': '取消置顶中...',
'Unpinned!': '已取消置顶!',
'Failed to unpin: {{error}}': '取消置顶失败: {{error}}',
'Unpin from profile': '从个人资料取消置顶',
'Pin to profile': '置顶到个人资料'
}
}

View File

@@ -332,6 +332,15 @@ export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraft
}
}
export function createPinListDraftEvent(tags: string[][], content = ''): TDraftEvent {
return {
kind: kinds.Pinlist,
content,
tags,
created_at: dayjs().unix()
}
}
export function createBlossomServerListDraftEvent(servers: string[]): TDraftEvent {
return {
kind: ExtendedKind.BLOSSOM_SERVER_LIST,

View File

@@ -1,4 +1,4 @@
import { BIG_RELAY_URLS, POLL_TYPE } from '@/constants'
import { BIG_RELAY_URLS, MAX_PINNED_NOTES, POLL_TYPE } from '@/constants'
import { TEmoji, TPollType, TRelayList, TRelaySet } from '@/types'
import { Event, kinds } from 'nostr-tools'
import { buildATag } from './draft-event'
@@ -380,3 +380,13 @@ export function getStarsFromRelayReviewEvent(event: Event): number {
}
return 0
}
export function getPinnedEventHexIdSetFromPinListEvent(event?: Event | null): Set<string> {
return new Set(
event?.tags
.filter((tag) => tag[0] === 'e')
.map((tag) => tag[1])
.reverse()
.slice(0, MAX_PINNED_NOTES) ?? []
)
}

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>
)
}

View File

@@ -838,14 +838,13 @@ class ClientService extends EventTarget {
}
let event: NEvent | undefined
if (filter.ids) {
if (filter.ids?.length) {
event = await this.fetchEventById(relays, filter.ids[0])
} else {
if (author) {
const relayList = await this.fetchRelayList(author)
relays.push(...relayList.write.slice(0, 4))
}
event = await this.tryHarderToFetchEvent(relays, filter)
}
if (!event && author) {
const relayList = await this.fetchRelayList(author)
event = await this.tryHarderToFetchEvent(relayList.write.slice(0, 5), filter)
}
if (event && event.id !== id) {
@@ -1261,6 +1260,8 @@ class ClientService extends EventTarget {
return params.map(({ pubkey, kind, d }) => {
const key = `${kind}:${pubkey}:${d ?? ''}`
const event = eventMap.get(key)
if (kind === kinds.Pinlist) return event ?? null
if (event) {
indexedDb.putReplaceableEvent(event)
return event
@@ -1321,6 +1322,10 @@ class ClientService extends EventTarget {
return evt ? getServersFromServerTags(evt.tags) : []
}
async fetchPinListEvent(pubkey: string) {
return this.fetchReplaceableEvent(pubkey, kinds.Pinlist)
}
async updateBlossomServerListEventCache(evt: NEvent) {
await this.updateReplaceableEventCache(evt)
}

View File

@@ -19,6 +19,7 @@ const StoreNames = {
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents',
EMOJI_SET_EVENTS: 'emojiSetEvents',
PIN_LIST_EVENTS: 'pinListEvents',
FAVORITE_RELAYS: 'favoriteRelays',
RELAY_SETS: 'relaySets',
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays',
@@ -42,7 +43,7 @@ class IndexedDbService {
init(): Promise<void> {
if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', 8)
const request = window.indexedDB.open('jumble', 9)
request.onerror = (event) => {
reject(event)
@@ -94,6 +95,9 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.RELAY_INFOS)) {
db.createObjectStore(StoreNames.RELAY_INFOS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.PIN_LIST_EVENTS)) {
db.createObjectStore(StoreNames.PIN_LIST_EVENTS, { keyPath: 'key' })
}
if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS)
}
@@ -459,6 +463,8 @@ class IndexedDbService {
return StoreNames.USER_EMOJI_LIST_EVENTS
case kinds.Emojisets:
return StoreNames.EMOJI_SET_EVENTS
case kinds.Pinlist:
return StoreNames.PIN_LIST_EVENTS
default:
return undefined
}
@@ -492,6 +498,10 @@ class IndexedDbService {
{
name: StoreNames.RELAY_INFOS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 days
},
{
name: StoreNames.PIN_LIST_EVENTS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 days
}
]
const transaction = this.db!.transaction(