Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
d823a14b51
  1. 71
      src/PageManager.tsx
  2. 12
      src/components/NoteList/index.tsx
  3. 38
      src/components/PersonalListBech32List/index.tsx
  4. 69
      src/components/PersonalListNoteRefRow/index.tsx
  5. 7
      src/components/Profile/ProfileFeed.tsx
  6. 7
      src/components/Profile/ProfileFeedWithPins.tsx
  7. 14
      src/components/ReplyNoteList/index.tsx
  8. 2
      src/contexts/primary-note-view-context.tsx
  9. 21
      src/i18n/locales/en.ts
  10. 14
      src/lib/event-metadata.ts
  11. 4
      src/lib/link.ts
  12. 44
      src/lib/personal-list-refs.ts
  13. 143
      src/pages/secondary/BookmarkListPage/index.tsx
  14. 49
      src/pages/secondary/PersonalListsSettingsPage/index.tsx
  15. 151
      src/pages/secondary/PinListPage/index.tsx
  16. 6
      src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx
  17. 23
      src/providers/MuteListProvider.tsx
  18. 8
      src/providers/NostrProvider/index.tsx
  19. 4
      src/routes.tsx
  20. 2
      src/services/local-storage.service.ts
  21. 16
      src/services/navigation.service.ts

71
src/PageManager.tsx

@ -49,7 +49,8 @@ import { NoteDrawerContext, useNoteDrawer, useNoteDrawerOptional } from '@/conte
import { import {
PrimaryNoteViewContext, PrimaryNoteViewContext,
usePrimaryNoteView, usePrimaryNoteView,
usePrimaryNoteViewOptional usePrimaryNoteViewOptional,
type TPrimaryOverlayViewType
} from '@/contexts/primary-note-view-context' } from '@/contexts/primary-note-view-context'
import { SecondaryPageContext, useSecondaryPage, useSecondaryPageOptional } from '@/contexts/secondary-page-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 SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePage'))
const PrimaryFollowingListPageLazy = lazy(() => import('@/pages/secondary/FollowingListPage')) const PrimaryFollowingListPageLazy = lazy(() => import('@/pages/secondary/FollowingListPage'))
const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPage')) 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 PrimaryOthersRelaySettingsPageLazy = lazy(() => import('@/pages/secondary/OthersRelaySettingsPage'))
const SecondaryRelayPageLazy = lazy(() => import('@/pages/secondary/RelayPage')) const SecondaryRelayPageLazy = lazy(() => import('@/pages/secondary/RelayPage'))
@ -734,6 +737,46 @@ export function useSmartMuteListNavigation() {
return { navigateToMuteList } 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(<PrimaryBookmarkListPageLazy index={0} hideTitlebar={true} />),
'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(<PrimaryPinListPageLazy index={0} hideTitlebar={true} />),
'pins'
)
} else {
pushSecondaryPage(url)
}
}
return { navigateToPinList }
}
// Fixed: Others relay settings navigation now uses primary note view on mobile, secondary routing on desktop // Fixed: Others relay settings navigation now uses primary note view on mobile, secondary routing on desktop
export function useSmartOthersRelaySettingsNavigation() { export function useSmartOthersRelaySettingsNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView() const { setPrimaryNoteView } = usePrimaryNoteView()
@ -780,7 +823,7 @@ export function useSmartSettingsNavigation() {
// DEPRECATED: ConditionalHomePage removed - double-panel functionality disabled // DEPRECATED: ConditionalHomePage removed - double-panel functionality disabled
// Helper function to get page title based on view type and URL // 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 // Create a temporary navigation service instance to use the getPageTitle method
const tempService = new NavigationService({ setPrimaryNoteView: () => {} }) const tempService = new NavigationService({ setPrimaryNoteView: () => {} })
return tempService.getPageTitle(viewType, pathname) return tempService.getPageTitle(viewType, pathname)
@ -798,7 +841,7 @@ function MainContentArea({
primaryPages: { name: TPrimaryPageName; element: ReactNode; props?: any }[] primaryPages: { name: TPrimaryPageName; element: ReactNode; props?: any }[]
currentPrimaryPage: TPrimaryPageName currentPrimaryPage: TPrimaryPageName
primaryNoteView: ReactNode | null primaryNoteView: ReactNode | null
primaryViewType: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null primaryViewType: TPrimaryOverlayViewType | null
goBack: () => void goBack: () => void
onPrimaryPanelRefresh: () => void onPrimaryPanelRefresh: () => void
}) { }) {
@ -913,7 +956,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
secondaryStackRef.current = secondaryStack secondaryStackRef.current = secondaryStack
}, [secondaryStack]) }, [secondaryStack])
const [primaryNoteView, setPrimaryNoteViewState] = useState<ReactNode | null>(null) const [primaryNoteView, setPrimaryNoteViewState] = useState<ReactNode | null>(null)
const [primaryViewType, setPrimaryViewType] = useState<'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null>(null) const [primaryViewType, setPrimaryViewType] = useState<TPrimaryOverlayViewType | null>(null)
const [savedPrimaryPage, setSavedPrimaryPage] = useState<TPrimaryPageName | null>(null) const [savedPrimaryPage, setSavedPrimaryPage] = useState<TPrimaryPageName | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerNoteId, setDrawerNoteId] = useState<string | null>(null) const [drawerNoteId, setDrawerNoteId] = useState<string | null>(null)
@ -949,7 +992,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
}, [primaryPages]) }, [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) { if (view && !primaryNoteView) {
// Saving current primary page before showing overlay // Saving current primary page before showing overlay
savedPrimaryPagePropsRef.current = primaryPages.find((p) => p.name === currentPrimaryPage)?.props as savedPrimaryPagePropsRef.current = primaryPages.find((p) => p.name === currentPrimaryPage)?.props as
@ -1680,7 +1723,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
navigatePrimaryPage('settings') navigatePrimaryPage('settings')
return 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 currentPath = window.location.pathname
const profileId = currentPath.replace('/users/', '').replace('/following', '').replace('/muted', '').replace('/relays', '') const profileId = currentPath.replace('/users/', '').replace('/following', '').replace('/muted', '').replace('/relays', '')
const profileUrl = `/users/${profileId}` const profileUrl = `/users/${profileId}`
@ -1912,15 +1959,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
variant="ghost" variant="ghost"
size="titlebar-icon" size="titlebar-icon"
title="Back to feed" title="Back to feed"
onClick={() => setPrimaryNoteView(null)} onClick={goBack}
> >
<ChevronLeft /> <ChevronLeft />
<div className="truncate text-lg font-semibold"> <div className="truncate text-lg font-semibold">
{primaryViewType === 'settings' ? 'Settings' : {primaryViewType === 'settings' || primaryViewType === 'settings-sub'
primaryViewType === 'settings-sub' ? 'Settings' : ? 'Settings'
primaryViewType === 'profile' ? 'Back' : : primaryViewType === 'profile'
primaryViewType === 'hashtag' ? 'Hashtag' : ? 'Back'
primaryViewType === 'note' ? getPageTitle(primaryViewType, window.location.pathname) : 'Note'} : getPageTitle(primaryViewType, window.location.pathname)}
</div> </div>
</Button> </Button>
</div> </div>

12
src/components/NoteList/index.tsx

@ -15,7 +15,7 @@ import {
} from '@/lib/spell-feed-request-identity' } from '@/lib/spell-feed-request-identity'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
@ -672,13 +672,9 @@ const NoteList = forwardRef(
// Filter out expired events // Filter out expired events
if (shouldFilterEvent(evt)) return true if (shouldFilterEvent(evt)) return true
// Filter out zap receipts below the zap threshold (superzaps) // Filter out zap receipts below the zap-reply threshold (same rule as thread replies)
if (evt.kind === ExtendedKind.ZAP_RECEIPT) { if (evt.kind === ExtendedKind.ZAP_RECEIPT && !shouldIncludeZapReceiptAtReplyThreshold(evt, zapReplyThreshold)) {
const zapInfo = getZapInfoFromEvent(evt) return true
// 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
}
} }
if (extraShouldHideEvent?.(evt)) return true if (extraShouldHideEvent?.(evt)) return true

38
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<string[]>([])
const bottomRef = useRef<HTMLDivElement>(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 (
<div className="space-y-0 divide-y divide-border/60">
{visible.map((id) => (
<PersonalListNoteRefRow key={id} bech32Id={id} />
))}
{bech32Ids.length > visible.length ? <div ref={bottomRef} className="h-4" /> : null}
</div>
)
}

69
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 (
<div className="flex items-center gap-2 px-4 py-2">
<Skeleton className="size-10 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-full max-w-md" />
</div>
</div>
)
}
return (
<Button
type="button"
variant="ghost"
className="h-auto min-h-[3.5rem] w-full justify-start gap-2 rounded-none px-4 py-2 font-normal hover:bg-muted/60"
onClick={onOpen}
>
{event ? (
<>
<UserAvatar userId={event.pubkey} className="shrink-0" />
<div className="min-w-0 flex-1 text-left">
<Username
userId={event.pubkey}
className="max-w-full truncate font-semibold"
skeletonClassName="h-4"
/>
<div className="truncate text-sm text-muted-foreground">
{preview || t('Event kind label', { kind: event.kind })}
</div>
</div>
</>
) : (
<div className="min-w-0 flex-1 text-left font-mono text-xs text-muted-foreground">
{bech32Id.length > 36 ? `${bech32Id.slice(0, 28)}` : bech32Id}
<div className="mt-0.5 text-[11px]">{t('Event not loaded')}</div>
</div>
)}
<ChevronRight className="size-4 shrink-0 opacity-50" />
</Button>
)
}

7
src/components/Profile/ProfileFeed.tsx

@ -1,5 +1,5 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
import { kinds, Event } from 'nostr-tools' import { kinds, Event } from 'nostr-tools'
import { forwardRef, useMemo } from 'react' import { forwardRef, useMemo } from 'react'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
@ -34,10 +34,7 @@ const ProfileFeed = forwardRef<{ refresh: () => void; getEvents?: () => Event[]
const filterPredicate = useMemo( const filterPredicate = useMemo(
() => (event: Event) => { () => (event: Event) => {
if (event.kind === ExtendedKind.ZAP_RECEIPT) { if (event.kind === ExtendedKind.ZAP_RECEIPT) {
const zapInfo = getZapInfoFromEvent(event) return shouldIncludeZapReceiptAtReplyThreshold(event, zapReplyThreshold)
if (!zapInfo?.amount || zapInfo.amount < zapReplyThreshold) {
return false
}
} }
return true return true
}, },

7
src/components/Profile/ProfileFeedWithPins.tsx

@ -3,7 +3,7 @@ import ProfileSearchBar from '@/components/ui/ProfileSearchBar'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind, PROFILE_POSTS_TAB_KINDS } from '@/constants' import { ExtendedKind, PROFILE_POSTS_TAB_KINDS } from '@/constants'
import { isReplyNoteEvent } from '@/lib/event' import { isReplyNoteEvent } from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
import { useProfilePins } from '@/hooks/useProfilePins' import { useProfilePins } from '@/hooks/useProfilePins'
import { useProfileTimeline } from '@/hooks/useProfileTimeline' import { useProfileTimeline } from '@/hooks/useProfileTimeline'
import { useProfileZapPollParticipation } from '@/hooks/useProfileZapPollParticipation' import { useProfileZapPollParticipation } from '@/hooks/useProfileZapPollParticipation'
@ -64,10 +64,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
const filterPredicate = useCallback( const filterPredicate = useCallback(
(event: Event) => { (event: Event) => {
if (event.kind === ExtendedKind.ZAP_RECEIPT) { if (event.kind === ExtendedKind.ZAP_RECEIPT) {
const zapInfo = getZapInfoFromEvent(event) return shouldIncludeZapReceiptAtReplyThreshold(event, zapReplyThreshold)
if (!zapInfo?.amount || zapInfo.amount < zapReplyThreshold) {
return false
}
} }
return true return true
}, },

14
src/components/ReplyNoteList/index.tsx

@ -18,7 +18,7 @@ import {
kind1QuotesThreadRoot kind1QuotesThreadRoot
} from '@/lib/event' } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent, shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter' import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
@ -28,6 +28,7 @@ import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useZap } from '@/providers/ZapProvider'
import { useReply } from '@/providers/ReplyProvider' import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
@ -72,6 +73,10 @@ function partitionZapReceipts(items: NEvent[]) {
return { zaps, nonZaps } 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 */ /** Zap receipts (9735) at top of reply feeds: largest sats first */
function sortZapReceiptsBySatsDesc(zaps: NEvent[]) { function sortZapReceiptsBySatsDesc(zaps: NEvent[]) {
return [...zaps].sort((a, b) => { return [...zaps].sort((a, b) => {
@ -256,6 +261,7 @@ function ReplyNoteList({
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { pubkey: userPubkey } = useNostr() const { pubkey: userPubkey } = useNostr()
const { zapReplyThreshold } = useZap()
const { blockedRelays } = useFavoriteRelays() const { blockedRelays } = useFavoriteRelays()
const { relayUrls: browsingRelayUrls } = useCurrentRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined) const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(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 // Sort notes/comments; zap receipts (9735) are always listed first, largest sats → smallest
switch (sort) { switch (sort) {
@ -459,7 +466,8 @@ function ReplyNoteList({
repliesMap, repliesMap,
mutePubkeySet, mutePubkeySet,
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
sort sort,
zapReplyThreshold
]) ])
const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies])

2
src/contexts/primary-note-view-context.tsx

@ -9,6 +9,8 @@ export type TPrimaryOverlayViewType =
| 'relay' | 'relay'
| 'following' | 'following'
| 'mute' | 'mute'
| 'bookmarks'
| 'pins'
| 'others-relay-settings' | 'others-relay-settings'
export type PrimaryNoteViewContextValue = { export type PrimaryNoteViewContextValue = {

21
src/i18n/locales/en.ts

@ -980,7 +980,7 @@ export default {
'Zapped profile': 'Zapped profile', 'Zapped profile': 'Zapped profile',
'Zap reply threshold': 'Zap reply threshold', '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':
'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', 'Mark as read': 'Mark as read',
Report: 'Report', Report: 'Report',
'Successfully report': 'Successfully reported', 'Successfully report': 'Successfully reported',
@ -1576,12 +1576,29 @@ export default {
'Follow sets': 'Follow sets', 'Follow sets': 'Follow sets',
'Personal Lists': 'Personal Lists', 'Personal Lists': 'Personal Lists',
'Personal lists hub intro': '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', 'Mute list': 'Mute list',
'Following list': 'Following 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', 'Bookmarks spell': 'Bookmarks spell',
'Pinned notes hint': 'Pinned notes hint':
'Pinned notes: use the note menu (⋯) on a note and choose pin to profile. Pins appear on your profile.', '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.':
'No NIP-51 bookmarks or web bookmarks yet.', 'No NIP-51 bookmarks or web bookmarks yet.',
'Web bookmarks': 'Web bookmarks', 'Web bookmarks': 'Web bookmarks',

14
src/lib/event-metadata.ts

@ -348,6 +348,7 @@ export function getZapInfoFromEvent(receiptEvent: Event) {
recipientPubkey = tagValue recipientPubkey = tagValue
break break
case 'e': case 'e':
case 'E':
originalEventId = tag[1] originalEventId = tag[1]
eventId = generateBech32IdFromETag(tag) eventId = generateBech32IdFromETag(tag)
break 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 // Helper function to convert d-tag to title case
export function dTagToTitleCase(dTag: string): string { export function dTagToTitleCase(dTag: string): string {
return dTag return dTag

4
src/lib/link.ts

@ -78,6 +78,10 @@ export const toProfileEditor = () => '/profile-editor'
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}` export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews` export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews`
export const toMuteList = () => '/mutes' export const toMuteList = () => '/mutes'
export const toBookmarksList = () => '/bookmarks'
export const toPinsList = () => '/pins'
export const toSpells = () => '/spells' export const toSpells = () => '/spells'
export const toChachiChat = (relay: string, d: string) => { export const toChachiChat = (relay: string, d: string) => {

44
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<string>()
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)
}

143
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<unknown>(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 <NotFoundPage />
}
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={
hideTitlebar
? undefined
: t("username's bookmarks", { username: profile.username, defaultValue: `${profile.username}'s bookmarks` })
}
hideBackButton={hideTitlebar}
controls={
hideTitlebar ? undefined : (
<div className="flex items-center gap-0">
<RefreshButton onClick={() => void refreshFromRelays()} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openJson()}>
<Code className="mr-2 size-4" />
{t('View JSON')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
displayScrollToTopButton
>
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<div key={bookmarkListEvent?.id ?? 'none'} className="min-h-[30vh] pt-1">
{bech32Ids.length === 0 ? (
<p className="px-4 pt-4 text-center text-sm text-muted-foreground">{t('No entries in bookmark list')}</p>
) : (
<PersonalListBech32List bech32Ids={bech32Ids} />
)}
</div>
</SecondaryPageLayout>
)
}
)
BookmarkListPage.displayName = 'BookmarkListPage'
export default BookmarkListPage

49
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 { usePrimaryPage } from '@/contexts/primary-page-context'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
useSmartBookmarkListNavigation,
useSmartFollowingListNavigation, useSmartFollowingListNavigation,
useSmartMuteListNavigation, useSmartMuteListNavigation,
useSmartPinListNavigation,
useSmartSettingsNavigation useSmartSettingsNavigation
} from '@/PageManager' } from '@/PageManager'
import { toFollowSetsSettings, toFollowingList, toMuteList } from '@/lib/link' import { toBookmarksList, toFollowSetsSettings, toFollowingList, toMuteList, toPinsList } from '@/lib/link'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Bookmark, ChevronRight, Pin, Users, VolumeX } from 'lucide-react' import { Bookmark, ChevronRight, Pin, Users, VolumeX } from 'lucide-react'
import { forwardRef, HTMLProps, useCallback, useEffect, useState } from 'react' import { forwardRef, HTMLProps, useCallback, useEffect, useState } from 'react'
@ -25,6 +27,8 @@ const PersonalListsSettingsPage = forwardRef(
const { navigateToSettings } = useSmartSettingsNavigation() const { navigateToSettings } = useSmartSettingsNavigation()
const { navigateToMuteList } = useSmartMuteListNavigation() const { navigateToMuteList } = useSmartMuteListNavigation()
const { navigateToFollowingList } = useSmartFollowingListNavigation() const { navigateToFollowingList } = useSmartFollowingListNavigation()
const { navigateToBookmarkList } = useSmartBookmarkListNavigation()
const { navigateToPinList } = useSmartPinListNavigation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0) const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), []) const bump = useCallback(() => setContentKey((k) => k + 1), [])
@ -47,10 +51,7 @@ const PersonalListsSettingsPage = forwardRef(
> >
<div key={contentKey} className="min-w-0 space-y-1 px-1 pt-2"> <div key={contentKey} className="min-w-0 space-y-1 px-1 pt-2">
<p className="px-3 pb-3 text-sm text-muted-foreground">{t('Personal lists hub intro')}</p> <p className="px-3 pb-3 text-sm text-muted-foreground">{t('Personal lists hub intro')}</p>
<SettingRow <SettingRow className="clickable" onClick={() => navigateToMuteList(toMuteList())}>
className="clickable"
onClick={() => navigateToMuteList(toMuteList())}
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<VolumeX /> <VolumeX />
<div>{t('Mute list')}</div> <div>{t('Mute list')}</div>
@ -70,31 +71,43 @@ const PersonalListsSettingsPage = forwardRef(
</SettingRow> </SettingRow>
) : null} ) : null}
{pubkey ? ( {pubkey ? (
<SettingRow <SettingRow className="clickable" onClick={() => navigateToBookmarkList(toBookmarksList())}>
className="clickable"
onClick={() => navigatePrimary('spells', { spell: 'bookmarks' })}
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Bookmark /> <Bookmark />
<div>{t('Bookmarks spell')}</div> <div>{t('Bookmarks list')}</div>
</div>
<ChevronRight />
</SettingRow>
) : null}
{pubkey ? (
<SettingRow className="clickable" onClick={() => navigateToPinList(toPinsList())}>
<div className="flex items-center gap-3">
<Pin />
<div>{t('Pinned notes list')}</div>
</div> </div>
<ChevronRight /> <ChevronRight />
</SettingRow> </SettingRow>
) : null} ) : null}
<SettingRow <SettingRow className="clickable" onClick={() => navigateToSettings(toFollowSetsSettings())}>
className="clickable"
onClick={() => navigateToSettings(toFollowSetsSettings())}
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Users /> <Users />
<div>{t('Follow sets')}</div> <div>{t('Follow sets')}</div>
</div> </div>
<ChevronRight /> <ChevronRight />
</SettingRow> </SettingRow>
<div className="flex min-h-[52px] items-center gap-3 rounded-lg px-4 py-2 text-sm text-muted-foreground"> <p className="flex min-h-[52px] items-start gap-3 rounded-lg px-4 py-2 text-sm text-muted-foreground">
<Pin className="size-4 shrink-0 opacity-80" /> <Bookmark className="mt-0.5 size-4 shrink-0 opacity-80" />
<div>{t('Pinned notes hint')}</div> <span>
</div> {t('Personal lists bookmarks spell hint')}{' '}
<button
type="button"
className="text-primary underline-offset-4 hover:underline"
onClick={() => navigatePrimary('spells', { spell: 'bookmarks' })}
>
{t('Bookmarks spell')}
</button>
</span>
</p>
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )

151
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<Event | null>(null)
const [jsonOpen, setJsonOpen] = useState(false)
const [jsonPayload, setJsonPayload] = useState<unknown>(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 <NotFoundPage />
}
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={
hideTitlebar
? undefined
: t("username's pinned notes", {
username: profile.username,
defaultValue: `${profile.username}'s pinned notes`
})
}
hideBackButton={hideTitlebar}
controls={
hideTitlebar ? undefined : (
<div className="flex items-center gap-0">
<RefreshButton onClick={() => void loadPins()} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openJson()}>
<Code className="mr-2 size-4" />
{t('View JSON')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
displayScrollToTopButton
>
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<div key={pinListEvent?.id ?? 'none'} className="min-h-[30vh] pt-1">
{bech32Ids.length === 0 ? (
<p className="px-4 pt-4 text-center text-sm text-muted-foreground">{t('No pinned notes in list')}</p>
) : (
<PersonalListBech32List bech32Ids={bech32Ids} />
)}
</div>
</SecondaryPageLayout>
)
}
)
PinListPage.displayName = 'PinListPage'
export default PinListPage

6
src/pages/secondary/WalletPage/ZapReplyThresholdInput.tsx

@ -1,7 +1,7 @@
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function ZapReplyThresholdInput() { export default function ZapReplyThresholdInput() {
@ -9,6 +9,10 @@ export default function ZapReplyThresholdInput() {
const { zapReplyThreshold, updateZapReplyThreshold } = useZap() const { zapReplyThreshold, updateZapReplyThreshold } = useZap()
const [zapReplyThresholdInput, setZapReplyThresholdInput] = useState(zapReplyThreshold) const [zapReplyThresholdInput, setZapReplyThresholdInput] = useState(zapReplyThreshold)
useEffect(() => {
setZapReplyThresholdInput(zapReplyThreshold)
}, [zapReplyThreshold])
return ( return (
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<Label htmlFor="zap-reply-threshold-input"> <Label htmlFor="zap-reply-threshold-input">

23
src/providers/MuteListProvider.tsx

@ -37,6 +37,8 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
pubkey: accountPubkey, pubkey: accountPubkey,
account,
isAccountSessionHydrating,
muteListEvent, muteListEvent,
publish, publish,
updateMuteListEvent, updateMuteListEvent,
@ -64,7 +66,12 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
}, [accountPubkey]) }, [accountPubkey])
const getPrivateTags = async (muteListEvent: Event) => { const getPrivateTags = async (muteListEvent: Event) => {
if (!muteListEvent.content) return [] if (!muteListEvent.content?.trim()) return []
// npub-only sessions cannot decrypt; never surface a stale IDB decrypt from a prior signing session.
if (!account || account.signerType === 'npub') {
return []
}
const storedDecryptedTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id) const storedDecryptedTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id)
@ -78,13 +85,19 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
} }
} }
const plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content) // During account hydrate, mute list can be set before every downstream invariant is ready; skip
// decrypt and the empty-ciphertext warning, then retry when hydration finishes.
if (isAccountSessionHydrating) {
return []
}
const plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content.trim())
if (!plainText.trim()) { if (!plainText.trim()) {
logMuteListPrivateIssueOnce( logMuteListPrivateIssueOnce(
muteListEvent.id, muteListEvent.id,
'Mute list has ciphertext but decryption returned empty (e.g. read-only / npub-only login). Public mutes still apply.', 'Mute list ciphertext could not be decrypted (npub-only / extension blocked NIP-04 / wrong key / corrupt payload). Public `p`/`e` mutes still apply.',
undefined { signerType: account.signerType }
) )
return [] return []
} }
@ -123,7 +136,7 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
setTags(muteListEvent.tags) setTags(muteListEvent.tags)
} }
updateMuteTags() updateMuteTags()
}, [muteListEvent]) }, [muteListEvent, isAccountSessionHydrating, account?.signerType, account?.pubkey])
const getMutePubkeys = () => { const getMutePubkeys = () => {
return Array.from(mutePubkeySet) return Array.from(mutePubkeySet)

8
src/providers/NostrProvider/index.tsx

@ -1105,13 +1105,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (draft.tags?.length) { if (draft.tags?.length) {
draft.tags = draft.tags.filter((tag) => Array.isArray(tag) && tag[0] !== 'client') draft.tags = draft.tags.filter((tag) => Array.isArray(tag) && tag[0] !== 'client')
} }
// 2) If user has allowed adding a client tag, add our own // 2) If user has allowed adding a client tag, add our own (and drop prior Jumble "alt" lines —
// unlike "client", we used not to strip "alt", so follow-list merges accumulated duplicates).
const addClientTag = const addClientTag =
typeof options.addClientTag === 'boolean' typeof options.addClientTag === 'boolean'
? options.addClientTag ? options.addClientTag
: (typeof window !== 'undefined' && storage.getAddClientTag()) : (typeof window !== 'undefined' && storage.getAddClientTag())
if (addClientTag) { if (addClientTag) {
draft.tags = draft.tags ?? [] const jumbleAttributionAlt = buildAltTag()[1]
draft.tags = (draft.tags ?? []).filter(
(tag) => Array.isArray(tag) && !(tag[0] === 'alt' && tag[1] === jumbleAttributionAlt)
)
draft.tags.push(buildClientTag(), buildAltTag()) draft.tags.push(buildClientTag(), buildAltTag())
} }
let event: VerifiedEvent let event: VerifiedEvent

4
src/routes.tsx

@ -12,6 +12,8 @@ import {
const FollowingListPageLazy = lazy(() => import('./pages/secondary/FollowingListPage')) const FollowingListPageLazy = lazy(() => import('./pages/secondary/FollowingListPage'))
const GeneralSettingsPageLazy = lazy(() => import('./pages/secondary/GeneralSettingsPage')) const GeneralSettingsPageLazy = lazy(() => import('./pages/secondary/GeneralSettingsPage'))
const MuteListPageLazy = lazy(() => import('./pages/secondary/MuteListPage')) const MuteListPageLazy = lazy(() => import('./pages/secondary/MuteListPage'))
const BookmarkListPageLazy = lazy(() => import('./pages/secondary/BookmarkListPage'))
const PinListPageLazy = lazy(() => import('./pages/secondary/PinListPage'))
const NoteListPageLazy = lazy(() => import('./pages/secondary/NoteListPage')) const NoteListPageLazy = lazy(() => import('./pages/secondary/NoteListPage'))
const NotePageLazy = lazy(() => import('./pages/secondary/NotePage')) const NotePageLazy = lazy(() => import('./pages/secondary/NotePage'))
const OthersRelaySettingsPageLazy = lazy(() => import('./pages/secondary/OthersRelaySettingsPage')) const OthersRelaySettingsPageLazy = lazy(() => import('./pages/secondary/OthersRelaySettingsPage'))
@ -85,6 +87,8 @@ const ROUTES = [
{ path: '/settings/personal-lists', element: SR(PersonalListsSettingsPageLazy) }, { path: '/settings/personal-lists', element: SR(PersonalListsSettingsPageLazy) },
{ path: '/profile-editor', element: SR(ProfileEditorPageLazy) }, { path: '/profile-editor', element: SR(ProfileEditorPageLazy) },
{ path: '/mutes', element: SR(MuteListPageLazy) }, { path: '/mutes', element: SR(MuteListPageLazy) },
{ path: '/bookmarks', element: SR(BookmarkListPageLazy) },
{ path: '/pins', element: SR(PinListPageLazy) },
{ path: '/follow-packs', element: SR(FollowPacksRedirectLazy) } { path: '/follow-packs', element: SR(FollowPacksRedirectLazy) }
] ]

2
src/services/local-storage.service.ts

@ -84,7 +84,7 @@ class LocalStorageService {
private defaultZapSats: number = 21 private defaultZapSats: number = 21
private defaultZapComment: string = 'Zap!' private defaultZapComment: string = 'Zap!'
private quickZap: boolean = false private quickZap: boolean = false
private zapReplyThreshold: number = 2100 private zapReplyThreshold: number = 1
private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {} private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {}
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
private autoplay: boolean = true private autoplay: boolean = true

16
src/services/navigation.service.ts

@ -33,7 +33,19 @@ const navLazyFallback = React.createElement(
'Loading…' 'Loading…'
) )
export type ViewType = 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null export type ViewType =
| 'note'
| 'settings'
| 'settings-sub'
| 'profile'
| 'hashtag'
| 'relay'
| 'following'
| 'mute'
| 'bookmarks'
| 'pins'
| 'others-relay-settings'
| null
export interface NavigationContext { export interface NavigationContext {
setPrimaryNoteView: (view: ReactNode, type: ViewType) => void setPrimaryNoteView: (view: ReactNode, type: ViewType) => void
@ -278,6 +290,8 @@ export class NavigationService {
} }
if (viewType === 'following') return 'Following' if (viewType === 'following') return 'Following'
if (viewType === 'mute') return 'Muted Users' if (viewType === 'mute') return 'Muted Users'
if (viewType === 'bookmarks') return 'Bookmarks'
if (viewType === 'pins') return 'Pinned notes'
if (viewType === 'others-relay-settings') return 'Relays and Storage Settings' if (viewType === 'others-relay-settings') return 'Relays and Storage Settings'
return 'Page' return 'Page'
} }

Loading…
Cancel
Save