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(