Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
d823a14b51
  1. 71
      src/PageManager.tsx
  2. 10
      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 @@ -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( @@ -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() { @@ -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(<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
export function useSmartOthersRelaySettingsNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
@ -780,7 +823,7 @@ export function useSmartSettingsNavigation() { @@ -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({ @@ -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 }) { @@ -913,7 +956,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
secondaryStackRef.current = secondaryStack
}, [secondaryStack])
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 [drawerOpen, setDrawerOpen] = useState(false)
const [drawerNoteId, setDrawerNoteId] = useState<string | null>(null)
@ -949,7 +992,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -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 }) { @@ -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 }) { @@ -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}
>
<ChevronLeft />
<div className="truncate text-lg font-semibold">
{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)}
</div>
</Button>
</div>

10
src/components/NoteList/index.tsx

@ -15,7 +15,7 @@ import { @@ -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,14 +672,10 @@ const NoteList = forwardRef( @@ -672,14 +672,10 @@ 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) {
// 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

38
src/components/PersonalListBech32List/index.tsx

@ -0,0 +1,38 @@ @@ -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 @@ @@ -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 @@ @@ -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[] @@ -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
},

7
src/components/Profile/ProfileFeedWithPins.tsx

@ -3,7 +3,7 @@ import ProfileSearchBar from '@/components/ui/ProfileSearchBar' @@ -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 @@ -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
},

14
src/components/ReplyNoteList/index.tsx

@ -18,7 +18,7 @@ import { @@ -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' @@ -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[]) { @@ -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({ @@ -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<TRootInfo | undefined>(undefined)
@ -397,7 +403,8 @@ function ReplyNoteList({ @@ -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({ @@ -459,7 +466,8 @@ function ReplyNoteList({
repliesMap,
mutePubkeySet,
hideContentMentioningMutedUsers,
sort
sort,
zapReplyThreshold
])
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 = @@ -9,6 +9,8 @@ export type TPrimaryOverlayViewType =
| 'relay'
| 'following'
| 'mute'
| 'bookmarks'
| 'pins'
| 'others-relay-settings'
export type PrimaryNoteViewContextValue = {

21
src/i18n/locales/en.ts

@ -980,7 +980,7 @@ export default { @@ -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 { @@ -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',

14
src/lib/event-metadata.ts

@ -348,6 +348,7 @@ export function getZapInfoFromEvent(receiptEvent: Event) { @@ -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) { @@ -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

4
src/lib/link.ts

@ -78,6 +78,10 @@ export const toProfileEditor = () => '/profile-editor' @@ -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) => {

44
src/lib/personal-list-refs.ts

@ -0,0 +1,44 @@ @@ -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 @@ @@ -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' @@ -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( @@ -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( @@ -47,10 +51,7 @@ const PersonalListsSettingsPage = forwardRef(
>
<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>
<SettingRow
className="clickable"
onClick={() => navigateToMuteList(toMuteList())}
>
<SettingRow className="clickable" onClick={() => navigateToMuteList(toMuteList())}>
<div className="flex items-center gap-3">
<VolumeX />
<div>{t('Mute list')}</div>
@ -70,31 +71,43 @@ const PersonalListsSettingsPage = forwardRef( @@ -70,31 +71,43 @@ const PersonalListsSettingsPage = forwardRef(
</SettingRow>
) : null}
{pubkey ? (
<SettingRow
className="clickable"
onClick={() => navigatePrimary('spells', { spell: 'bookmarks' })}
>
<SettingRow className="clickable" onClick={() => navigateToBookmarkList(toBookmarksList())}>
<div className="flex items-center gap-3">
<Bookmark />
<div>{t('Bookmarks spell')}</div>
<div>{t('Bookmarks list')}</div>
</div>
<ChevronRight />
</SettingRow>
) : null}
<SettingRow
className="clickable"
onClick={() => navigateToSettings(toFollowSetsSettings())}
>
{pubkey ? (
<SettingRow className="clickable" onClick={() => navigateToPinList(toPinsList())}>
<div className="flex items-center gap-3">
<Pin />
<div>{t('Pinned notes list')}</div>
</div>
<ChevronRight />
</SettingRow>
) : null}
<SettingRow className="clickable" onClick={() => navigateToSettings(toFollowSetsSettings())}>
<div className="flex items-center gap-3">
<Users />
<div>{t('Follow sets')}</div>
</div>
<ChevronRight />
</SettingRow>
<div className="flex min-h-[52px] items-center gap-3 rounded-lg px-4 py-2 text-sm text-muted-foreground">
<Pin className="size-4 shrink-0 opacity-80" />
<div>{t('Pinned notes hint')}</div>
</div>
<p className="flex min-h-[52px] items-start gap-3 rounded-lg px-4 py-2 text-sm text-muted-foreground">
<Bookmark className="mt-0.5 size-4 shrink-0 opacity-80" />
<span>
{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>
</SecondaryPageLayout>
)

151
src/pages/secondary/PinListPage/index.tsx

@ -0,0 +1,151 @@ @@ -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 @@ @@ -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() { @@ -9,6 +9,10 @@ export default function ZapReplyThresholdInput() {
const { zapReplyThreshold, updateZapReplyThreshold } = useZap()
const [zapReplyThresholdInput, setZapReplyThresholdInput] = useState(zapReplyThreshold)
useEffect(() => {
setZapReplyThresholdInput(zapReplyThreshold)
}, [zapReplyThreshold])
return (
<div className="w-full space-y-1">
<Label htmlFor="zap-reply-threshold-input">

23
src/providers/MuteListProvider.tsx

@ -37,6 +37,8 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -37,6 +37,8 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation()
const {
pubkey: accountPubkey,
account,
isAccountSessionHydrating,
muteListEvent,
publish,
updateMuteListEvent,
@ -64,7 +66,12 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -64,7 +66,12 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
}, [accountPubkey])
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)
@ -78,13 +85,19 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -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()) {
logMuteListPrivateIssueOnce(
muteListEvent.id,
'Mute list has ciphertext but decryption returned empty (e.g. read-only / npub-only login). Public mutes still apply.',
undefined
'Mute list ciphertext could not be decrypted (npub-only / extension blocked NIP-04 / wrong key / corrupt payload). Public `p`/`e` mutes still apply.',
{ signerType: account.signerType }
)
return []
}
@ -123,7 +136,7 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -123,7 +136,7 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
setTags(muteListEvent.tags)
}
updateMuteTags()
}, [muteListEvent])
}, [muteListEvent, isAccountSessionHydrating, account?.signerType, account?.pubkey])
const getMutePubkeys = () => {
return Array.from(mutePubkeySet)

8
src/providers/NostrProvider/index.tsx

@ -1105,13 +1105,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1105,13 +1105,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (draft.tags?.length) {
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 =
typeof options.addClientTag === 'boolean'
? options.addClientTag
: (typeof window !== 'undefined' && storage.getAddClientTag())
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())
}
let event: VerifiedEvent

4
src/routes.tsx

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

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

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

16
src/services/navigation.service.ts

@ -33,7 +33,19 @@ const navLazyFallback = React.createElement( @@ -33,7 +33,19 @@ const navLazyFallback = React.createElement(
'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 {
setPrimaryNoteView: (view: ReactNode, type: ViewType) => void
@ -278,6 +290,8 @@ export class NavigationService { @@ -278,6 +290,8 @@ export class NavigationService {
}
if (viewType === 'following') return 'Following'
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'
return 'Page'
}

Loading…
Cancel
Save