diff --git a/src/App.tsx b/src/App.tsx
index fee7dd5f..01506259 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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 {
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx
index 3b0252c1..a2b089a9 100644
--- a/src/components/NoteCard/MainNoteCard.tsx
+++ b/src/components/NoteCard/MainNoteCard.tsx
@@ -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))
}}
>
-
+
+ {pinned && }
+
+ {t('Pinned')}
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/NoteCard/RepostNoteCard.tsx b/src/components/NoteCard/RepostNoteCard.tsx
index ed8ed401..5cd562c5 100644
--- a/src/components/NoteCard/RepostNoteCard.tsx
+++ b/src/components/NoteCard/RepostNoteCard.tsx
@@ -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
+ return (
+
+ )
}
diff --git a/src/components/NoteCard/index.tsx b/src/components/NoteCard/index.tsx
index 9d4c70fc..5dd8c329 100644
--- a/src/components/NoteCard/index.tsx
+++ b/src/components/NoteCard/index.tsx
@@ -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 (
-
+
)
}
- return
+ return
}
export function NoteCardLoadingSkeleton() {
diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx
index c23e17cf..c2714cbd 100644
--- a/src/components/NoteList/index.tsx
+++ b/src/components/NoteList/index.tsx
@@ -43,7 +43,8 @@ const NoteList = forwardRef(
hideReplies = false,
hideUntrustedNotes = false,
areAlgoRelays = false,
- showRelayCloseReason = false
+ showRelayCloseReason = false,
+ filteredEventHexIdSet = new Set
()
}: {
subRequests: TFeedSubRequest[]
showKinds: number[]
@@ -52,6 +53,7 @@ const NoteList = forwardRef(
hideUntrustedNotes?: boolean
areAlgoRelays?: boolean
showRelayCloseReason?: boolean
+ filteredEventHexIdSet?: Set
},
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(() => {
diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx
index 68d494af..19d1c7ca 100644
--- a/src/components/NoteOptions/useMenuActions.tsx
+++ b/src/components/NoteOptions/useMenuActions.tsx
@@ -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,
diff --git a/src/components/Profile/ProfileFeed.tsx b/src/components/Profile/ProfileFeed.tsx
index dd5e5e22..1e6811da 100644
--- a/src/components/Profile/ProfileFeed.tsx
+++ b/src/components/Profile/ProfileFeed.tsx
@@ -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(() => storage.getNoteListMode())
- const noteListRef = useRef(null)
const [subRequests, setSubRequests] = useState([])
+ const [pinnedEventIds, setPinnedEventIds] = useState([])
+ const [pinnedEventHexIdSet, setPinnedEventHexIdSet] = useState>(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(null)
+ const topRef = useRef(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()
+ 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({
>
}
/>
+
+ {pinnedEventIds.map((eventId) => (
+
+ ))}
>
)
}
+
+function PinnedNote({ eventId }: { eventId: string }) {
+ const { event, isFetching } = useFetchEvent(eventId)
+
+ if (isFetching) {
+ return
+ }
+
+ if (!event) {
+ return null
+ }
+
+ return
+}
diff --git a/src/constants.ts b/src/constants.ts
index 4028264c..ec829540 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -153,3 +153,5 @@ export const MEDIA_AUTO_LOAD_POLICY = {
WIFI_ONLY: 'wifi-only',
NEVER: 'never'
} as const
+
+export const MAX_PINNED_NOTES = 10
diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts
index 46434875..d7452c01 100644
--- a/src/i18n/locales/ar.ts
+++ b/src/i18n/locales/ar.ts
@@ -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': 'تثبيت في الملف الشخصي'
}
}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 22ba28c0..850855ce 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 7ddef6cc..1b6ff43d 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts
index 818e2321..b2129736 100644
--- a/src/i18n/locales/es.ts
+++ b/src/i18n/locales/es.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/fa.ts b/src/i18n/locales/fa.ts
index aed1ded8..5bb6341c 100644
--- a/src/i18n/locales/fa.ts
+++ b/src/i18n/locales/fa.ts
@@ -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': 'پین به پروفایل'
}
}
diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts
index 5ceffa60..762f2bb9 100644
--- a/src/i18n/locales/fr.ts
+++ b/src/i18n/locales/fr.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts
index 9df8bc38..eb96b3ba 100644
--- a/src/i18n/locales/hi.ts
+++ b/src/i18n/locales/hi.ts
@@ -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': 'प्रोफ़ाइल पर पिन करें'
}
}
diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts
index 2974a639..58c797e9 100644
--- a/src/i18n/locales/it.ts
+++ b/src/i18n/locales/it.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts
index fa2b88ba..139bca1e 100644
--- a/src/i18n/locales/ja.ts
+++ b/src/i18n/locales/ja.ts
@@ -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': 'プロフィールに固定'
}
}
diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts
index 0f4305e5..51ce310a 100644
--- a/src/i18n/locales/ko.ts
+++ b/src/i18n/locales/ko.ts
@@ -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': '프로필에 고정'
}
}
diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts
index c97f8c99..6cde1b51 100644
--- a/src/i18n/locales/pl.ts
+++ b/src/i18n/locales/pl.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts
index c277523d..bd09f09c 100644
--- a/src/i18n/locales/pt-BR.ts
+++ b/src/i18n/locales/pt-BR.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts
index a2ff531f..04045aa1 100644
--- a/src/i18n/locales/pt-PT.ts
+++ b/src/i18n/locales/pt-PT.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts
index cdee508f..cdadf260 100644
--- a/src/i18n/locales/ru.ts
+++ b/src/i18n/locales/ru.ts
@@ -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': 'Закрепить в профиле'
}
}
diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts
index 864bade2..1040617c 100644
--- a/src/i18n/locales/th.ts
+++ b/src/i18n/locales/th.ts
@@ -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': 'ปักหมุดไปที่โปรไฟล์'
}
}
diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts
index eb818d8c..14b78af6 100644
--- a/src/i18n/locales/zh.ts
+++ b/src/i18n/locales/zh.ts
@@ -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': '置顶到个人资料'
}
}
diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts
index f982cd24..427b148a 100644
--- a/src/lib/draft-event.ts
+++ b/src/lib/draft-event.ts
@@ -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,
diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts
index f417f7e6..3464cec7 100644
--- a/src/lib/event-metadata.ts
+++ b/src/lib/event-metadata.ts
@@ -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 {
+ return new Set(
+ event?.tags
+ .filter((tag) => tag[0] === 'e')
+ .map((tag) => tag[1])
+ .reverse()
+ .slice(0, MAX_PINNED_NOTES) ?? []
+ )
+}
diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx
index a4071971..570f91a8 100644
--- a/src/providers/NostrProvider/index.tsx
+++ b/src/providers/NostrProvider/index.tsx
@@ -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
updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise
+ updatePinListEvent: (pinListEvent: Event) => Promise
updateNotificationsSeenAt: (skipPublish?: boolean) => Promise
}
@@ -119,6 +121,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [bookmarkListEvent, setBookmarkListEvent] = useState(null)
const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState(null)
const [userEmojiListEvent, setUserEmojiListEvent] = useState(null)
+ const [pinListEvent, setPinListEvent] = useState(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
}}
>
diff --git a/src/providers/PinListProvider.tsx b/src/providers/PinListProvider.tsx
new file mode 100644
index 00000000..9974daed
--- /dev/null
+++ b/src/providers/PinListProvider.tsx
@@ -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
+ pin: (event: Event) => Promise
+ unpin: (event: Event) => Promise
+}
+
+const PinListContext = createContext(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 (
+
+ {children}
+
+ )
+}
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index e0c572c9..1ff525f4 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -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)
}
diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts
index 3bf44816..405d8a4a 100644
--- a/src/services/indexed-db.service.ts
+++ b/src/services/indexed-db.service.ts
@@ -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 {
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(