diff --git a/src/PageManager.tsx b/src/PageManager.tsx index f6d6336a..be8c50f2 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -49,7 +49,8 @@ import { NoteDrawerContext, useNoteDrawer, useNoteDrawerOptional } from '@/conte import { PrimaryNoteViewContext, usePrimaryNoteView, - usePrimaryNoteViewOptional + usePrimaryNoteViewOptional, + type TPrimaryOverlayViewType } from '@/contexts/primary-note-view-context' import { SecondaryPageContext, useSecondaryPage, useSecondaryPageOptional } from '@/contexts/secondary-page-context' @@ -88,6 +89,8 @@ const RelayPulseActiveNpubsSheetLazy = lazy( const SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePage')) const PrimaryFollowingListPageLazy = lazy(() => import('@/pages/secondary/FollowingListPage')) const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPage')) +const PrimaryBookmarkListPageLazy = lazy(() => import('@/pages/secondary/BookmarkListPage')) +const PrimaryPinListPageLazy = lazy(() => import('@/pages/secondary/PinListPage')) const PrimaryOthersRelaySettingsPageLazy = lazy(() => import('@/pages/secondary/OthersRelaySettingsPage')) const SecondaryRelayPageLazy = lazy(() => import('@/pages/secondary/RelayPage')) @@ -734,6 +737,46 @@ export function useSmartMuteListNavigation() { return { navigateToMuteList } } +export function useSmartBookmarkListNavigation() { + const { setPrimaryNoteView } = usePrimaryNoteView() + const { push: pushSecondaryPage } = useSecondaryPage() + const { isSmallScreen } = useScreenSize() + + const navigateToBookmarkList = (url: string) => { + if (isSmallScreen) { + window.history.pushState(null, '', url) + setPrimaryNoteView( + suspensePrimaryPage(), + 'bookmarks' + ) + } else { + pushSecondaryPage(url) + } + } + + return { navigateToBookmarkList } +} + +export function useSmartPinListNavigation() { + const { setPrimaryNoteView } = usePrimaryNoteView() + const { push: pushSecondaryPage } = useSecondaryPage() + const { isSmallScreen } = useScreenSize() + + const navigateToPinList = (url: string) => { + if (isSmallScreen) { + window.history.pushState(null, '', url) + setPrimaryNoteView( + suspensePrimaryPage(), + 'pins' + ) + } else { + pushSecondaryPage(url) + } + } + + return { navigateToPinList } +} + // Fixed: Others relay settings navigation now uses primary note view on mobile, secondary routing on desktop export function useSmartOthersRelaySettingsNavigation() { const { setPrimaryNoteView } = usePrimaryNoteView() @@ -780,7 +823,7 @@ export function useSmartSettingsNavigation() { // DEPRECATED: ConditionalHomePage removed - double-panel functionality disabled // Helper function to get page title based on view type and URL -function getPageTitle(viewType: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null, pathname: string): string { +function getPageTitle(viewType: TPrimaryOverlayViewType | null, pathname: string): string { // Create a temporary navigation service instance to use the getPageTitle method const tempService = new NavigationService({ setPrimaryNoteView: () => {} }) return tempService.getPageTitle(viewType, pathname) @@ -798,7 +841,7 @@ function MainContentArea({ primaryPages: { name: TPrimaryPageName; element: ReactNode; props?: any }[] currentPrimaryPage: TPrimaryPageName primaryNoteView: ReactNode | null - primaryViewType: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null + primaryViewType: TPrimaryOverlayViewType | null goBack: () => void onPrimaryPanelRefresh: () => void }) { @@ -913,7 +956,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { secondaryStackRef.current = secondaryStack }, [secondaryStack]) const [primaryNoteView, setPrimaryNoteViewState] = useState(null) - const [primaryViewType, setPrimaryViewType] = useState<'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null>(null) + const [primaryViewType, setPrimaryViewType] = useState(null) const [savedPrimaryPage, setSavedPrimaryPage] = useState(null) const [drawerOpen, setDrawerOpen] = useState(false) const [drawerNoteId, setDrawerNoteId] = useState(null) @@ -949,7 +992,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } }, [primaryPages]) - const setPrimaryNoteView = (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings') => { + const setPrimaryNoteView = (view: ReactNode | null, type?: TPrimaryOverlayViewType) => { if (view && !primaryNoteView) { // Saving current primary page before showing overlay savedPrimaryPagePropsRef.current = primaryPages.find((p) => p.name === currentPrimaryPage)?.props as @@ -1680,7 +1723,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { navigatePrimaryPage('settings') return } - if (primaryViewType === 'following' || primaryViewType === 'mute' || primaryViewType === 'others-relay-settings') { + if (primaryViewType === 'bookmarks' || primaryViewType === 'pins' || primaryViewType === 'mute') { + setPrimaryNoteView(null) + return + } + if (primaryViewType === 'following' || primaryViewType === 'others-relay-settings') { const currentPath = window.location.pathname const profileId = currentPath.replace('/users/', '').replace('/following', '').replace('/muted', '').replace('/relays', '') const profileUrl = `/users/${profileId}` @@ -1912,15 +1959,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { variant="ghost" size="titlebar-icon" title="Back to feed" - onClick={() => setPrimaryNoteView(null)} + onClick={goBack} >
- {primaryViewType === 'settings' ? 'Settings' : - primaryViewType === 'settings-sub' ? 'Settings' : - primaryViewType === 'profile' ? 'Back' : - primaryViewType === 'hashtag' ? 'Hashtag' : - primaryViewType === 'note' ? getPageTitle(primaryViewType, window.location.pathname) : 'Note'} + {primaryViewType === 'settings' || primaryViewType === 'settings-sub' + ? 'Settings' + : primaryViewType === 'profile' + ? 'Back' + : getPageTitle(primaryViewType, window.location.pathname)}
diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 19a38120..2220ef33 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -15,7 +15,7 @@ import { } from '@/lib/spell-feed-request-identity' import logger from '@/lib/logger' import { normalizeUrl } from '@/lib/url' -import { getZapInfoFromEvent } from '@/lib/event-metadata' +import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' import { isTouchDevice } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' @@ -672,13 +672,9 @@ const NoteList = forwardRef( // Filter out expired events if (shouldFilterEvent(evt)) return true - // Filter out zap receipts below the zap threshold (superzaps) - if (evt.kind === ExtendedKind.ZAP_RECEIPT) { - const zapInfo = getZapInfoFromEvent(evt) - // Hide zap receipts if amount is missing, 0, or below the threshold - if (!zapInfo || zapInfo.amount === undefined || zapInfo.amount === 0 || zapInfo.amount < zapReplyThreshold) { - return true - } + // Filter out zap receipts below the zap-reply threshold (same rule as thread replies) + if (evt.kind === ExtendedKind.ZAP_RECEIPT && !shouldIncludeZapReceiptAtReplyThreshold(evt, zapReplyThreshold)) { + return true } if (extraShouldHideEvent?.(evt)) return true diff --git a/src/components/PersonalListBech32List/index.tsx b/src/components/PersonalListBech32List/index.tsx new file mode 100644 index 00000000..1a6bc899 --- /dev/null +++ b/src/components/PersonalListBech32List/index.tsx @@ -0,0 +1,38 @@ +import PersonalListNoteRefRow from '@/components/PersonalListNoteRefRow' +import { useEffect, useRef, useState } from 'react' + +const PAGE = 10 + +/** Paginated list of nevent/naddr ids (same infinite-scroll pattern as mute list / {@link ProfileList}). */ +export default function PersonalListBech32List({ bech32Ids }: { bech32Ids: string[] }) { + const [visible, setVisible] = useState([]) + const bottomRef = useRef(null) + + useEffect(() => { + setVisible(bech32Ids.slice(0, PAGE)) + }, [bech32Ids]) + + useEffect(() => { + const el = bottomRef.current + if (!el) return + const obs = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && bech32Ids.length > visible.length) { + setVisible((prev) => [...prev, ...bech32Ids.slice(prev.length, prev.length + PAGE)]) + } + }, + { root: null, rootMargin: '10px', threshold: 1 } + ) + obs.observe(el) + return () => obs.disconnect() + }, [visible, bech32Ids]) + + return ( +
+ {visible.map((id) => ( + + ))} + {bech32Ids.length > visible.length ?
: null} +
+ ) +} diff --git a/src/components/PersonalListNoteRefRow/index.tsx b/src/components/PersonalListNoteRefRow/index.tsx new file mode 100644 index 00000000..e47638f4 --- /dev/null +++ b/src/components/PersonalListNoteRefRow/index.tsx @@ -0,0 +1,69 @@ +import { useFetchEvent } from '@/hooks' +import { toNote } from '@/lib/link' +import { useSmartNoteNavigation } from '@/PageManager' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import UserAvatar from '@/components/UserAvatar' +import Username from '@/components/Username' +import { ChevronRight } from 'lucide-react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +/** + * One row in bookmark / pin list pages (same idea as {@link UserItem} on mute/follow lists). + */ +export default function PersonalListNoteRefRow({ bech32Id }: { bech32Id: string }) { + const { t } = useTranslation() + const { event, isFetching } = useFetchEvent(bech32Id) + const { navigateToNote } = useSmartNoteNavigation() + const preview = useMemo(() => { + const c = event?.content?.trim() + if (!c) return '' + return c.replace(/\s+/g, ' ').slice(0, 140) + }, [event?.content]) + + const onOpen = () => navigateToNote(toNote(bech32Id)) + + if (isFetching) { + return ( +
+ +
+ + +
+
+ ) + } + + return ( + + ) +} diff --git a/src/components/Profile/ProfileFeed.tsx b/src/components/Profile/ProfileFeed.tsx index 20e2a718..5a858c9b 100644 --- a/src/components/Profile/ProfileFeed.tsx +++ b/src/components/Profile/ProfileFeed.tsx @@ -1,5 +1,5 @@ import { ExtendedKind } from '@/constants' -import { getZapInfoFromEvent } from '@/lib/event-metadata' +import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' import { kinds, Event } from 'nostr-tools' import { forwardRef, useMemo } from 'react' import { useZap } from '@/providers/ZapProvider' @@ -34,10 +34,7 @@ const ProfileFeed = forwardRef<{ refresh: () => void; getEvents?: () => Event[] const filterPredicate = useMemo( () => (event: Event) => { if (event.kind === ExtendedKind.ZAP_RECEIPT) { - const zapInfo = getZapInfoFromEvent(event) - if (!zapInfo?.amount || zapInfo.amount < zapReplyThreshold) { - return false - } + return shouldIncludeZapReceiptAtReplyThreshold(event, zapReplyThreshold) } return true }, diff --git a/src/components/Profile/ProfileFeedWithPins.tsx b/src/components/Profile/ProfileFeedWithPins.tsx index e3aa36fa..38d731ec 100644 --- a/src/components/Profile/ProfileFeedWithPins.tsx +++ b/src/components/Profile/ProfileFeedWithPins.tsx @@ -3,7 +3,7 @@ import ProfileSearchBar from '@/components/ui/ProfileSearchBar' import { Skeleton } from '@/components/ui/skeleton' import { ExtendedKind, PROFILE_POSTS_TAB_KINDS } from '@/constants' import { isReplyNoteEvent } from '@/lib/event' -import { getZapInfoFromEvent } from '@/lib/event-metadata' +import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' import { useProfilePins } from '@/hooks/useProfilePins' import { useProfileTimeline } from '@/hooks/useProfileTimeline' import { useProfileZapPollParticipation } from '@/hooks/useProfileZapPollParticipation' @@ -64,10 +64,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string const filterPredicate = useCallback( (event: Event) => { if (event.kind === ExtendedKind.ZAP_RECEIPT) { - const zapInfo = getZapInfoFromEvent(event) - if (!zapInfo?.amount || zapInfo.amount < zapReplyThreshold) { - return false - } + return shouldIncludeZapReceiptAtReplyThreshold(event, zapReplyThreshold) } return true }, diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index ed9d8c1a..ee66b87f 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -18,7 +18,7 @@ import { kind1QuotesThreadRoot } from '@/lib/event' import logger from '@/lib/logger' -import { getZapInfoFromEvent } from '@/lib/event-metadata' +import { getZapInfoFromEvent, shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' import { normalizeUrl } from '@/lib/url' import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' @@ -28,6 +28,7 @@ import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useMuteList } from '@/contexts/mute-list-context' import { useNostr } from '@/providers/NostrProvider' +import { useZap } from '@/providers/ZapProvider' import { useReply } from '@/providers/ReplyProvider' import { useUserTrust } from '@/contexts/user-trust-context' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' @@ -72,6 +73,10 @@ function partitionZapReceipts(items: NEvent[]) { return { zaps, nonZaps } } +function filterZapReceiptsByReplyThreshold(zaps: NEvent[], thresholdSats: number): NEvent[] { + return zaps.filter((z) => shouldIncludeZapReceiptAtReplyThreshold(z, thresholdSats)) +} + /** Zap receipts (9735) at top of reply feeds: largest sats first */ function sortZapReceiptsBySatsDesc(zaps: NEvent[]) { return [...zaps].sort((a, b) => { @@ -256,6 +261,7 @@ function ReplyNoteList({ const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() const { pubkey: userPubkey } = useNostr() + const { zapReplyThreshold } = useZap() const { blockedRelays } = useFavoriteRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays() const [rootInfo, setRootInfo] = useState(undefined) @@ -397,7 +403,8 @@ function ReplyNoteList({ - const { zaps, nonZaps } = partitionZapReceipts(replyEvents) + const { zaps: zapsPartitioned, nonZaps } = partitionZapReceipts(replyEvents) + const zaps = filterZapReceiptsByReplyThreshold(zapsPartitioned, zapReplyThreshold) // Sort notes/comments; zap receipts (9735) are always listed first, largest sats → smallest switch (sort) { @@ -459,7 +466,8 @@ function ReplyNoteList({ repliesMap, mutePubkeySet, hideContentMentioningMutedUsers, - sort + sort, + zapReplyThreshold ]) const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) diff --git a/src/contexts/primary-note-view-context.tsx b/src/contexts/primary-note-view-context.tsx index a05d4404..17db10b3 100644 --- a/src/contexts/primary-note-view-context.tsx +++ b/src/contexts/primary-note-view-context.tsx @@ -9,6 +9,8 @@ export type TPrimaryOverlayViewType = | 'relay' | 'following' | 'mute' + | 'bookmarks' + | 'pins' | 'others-relay-settings' export type PrimaryNoteViewContextValue = { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c2c50be1..58f1a591 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -980,7 +980,7 @@ export default { 'Zapped profile': 'Zapped profile', 'Zap reply threshold': 'Zap reply threshold', 'Zaps above this amount will appear as replies in threads': - 'Zaps above this amount will appear as replies in threads', + 'Only zap receipts (kind 9735) with at least this many sats are shown in home and relay feeds (with “Zaps” enabled in the kind filter) and listed under notes as zap replies.', 'Mark as read': 'Mark as read', Report: 'Report', 'Successfully report': 'Successfully reported', @@ -1576,12 +1576,29 @@ export default { 'Follow sets': 'Follow sets', 'Personal Lists': 'Personal Lists', 'Personal lists hub intro': - 'Mute list, who you follow, NIP-51 bookmarks, and pins. Web page bookmarks (NIP-B0, kind 39701) are separate: save them from an article’s side panel or open the Bookmarks spell to see note bookmarks and web bookmarks together.', + 'Open mute list, following, bookmarks list, or pinned notes on their own pages (like mute and following). Follow sets are below. Web page bookmarks (NIP-B0, kind 39701) are separate—save from an article’s panel or use the Bookmarks spell for a mixed feed.', 'Mute list': 'Mute list', 'Following list': 'Following list', + 'Bookmarks list': 'Bookmarks list', + 'Pinned notes list': 'Pinned notes list', + 'Personal lists bookmarks spell hint': + 'For a note feed from NIP-51 bookmarks, use the', 'Bookmarks spell': 'Bookmarks spell', 'Pinned notes hint': 'Pinned notes: use the note menu (⋯) on a note and choose pin to profile. Pins appear on your profile.', + 'Bookmarks list section title': 'Bookmarks list', + 'Bookmarks list section subtitle': + 'Events referenced by `e` / `a` tags on your kind 10003 bookmark list (newest first).', + 'No entries in bookmark list': 'Your bookmark list is empty.', + 'View bookmarks as feed in Spells': 'View bookmarks as a note feed in Spells', + 'Pinned notes list section title': 'Pinned notes list', + 'Pinned notes list section subtitle': + 'Events referenced by `e` / `a` tags on your kind 10001 pin list (same order as on your profile).', + 'Loading pin list': 'Loading pin list…', + 'No pinned notes in list': 'No pinned notes in your pin list yet.', + "username's bookmarks": "{{username}}'s bookmarks", + "username's pinned notes": "{{username}}'s pinned notes", + 'Event not loaded': 'Event not loaded', 'No NIP-51 bookmarks or web bookmarks yet.': 'No NIP-51 bookmarks or web bookmarks yet.', 'Web bookmarks': 'Web bookmarks', diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 17edd18f..8b1be7c6 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -348,6 +348,7 @@ export function getZapInfoFromEvent(receiptEvent: Event) { recipientPubkey = tagValue break case 'e': + case 'E': originalEventId = tag[1] eventId = generateBech32IdFromETag(tag) break @@ -436,6 +437,19 @@ export function getZapInfoFromEvent(receiptEvent: Event) { } } +/** + * Kind 9735: include in timelines and reply lists only when amount (sats) is known and at least `thresholdSats`. + * Matches {@link NoteList} zap filtering. + */ +export function shouldIncludeZapReceiptAtReplyThreshold(receipt: Event, thresholdSats: number): boolean { + if (receipt.kind !== kinds.Zap) return true + const zapInfo = getZapInfoFromEvent(receipt) + if (!zapInfo || zapInfo.amount === undefined || zapInfo.amount === 0 || zapInfo.amount < thresholdSats) { + return false + } + return true +} + // Helper function to convert d-tag to title case export function dTagToTitleCase(dTag: string): string { return dTag diff --git a/src/lib/link.ts b/src/lib/link.ts index 10678234..9d684273 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -78,6 +78,10 @@ export const toProfileEditor = () => '/profile-editor' export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}` export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews` export const toMuteList = () => '/mutes' + +export const toBookmarksList = () => '/bookmarks' + +export const toPinsList = () => '/pins' export const toSpells = () => '/spells' export const toChachiChat = (relay: string, d: string) => { diff --git a/src/lib/personal-list-refs.ts b/src/lib/personal-list-refs.ts new file mode 100644 index 00000000..3394a042 --- /dev/null +++ b/src/lib/personal-list-refs.ts @@ -0,0 +1,44 @@ +import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag' +import type { Event } from 'nostr-tools' + +function pushBech32FromTag(tag: string[], out: string[]) { + const [name, v] = tag + if (name === 'e' && v && /^[0-9a-f]{64}$/i.test(v)) { + const n = generateBech32IdFromETag(tag) + if (n) out.push(n) + } else if (name === 'a' && v?.trim()) { + const n = generateBech32IdFromATag(tag) + if (n) out.push(n) + } +} + +function dedupePreserveOrder(ids: string[]): string[] { + const seen = new Set() + const next: string[] = [] + for (const id of ids) { + if (seen.has(id)) continue + seen.add(id) + next.push(id) + } + return next +} + +/** NIP-51 kind 10003 bookmark list: `e` / `a` → nevent/naddr, newest-first (matches home bookmarks feed). */ +export function bookmarkBech32IdsFromListEvent(ev: Event | null): string[] { + if (!ev?.tags?.length) return [] + const raw: string[] = [] + for (const t of ev.tags) pushBech32FromTag(t, raw) + return dedupePreserveOrder(raw).reverse() +} + +/** Kind 10001 pin list: `e` reversed then `a`, same ordering as profile pins. */ +export function pinBech32IdsFromListEvent(ev: Event | null): string[] { + if (!ev?.tags?.length) return [] + const tags = ev.tags + const eTags = tags.filter((t) => t[0] === 'e') + const aTags = tags.filter((t) => t[0] === 'a') + const raw: string[] = [] + for (const t of [...eTags].reverse()) pushBech32FromTag(t, raw) + for (const t of aTags) pushBech32FromTag(t, raw) + return dedupePreserveOrder(raw) +} diff --git a/src/pages/secondary/BookmarkListPage/index.tsx b/src/pages/secondary/BookmarkListPage/index.tsx new file mode 100644 index 00000000..5b85f9f4 --- /dev/null +++ b/src/pages/secondary/BookmarkListPage/index.tsx @@ -0,0 +1,143 @@ +import JsonViewDialog from '@/components/JsonViewDialog' +import PersonalListBech32List from '@/components/PersonalListBech32List' +import { RefreshButton } from '@/components/RefreshButton' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' +import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' +import { bookmarkBech32IdsFromListEvent } from '@/lib/personal-list-refs' +import { useNostr } from '@/providers/NostrProvider' +import { getLatestEvent } from '@/lib/event' +import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' +import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' +import { normalizeUrl } from '@/lib/url' +import { PROFILE_FETCH_RELAY_URLS } from '@/constants' +import { queryService } from '@/services/client.service' +import { Code, MoreVertical } from 'lucide-react' +import { kinds } from 'nostr-tools' +import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import NotFoundPage from '../NotFoundPage' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' + +const BookmarkListPage = forwardRef( + ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { + const { t } = useTranslation() + const { registerPrimaryPanelRefresh } = usePrimaryNoteView() + const { profile, pubkey, bookmarkListEvent, relayList, updateBookmarkListEvent } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const [jsonOpen, setJsonOpen] = useState(false) + const [jsonPayload, setJsonPayload] = useState(null) + + const bech32Ids = useMemo(() => bookmarkBech32IdsFromListEvent(bookmarkListEvent), [bookmarkListEvent]) + + const refreshFromRelays = useCallback(async () => { + if (!pubkey) return + const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({ + accountPubkey: pubkey, + favoriteRelays: favoriteRelays ?? [], + blockedRelays + }) + let latest = + (await fetchLatestReplaceableListEvent(pubkey, kinds.BookmarkList, comprehensiveRelays)) ?? null + if (!latest) { + const urls = Array.from( + new Set( + [ + ...PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u), + ...(relayList?.write ?? []).map((u) => normalizeUrl(u) || u) + ].filter(Boolean) + ) + ).slice(0, 12) + if (urls.length) { + try { + const events = await queryService.fetchEvents(urls, { + kinds: [kinds.BookmarkList], + authors: [pubkey], + limit: 5 + }) + latest = getLatestEvent(events) ?? null + } catch { + /* ignore */ + } + } + } + if (latest) await updateBookmarkListEvent(latest) + }, [pubkey, favoriteRelays, blockedRelays, relayList?.write, updateBookmarkListEvent]) + + const openJson = useCallback(() => { + setJsonPayload({ + bookmarkListEvent: bookmarkListEvent ?? null, + derivedBech32Ids: bech32Ids, + note: 'Bookmarks are `e` / `a` tags on your kind 10003 (NIP-51) bookmark list replaceable event.' + }) + setJsonOpen(true) + }, [bookmarkListEvent, bech32Ids]) + + useEffect(() => { + if (!hideTitlebar) { + registerPrimaryPanelRefresh(null) + return + } + registerPrimaryPanelRefresh(() => { + void refreshFromRelays() + }) + return () => registerPrimaryPanelRefresh(null) + }, [hideTitlebar, registerPrimaryPanelRefresh, refreshFromRelays]) + + if (!profile || !pubkey) { + return + } + + return ( + + void refreshFromRelays()} /> + + + + + + openJson()}> + + {t('View JSON')} + + + +
+ ) + } + displayScrollToTopButton + > + setJsonOpen(false)} /> +
+ {bech32Ids.length === 0 ? ( +

{t('No entries in bookmark list')}

+ ) : ( + + )} +
+ + ) + } +) + +BookmarkListPage.displayName = 'BookmarkListPage' +export default BookmarkListPage diff --git a/src/pages/secondary/PersonalListsSettingsPage/index.tsx b/src/pages/secondary/PersonalListsSettingsPage/index.tsx index a3f2a8ad..ce703544 100644 --- a/src/pages/secondary/PersonalListsSettingsPage/index.tsx +++ b/src/pages/secondary/PersonalListsSettingsPage/index.tsx @@ -4,11 +4,13 @@ import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryPage } from '@/contexts/primary-page-context' import { cn } from '@/lib/utils' import { + useSmartBookmarkListNavigation, useSmartFollowingListNavigation, useSmartMuteListNavigation, + useSmartPinListNavigation, useSmartSettingsNavigation } from '@/PageManager' -import { toFollowSetsSettings, toFollowingList, toMuteList } from '@/lib/link' +import { toBookmarksList, toFollowSetsSettings, toFollowingList, toMuteList, toPinsList } from '@/lib/link' import { useNostr } from '@/providers/NostrProvider' import { Bookmark, ChevronRight, Pin, Users, VolumeX } from 'lucide-react' import { forwardRef, HTMLProps, useCallback, useEffect, useState } from 'react' @@ -25,6 +27,8 @@ const PersonalListsSettingsPage = forwardRef( const { navigateToSettings } = useSmartSettingsNavigation() const { navigateToMuteList } = useSmartMuteListNavigation() const { navigateToFollowingList } = useSmartFollowingListNavigation() + const { navigateToBookmarkList } = useSmartBookmarkListNavigation() + const { navigateToPinList } = useSmartPinListNavigation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const [contentKey, setContentKey] = useState(0) const bump = useCallback(() => setContentKey((k) => k + 1), []) @@ -47,10 +51,7 @@ const PersonalListsSettingsPage = forwardRef( >

{t('Personal lists hub intro')}

- navigateToMuteList(toMuteList())} - > + navigateToMuteList(toMuteList())}>
{t('Mute list')}
@@ -70,31 +71,43 @@ const PersonalListsSettingsPage = forwardRef( ) : null} {pubkey ? ( - navigatePrimary('spells', { spell: 'bookmarks' })} - > + navigateToBookmarkList(toBookmarksList())}>
-
{t('Bookmarks spell')}
+
{t('Bookmarks list')}
+
+ +
+ ) : null} + {pubkey ? ( + navigateToPinList(toPinsList())}> +
+ +
{t('Pinned notes list')}
) : null} - navigateToSettings(toFollowSetsSettings())} - > + navigateToSettings(toFollowSetsSettings())}>
{t('Follow sets')}
-
- -
{t('Pinned notes hint')}
-
+

+ + + {t('Personal lists bookmarks spell hint')}{' '} + + +

) diff --git a/src/pages/secondary/PinListPage/index.tsx b/src/pages/secondary/PinListPage/index.tsx new file mode 100644 index 00000000..a8b7910a --- /dev/null +++ b/src/pages/secondary/PinListPage/index.tsx @@ -0,0 +1,151 @@ +import JsonViewDialog from '@/components/JsonViewDialog' +import PersonalListBech32List from '@/components/PersonalListBech32List' +import { RefreshButton } from '@/components/RefreshButton' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' +import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' +import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' +import { pinBech32IdsFromListEvent } from '@/lib/personal-list-refs' +import { fetchNewestPinListForPubkey } from '@/lib/replaceable-list-latest' +import { useNostr } from '@/providers/NostrProvider' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import indexedDb from '@/services/indexed-db.service' +import { Code, MoreVertical } from 'lucide-react' +import type { Event } from 'nostr-tools' +import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import NotFoundPage from '../NotFoundPage' + +const PinListPage = forwardRef( + ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { + const { t } = useTranslation() + const { registerPrimaryPanelRefresh } = usePrimaryNoteView() + const { profile, pubkey } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const [pinListEvent, setPinListEvent] = useState(null) + const [jsonOpen, setJsonOpen] = useState(false) + const [jsonPayload, setJsonPayload] = useState(null) + + const loadPins = useCallback(async () => { + if (!pubkey) { + setPinListEvent(null) + return + } + let cached: Event | null | undefined + try { + cached = (await indexedDb.getReplaceableEvent(pubkey, 10001)) ?? undefined + } catch { + cached = undefined + } + const relays = await buildAccountListRelayUrlsForMerge({ + accountPubkey: pubkey, + favoriteRelays: favoriteRelays ?? [], + blockedRelays + }) + const fromNet = await fetchNewestPinListForPubkey(pubkey, relays) + const best = + !cached && fromNet + ? fromNet + : cached && !fromNet + ? cached + : cached && fromNet + ? fromNet.created_at >= cached.created_at + ? fromNet + : cached + : null + setPinListEvent(best ?? null) + if (best) { + try { + await indexedDb.putReplaceableEvent(best) + } catch { + /* ignore */ + } + } + }, [pubkey, favoriteRelays, blockedRelays]) + + useEffect(() => { + void loadPins() + }, [loadPins]) + + const bech32Ids = useMemo(() => pinBech32IdsFromListEvent(pinListEvent), [pinListEvent]) + + const openJson = useCallback(() => { + setJsonPayload({ + pinListEvent: pinListEvent ?? null, + derivedBech32Ids: bech32Ids, + note: 'Pins are `e` / `a` tags on your kind 10001 replaceable pin list event.' + }) + setJsonOpen(true) + }, [pinListEvent, bech32Ids]) + + useEffect(() => { + if (!hideTitlebar) { + registerPrimaryPanelRefresh(null) + return + } + registerPrimaryPanelRefresh(() => { + void loadPins() + }) + return () => registerPrimaryPanelRefresh(null) + }, [hideTitlebar, registerPrimaryPanelRefresh, loadPins]) + + if (!profile || !pubkey) { + return + } + + return ( + + void loadPins()} /> + + + + + + openJson()}> + + {t('View JSON')} + + + +
+ ) + } + displayScrollToTopButton + > + setJsonOpen(false)} /> +
+ {bech32Ids.length === 0 ? ( +

{t('No pinned notes in list')}

+ ) : ( + + )} +
+ + ) + } +) + +PinListPage.displayName = 'PinListPage' +export default PinListPage diff --git a/src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx b/src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx index 6f138609..b9747f53 100644 --- a/src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx +++ b/src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx @@ -1,7 +1,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { useZap } from '@/providers/ZapProvider' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' export default function ZapReplyThresholdInput() { @@ -9,6 +9,10 @@ export default function ZapReplyThresholdInput() { const { zapReplyThreshold, updateZapReplyThreshold } = useZap() const [zapReplyThresholdInput, setZapReplyThresholdInput] = useState(zapReplyThreshold) + useEffect(() => { + setZapReplyThresholdInput(zapReplyThreshold) + }, [zapReplyThreshold]) + return (