Browse Source

move follows feed to it's own page

imwald
Silberengel 1 month ago
parent
commit
92bfce5cb8
  1. 53
      src/PageManager.tsx
  2. 8
      src/components/FavoriteRelaysActiveStrip/index.tsx
  3. 13
      src/components/KindFilter/index.tsx
  4. 52
      src/components/LatestFromFollowsSection/index.tsx
  5. 2
      src/components/NoteOptions/useMenuActions.tsx
  6. 9
      src/components/Profile/ProfileFeedWithPins.tsx
  7. 21
      src/components/Sidebar/FollowsLatestButton.tsx
  8. 2
      src/components/Sidebar/index.tsx
  9. 12
      src/constants.ts
  10. 6
      src/i18n/locales/de.ts
  11. 6
      src/i18n/locales/en.ts
  12. 52
      src/pages/primary/FollowsLatestPage/index.tsx
  13. 11
      src/pages/primary/SearchPage/index.tsx
  14. 4
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  15. 15
      src/pages/primary/SpellsPage/index.tsx
  16. 10
      src/pages/secondary/SearchPage/index.tsx
  17. 4
      src/providers/KindFilterProvider.tsx
  18. 2
      src/routes.tsx
  19. 7
      src/services/local-storage.service.ts

53
src/PageManager.tsx

@ -73,6 +73,7 @@ const MePageLazy = lazy(() => import('./pages/primary/MePage'))
const ProfilePageLazy = lazy(() => import('./pages/primary/ProfilePage')) const ProfilePageLazy = lazy(() => import('./pages/primary/ProfilePage'))
const RelayPageLazy = lazy(() => import('./pages/primary/RelayPage')) const RelayPageLazy = lazy(() => import('./pages/primary/RelayPage'))
const SearchPageLazy = lazy(() => import('./pages/primary/SearchPage')) const SearchPageLazy = lazy(() => import('./pages/primary/SearchPage'))
const FollowsLatestPageLazy = lazy(() => import('./pages/primary/FollowsLatestPage'))
const RssPageLazy = lazy(() => import('./pages/primary/RssPage')) const RssPageLazy = lazy(() => import('./pages/primary/RssPage'))
const SettingsPrimaryPageLazy = lazy(() => import('./pages/primary/SettingsPrimaryPage')) const SettingsPrimaryPageLazy = lazy(() => import('./pages/primary/SettingsPrimaryPage'))
@ -99,6 +100,7 @@ const PRIMARY_PAGE_REF_MAP = {
profile: createRef<TPageRef>(), profile: createRef<TPageRef>(),
relay: createRef<TPageRef>(), relay: createRef<TPageRef>(),
search: createRef<TPageRef>(), search: createRef<TPageRef>(),
'follows-latest': createRef<TPageRef>(),
rss: createRef<TPageRef>(), rss: createRef<TPageRef>(),
settings: createRef<TPageRef>(), settings: createRef<TPageRef>(),
spells: createRef<TPageRef>() spells: createRef<TPageRef>()
@ -137,6 +139,11 @@ const getPrimaryPageMap = () => ({
<SearchPageLazy ref={PRIMARY_PAGE_REF_MAP.search} /> <SearchPageLazy ref={PRIMARY_PAGE_REF_MAP.search} />
</Suspense> </Suspense>
), ),
'follows-latest': (
<Suspense fallback={primaryPageLazyFallback}>
<FollowsLatestPageLazy ref={PRIMARY_PAGE_REF_MAP['follows-latest']} />
</Suspense>
),
rss: ( rss: (
<Suspense fallback={primaryPageLazyFallback}> <Suspense fallback={primaryPageLazyFallback}>
<RssPageLazy ref={PRIMARY_PAGE_REF_MAP.rss} /> <RssPageLazy ref={PRIMARY_PAGE_REF_MAP.rss} />
@ -208,7 +215,15 @@ export { useSecondaryPage, useSecondaryPageOptional }
// Helper function to build contextual note URL // Helper function to build contextual note URL
function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): string { function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): string {
// Pages that should preserve context in the URL // Pages that should preserve context in the URL
const contextualPages: TPrimaryPageName[] = ['search', 'profile', 'feed', 'spells', 'rss', 'explore'] const contextualPages: TPrimaryPageName[] = [
'search',
'profile',
'feed',
'spells',
'rss',
'explore',
'follows-latest'
]
if (currentPage && contextualPages.includes(currentPage)) { if (currentPage && contextualPages.includes(currentPage)) {
return `/${currentPage}/notes/${noteId}` return `/${currentPage}/notes/${noteId}`
@ -219,7 +234,15 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str
function buildRssArticleUrl(articleUrl: string, currentPage: TPrimaryPageName | null): string { function buildRssArticleUrl(articleUrl: string, currentPage: TPrimaryPageName | null): string {
const key = encodeRssArticlePathSegment(articleUrl) const key = encodeRssArticlePathSegment(articleUrl)
const contextualPages: TPrimaryPageName[] = ['search', 'profile', 'feed', 'spells', 'rss', 'explore'] const contextualPages: TPrimaryPageName[] = [
'search',
'profile',
'feed',
'spells',
'rss',
'explore',
'follows-latest'
]
if (currentPage && contextualPages.includes(currentPage)) { if (currentPage && contextualPages.includes(currentPage)) {
return `/${currentPage}/rss-item/${key}` return `/${currentPage}/rss-item/${key}`
} }
@ -237,7 +260,7 @@ function secondaryUrlIsRssArticle(url: string): boolean {
/* keep path */ /* keep path */
} }
return ( return (
/^\/(discussions|search|profile|home|feed|spells|explore|rss)\/rss-item\/[^/?#]+/.test(path) || /^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/rss-item\/[^/?#]+/.test(path) ||
/^\/rss-item\/[^/?#]+/.test(path) /^\/rss-item\/[^/?#]+/.test(path)
) )
} }
@ -322,7 +345,7 @@ function restoredPrimaryBrowserUrl(pathname: string, fullUrlForQuery: string): s
function parseNoteUrl(url: string): { noteId: string; context?: string } { function parseNoteUrl(url: string): { noteId: string; context?: string } {
// Match patterns like /discussions/notes/{noteId} or /notes/{noteId} // Match patterns like /discussions/notes/{noteId} or /notes/{noteId}
const contextualMatch = url.match( const contextualMatch = url.match(
/\/(discussions|search|profile|home|feed|spells|explore|rss)\/notes\/(.+)$/ /\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/
) )
if (contextualMatch) { if (contextualMatch) {
return { noteId: contextualMatch[2], context: contextualMatch[1] } return { noteId: contextualMatch[2], context: contextualMatch[1] }
@ -458,7 +481,9 @@ export function useSmartRelayNavigation() {
const navigateToRelay = (url: string) => { const navigateToRelay = (url: string) => {
// Extract relay URL from path (handles both /relays/{url} and /{context}/relays/{url}) // Extract relay URL from path (handles both /relays/{url} and /{context}/relays/{url})
const relayUrlMatch = const relayUrlMatch =
url.match(/\/(discussions|search|profile|home|feed|spells|explore)\/relays\/(.+)$/) || url.match(
/\/(discussions|search|profile|home|feed|spells|explore|follows-latest)\/relays\/(.+)$/
) ||
url.match(/\/relays\/(.+)$/) url.match(/\/relays\/(.+)$/)
const relayUrl = relayUrlMatch ? decodeURIComponent(relayUrlMatch[relayUrlMatch.length - 1]) : decodeURIComponent(url.replace(/.*\/relays\//, '')) const relayUrl = relayUrlMatch ? decodeURIComponent(relayUrlMatch[relayUrlMatch.length - 1]) : decodeURIComponent(url.replace(/.*\/relays\//, ''))
@ -493,7 +518,9 @@ export function useSmartRelayNavigationOptional() {
const { current: currentPrimaryPage } = primaryPage const { current: currentPrimaryPage } = primaryPage
const navigateToRelay = (url: string) => { const navigateToRelay = (url: string) => {
const relayUrlMatch = const relayUrlMatch =
url.match(/\/(discussions|search|profile|home|feed|spells|explore)\/relays\/(.+)$/) || url.match(
/\/(discussions|search|profile|home|feed|spells|explore|follows-latest)\/relays\/(.+)$/
) ||
url.match(/\/relays\/(.+)$/) url.match(/\/relays\/(.+)$/)
const relayUrl = relayUrlMatch ? decodeURIComponent(relayUrlMatch[relayUrlMatch.length - 1]) : decodeURIComponent(url.replace(/.*\/relays\//, '')) const relayUrl = relayUrlMatch ? decodeURIComponent(relayUrlMatch[relayUrlMatch.length - 1]) : decodeURIComponent(url.replace(/.*\/relays\//, ''))
const contextualUrl = buildRelayUrl(relayUrl, currentPrimaryPage) const contextualUrl = buildRelayUrl(relayUrl, currentPrimaryPage)
@ -1012,7 +1039,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const pathname = window.location.pathname const pathname = window.location.pathname
// Check if this is a note URL - handle both /notes/{id} and /{context}/notes/{id} // Check if this is a note URL - handle both /notes/{id} and /{context}/notes/{id}
const contextualNoteMatch = pathname.match(/\/(discussions|search|profile|home|feed|spells|explore|rss)\/notes\/(.+)$/) const contextualNoteMatch = pathname.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/)
const standardNoteMatch = pathname.match(/\/notes\/(.+)$/) const standardNoteMatch = pathname.match(/\/notes\/(.+)$/)
const noteUrlMatch = contextualNoteMatch || standardNoteMatch const noteUrlMatch = contextualNoteMatch || standardNoteMatch
@ -1081,7 +1108,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// RSS article in side panel: /{context}/rss-item/{key} or /rss-item/{key} // RSS article in side panel: /{context}/rss-item/{key} or /rss-item/{key}
const contextualRssMatch = pathname.match( const contextualRssMatch = pathname.match(
/^\/(discussions|search|profile|home|feed|spells|explore|rss)\/rss-item\/([^/?#]+)/ /^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/rss-item\/([^/?#]+)/
) )
const standardRssMatch = pathname.match(/^\/rss-item\/([^/?#]+)/) const standardRssMatch = pathname.match(/^\/rss-item\/([^/?#]+)/)
const rssArticleKey = contextualRssMatch?.[2] ?? standardRssMatch?.[1] const rssArticleKey = contextualRssMatch?.[2] ?? standardRssMatch?.[1]
@ -1216,7 +1243,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Check if pathname matches a primary page name // Check if pathname matches a primary page name
// First, check if it's a contextual note URL (e.g., /discussions/notes/...) // First, check if it's a contextual note URL (e.g., /discussions/notes/...)
const contextualNoteMatch = pathname.match( const contextualNoteMatch = pathname.match(
/^\/(discussions|search|profile|home|feed|spells|explore|rss)\/notes\// /^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\//
) )
if (contextualNoteMatch) { if (contextualNoteMatch) {
const pageContext = contextualNoteMatch[1] const pageContext = contextualNoteMatch[1]
@ -1281,7 +1308,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const urlToCheck = state?.url || window.location.pathname const urlToCheck = state?.url || window.location.pathname
// Check if it's a note URL (we'll update drawer after stack is synced) // Check if it's a note URL (we'll update drawer after stack is synced)
const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|home|feed|spells|explore|rss)\/notes\/(.+)$/) || const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/) ||
urlToCheck.match(/\/notes\/(.+)$/) urlToCheck.match(/\/notes\/(.+)$/)
const noteIdToShow = noteUrlMatch ? noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] : null const noteIdToShow = noteUrlMatch ? noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] : null
@ -1303,7 +1330,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
/* keep pathname */ /* keep pathname */
} }
const ctxRssPop = rssPathSync.match( const ctxRssPop = rssPathSync.match(
/^\/(discussions|search|profile|home|feed|spells|explore|rss)\/rss-item\/([^/?#]+)/ /^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/rss-item\/([^/?#]+)/
) )
if (ctxRssPop) { if (ctxRssPop) {
const resolvedPop = noteContextToPrimaryEntry(ctxRssPop[1]) const resolvedPop = noteContextToPrimaryEntry(ctxRssPop[1])
@ -1394,7 +1421,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
// Check if navigating to a note URL (supports both /notes/{id} and /{context}/notes/{id}) // Check if navigating to a note URL (supports both /notes/{id} and /{context}/notes/{id})
const noteUrlMatch = state.url.match(/\/(discussions|search|profile|home|feed|spells|explore|rss)\/notes\/(.+)$/) || const noteUrlMatch = state.url.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/) ||
state.url.match(/\/notes\/(.+)$/) state.url.match(/\/notes\/(.+)$/)
if (noteUrlMatch) { if (noteUrlMatch) {
const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0]
@ -1445,7 +1472,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Extract noteId from top item's URL or from state.url // Extract noteId from top item's URL or from state.url
const topItemUrl = newStack[newStack.length - 1]?.url || state?.url const topItemUrl = newStack[newStack.length - 1]?.url || state?.url
if (topItemUrl) { if (topItemUrl) {
const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|home|feed|spells|explore|rss)\/notes\/(.+)$/) || const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/) ||
topItemUrl.match(/\/notes\/(.+)$/) topItemUrl.match(/\/notes\/(.+)$/)
if (topNoteUrlMatch) { if (topNoteUrlMatch) {
const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1].split('?')[0].split('#')[0] const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1].split('?')[0].split('#')[0]

8
src/components/FavoriteRelaysActiveStrip/index.tsx

@ -310,7 +310,7 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
avatarSize="small" avatarSize="small"
labelClassName="text-[0.7rem] font-medium text-muted-foreground" labelClassName="text-[0.7rem] font-medium text-muted-foreground"
stackClassName="w-full min-w-0 max-w-full" stackClassName="w-full min-w-0 max-w-full"
onOpenFollowsNotes={pubkey ? () => navigate('search', { expandFollows: true }) : undefined} onOpenFollowsNotes={pubkey ? () => navigate('follows-latest') : undefined}
/> />
</div> </div>
</div> </div>
@ -408,7 +408,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
className="size-7 shrink-0" className="size-7 shrink-0"
aria-label={t('See the newest notes from your follows')} aria-label={t('See the newest notes from your follows')}
title={t('See the newest notes from your follows')} title={t('See the newest notes from your follows')}
onClick={() => navigate('search', { expandFollows: true })} onClick={() => navigate('follows-latest')}
> >
<FileText className="size-3.5" /> <FileText className="size-3.5" />
</Button> </Button>
@ -429,7 +429,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
className="size-8 shrink-0" className="size-8 shrink-0"
aria-label={t('See the newest notes from your follows')} aria-label={t('See the newest notes from your follows')}
title={t('See the newest notes from your follows')} title={t('See the newest notes from your follows')}
onClick={() => navigate('search', { expandFollows: true })} onClick={() => navigate('follows-latest')}
> >
<FileText className="size-4" /> <FileText className="size-4" />
</Button> </Button>
@ -446,7 +446,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
avatarSize="xSmall" avatarSize="xSmall"
labelClassName="text-[0.6rem] font-medium text-muted-foreground xl:px-1" labelClassName="text-[0.6rem] font-medium text-muted-foreground xl:px-1"
stackClassName="w-full max-xl:items-center" stackClassName="w-full max-xl:items-center"
onOpenFollowsNotes={pubkey ? () => navigate('search', { expandFollows: true }) : undefined} onOpenFollowsNotes={pubkey ? () => navigate('follows-latest') : undefined}
/> />
</div> </div>
</div> </div>

13
src/components/KindFilter/index.tsx

@ -3,7 +3,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ExtendedKind, SUPPORTED_KINDS } from '@/constants' import { ExtendedKind, PROFILE_FEED_KINDS } from '@/constants'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilter } from '@/providers/KindFilterProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
@ -25,7 +25,8 @@ const KIND_FILTER_OPTIONS = [
{ kindGroup: [ExtendedKind.VIDEO, ExtendedKind.SHORT_VIDEO], label: 'Video Posts' }, { kindGroup: [ExtendedKind.VIDEO, ExtendedKind.SHORT_VIDEO], label: 'Video Posts' },
{ kindGroup: [ExtendedKind.DISCUSSION], label: 'Discussions' }, { kindGroup: [ExtendedKind.DISCUSSION], label: 'Discussions' },
{ kindGroup: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], label: 'Calendar Events' }, { kindGroup: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], label: 'Calendar Events' },
{ kindGroup: [ExtendedKind.ZAP_RECEIPT], label: 'Zaps' } { kindGroup: [ExtendedKind.ZAP_RECEIPT], label: 'Zaps' },
{ kindGroup: [kinds.Repost], label: 'Boosts' }
] ]
function buildShowKindsFromOptions( function buildShowKindsFromOptions(
@ -210,13 +211,7 @@ export default function KindFilter({
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
setTemporaryShowKinds( setTemporaryShowKinds(
SUPPORTED_KINDS.filter( PROFILE_FEED_KINDS.filter((k) => k !== KIND_1 && k !== KIND_1111)
(k) =>
k !== kinds.Repost &&
k !== ExtendedKind.PUBLICATION &&
k !== KIND_1 &&
k !== KIND_1111
)
) )
setTemporaryShowKind1OPs(true) setTemporaryShowKind1OPs(true)
setTemporaryShowKind1Replies(true) setTemporaryShowKind1Replies(true)

52
src/components/LatestFromFollowsSection/index.tsx

@ -25,7 +25,7 @@ import { useUserTrust } from '@/contexts/user-trust-context'
import { queryService, replaceableEventService } from '@/services/client.service' import { queryService, replaceableEventService } from '@/services/client.service'
import type { TRelayList } from '@/types' import type { TRelayList } from '@/types'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { ChevronDown, ChevronRight, Star } from 'lucide-react' import { ChevronRight, Star } from 'lucide-react'
import { Event, kinds, nip19, NostrEvent } from 'nostr-tools' import { Event, kinds, nip19, NostrEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -94,7 +94,15 @@ function recommendedCuratorHexPubkey(): string | null {
} }
} }
export default function LatestFromFollowsSection({ defaultOpen = false }: { defaultOpen?: boolean } = {}) { export default function LatestFromFollowsSection({
refreshKey = 0,
variant = 'embedded'
}: {
/** Bump to re-run batched relay fetches (e.g. titlebar / page refresh). */
refreshKey?: number
/** `page`: full-width list on the follows-latest primary page; `embedded`: tighter vertical spacing. */
variant?: 'page' | 'embedded'
} = {}) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { pubkey, followListEvent, isInitialized, isAccountSessionHydrating } = useNostr() const { pubkey, followListEvent, isInitialized, isAccountSessionHydrating } = useNostr()
@ -113,8 +121,6 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa
const [postsByPubkey, setPostsByPubkey] = useState<Map<string, NostrEvent[]>>(() => new Map()) const [postsByPubkey, setPostsByPubkey] = useState<Map<string, NostrEvent[]>>(() => new Map())
const [batchBusy, setBatchBusy] = useState(false) const [batchBusy, setBatchBusy] = useState(false)
/** Search page: start collapsed so the bar doesn’t push the search field; data still prefetches in the background. */
const [sectionOpen, setSectionOpen] = useState(defaultOpen)
const abortedRef = useRef(false) const abortedRef = useRef(false)
const followPubkeys = pubkey ? (loggedInFollowPubkeys ?? []) : guestFollowPubkeys const followPubkeys = pubkey ? (loggedInFollowPubkeys ?? []) : guestFollowPubkeys
@ -323,7 +329,8 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa
loadingFollowList, loadingFollowList,
isInitialized, isInitialized,
acceptEvent, acceptEvent,
followsFeedScopeKey followsFeedScopeKey,
refreshKey
]) ])
const sortedRowPubkeys = useMemo(() => { const sortedRowPubkeys = useMemo(() => {
@ -337,10 +344,7 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa
return [...withPosts, ...withoutPosts] return [...withPosts, ...withoutPosts]
}, [followPubkeys, postsByPubkey]) }, [followPubkeys, postsByPubkey])
const heading = const vertical = variant === 'page' ? '' : 'mb-6'
followsLabel === 'recommended'
? t('Latest from our recommended follows')
: t('Latest from your follows')
if (!isInitialized) { if (!isInitialized) {
return null return null
@ -348,7 +352,7 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa
if (loadingFollowList) { if (loadingFollowList) {
return ( return (
<div className="mb-6 space-y-2" role="status" aria-busy="true" aria-live="polite"> <div className={cn('space-y-2', vertical)} role="status" aria-busy="true" aria-live="polite">
<Skeleton className="h-4 w-56 max-w-full" /> <Skeleton className="h-4 w-56 max-w-full" />
<Skeleton className="h-4 w-72 max-w-full" /> <Skeleton className="h-4 w-72 max-w-full" />
</div> </div>
@ -357,7 +361,12 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa
if (followPubkeys.length === 0) { if (followPubkeys.length === 0) {
return ( return (
<div className="mb-6 rounded-lg border border-border/80 bg-muted/20 px-4 py-3 text-sm text-muted-foreground"> <div
className={cn(
'rounded-lg border border-border/80 bg-muted/20 px-4 py-3 text-sm text-muted-foreground',
vertical
)}
>
{followsLabel === 'recommended' {followsLabel === 'recommended'
? t('Could not load recommended follows') ? t('Could not load recommended follows')
: t('Your follow list is empty')} : t('Your follow list is empty')}
@ -366,20 +375,7 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa
} }
return ( return (
<Collapsible open={sectionOpen} onOpenChange={setSectionOpen} className="min-w-0"> <div className="min-w-0 space-y-0 rounded-lg border border-border/60 overflow-hidden">
<CollapsibleTrigger className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/80 bg-muted/15 px-3 py-2.5 text-left hover:bg-muted/25">
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="text-base font-semibold">{heading}</span>
{batchBusy && postsByPubkey.size === 0 ? (
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />
) : null}
</span>
<ChevronDown
className={cn('size-5 shrink-0 text-muted-foreground transition-transform', sectionOpen && 'rotate-180')}
/>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="mt-2 space-y-0 rounded-lg border border-border/60 overflow-hidden">
{batchBusy && postsByPubkey.size === 0 ? ( {batchBusy && postsByPubkey.size === 0 ? (
<div className="space-y-2 px-4 py-4" role="status" aria-busy="true" aria-live="polite"> <div className="space-y-2 px-4 py-4" role="status" aria-busy="true" aria-live="polite">
<Skeleton className="h-3 w-64 max-w-full" /> <Skeleton className="h-3 w-64 max-w-full" />
@ -403,14 +399,12 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa
/> />
) )
})} })}
</div>
{batchBusy && postsByPubkey.size > 0 ? ( {batchBusy && postsByPubkey.size > 0 ? (
<div className="mt-2 px-1"> <div className="px-4 py-2 border-t border-border/50">
<Skeleton className="h-3 w-28" aria-hidden /> <Skeleton className="h-3 w-28" aria-hidden />
</div> </div>
) : null} ) : null}
</CollapsibleContent> </div>
</Collapsible>
) )
} }

2
src/components/NoteOptions/useMenuActions.tsx

@ -640,6 +640,8 @@ export function useMenuActions({
? `/spells/notes/${noteId}` ? `/spells/notes/${noteId}`
: currentPrimaryPage === 'rss' : currentPrimaryPage === 'rss'
? `/rss/notes/${noteId}` ? `/rss/notes/${noteId}`
: currentPrimaryPage === 'follows-latest'
? `/follows-latest/notes/${noteId}`
: `/notes/${noteId}` : `/notes/${noteId}`
const jumbleUrl = `https://jumble.imwald.eu${path}` const jumbleUrl = `https://jumble.imwald.eu${path}`
navigator.clipboard.writeText(jumbleUrl) navigator.clipboard.writeText(jumbleUrl)

9
src/components/Profile/ProfileFeedWithPins.tsx

@ -42,6 +42,11 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { zapReplyThreshold } = useZap() const { zapReplyThreshold } = useZap()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilter() const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilter()
/** Profile timelines always show reposts; global kind filter still applies to other kinds. */
const profileTimelineShowKinds = useMemo(() => {
if (showKinds.includes(kinds.Repost)) return showKinds
return [...showKinds, kinds.Repost].sort((a, b) => a - b)
}, [showKinds])
const hideReplies = useHideRepliesLikeMainFeed() const hideReplies = useHideRepliesLikeMainFeed()
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
@ -77,7 +82,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
const passesMainFeedTimelineRules = useCallback( const passesMainFeedTimelineRules = useCallback(
(event: Event) => { (event: Event) => {
if (!showKinds.includes(event.kind)) return false if (!profileTimelineShowKinds.includes(event.kind)) return false
if (event.kind === kinds.ShortTextNote) { if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event) const isReply = isReplyNoteEvent(event)
if (hideReplies && isReply) return false if (hideReplies && isReply) return false
@ -87,7 +92,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false
return true return true
}, },
[showKinds, showKind1OPs, showKind1Replies, showKind1111, hideReplies] [profileTimelineShowKinds, showKind1OPs, showKind1Replies, showKind1111, hideReplies]
) )
const restTimeline = useMemo( const restTimeline = useMemo(

21
src/components/Sidebar/FollowsLatestButton.tsx

@ -0,0 +1,21 @@
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { UsersRound } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import SidebarItem from './SidebarItem'
export default function FollowsLatestButton() {
const { t } = useTranslation()
const { navigate, current, display } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView()
return (
<SidebarItem
title={t('Follows latest nav label')}
onClick={() => navigate('follows-latest')}
active={current === 'follows-latest' && display && primaryViewType === null}
>
<UsersRound strokeWidth={2.5} />
</SidebarItem>
)
}

2
src/components/Sidebar/index.tsx

@ -9,6 +9,7 @@ import NotificationButton from './NotificationButton'
import PostButton from './PostButton' import PostButton from './PostButton'
import RssButton from './RssButton' import RssButton from './RssButton'
import SearchButton from './SearchButton' import SearchButton from './SearchButton'
import FollowsLatestButton from './FollowsLatestButton'
import SpellsButton from './SpellsButton' import SpellsButton from './SpellsButton'
import { FavoriteRelaysActiveStripSidebar } from '@/components/FavoriteRelaysActiveStrip' import { FavoriteRelaysActiveStripSidebar } from '@/components/FavoriteRelaysActiveStrip'
import PaneModeToggle from './PaneModeToggle' import PaneModeToggle from './PaneModeToggle'
@ -35,6 +36,7 @@ export default function PrimaryPageSidebar() {
<DiscussionsButton /> <DiscussionsButton />
<NotificationButton /> <NotificationButton />
<SearchButton /> <SearchButton />
<FollowsLatestButton />
<SpellsButton /> <SpellsButton />
<RssButton /> <RssButton />
<FavoriteRelaysActiveStripSidebar /> <FavoriteRelaysActiveStripSidebar />

12
src/constants.ts

@ -366,16 +366,24 @@ export const SUPPORTED_KINDS = [
ExtendedKind.APPLICATION_HANDLER_INFO ExtendedKind.APPLICATION_HANDLER_INFO
] ]
/** Kinds for profile feed and favorites-style feeds: supported kinds except boosts (kind 6), publications, publication content, NIP-89 handlers. */ /**
* Kinds for profile-style feeds and the kind-filter UI (includes boosts). Excludes publications,
* publication content, and NIP-89 handler kinds.
*/
export const PROFILE_FEED_KINDS = SUPPORTED_KINDS.filter( export const PROFILE_FEED_KINDS = SUPPORTED_KINDS.filter(
(k) => (k) =>
k !== kinds.Repost &&
k !== ExtendedKind.PUBLICATION && k !== ExtendedKind.PUBLICATION &&
k !== ExtendedKind.PUBLICATION_CONTENT && k !== ExtendedKind.PUBLICATION_CONTENT &&
k !== ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION && k !== ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION &&
k !== ExtendedKind.APPLICATION_HANDLER_INFO k !== ExtendedKind.APPLICATION_HANDLER_INFO
) )
/**
* {@link PROFILE_FEED_KINDS} without reposts (kind 6). Default for the global kind filter, home feed,
* and most faux spells. Reposts are still shown on profile timelines, Spells Following, and Follows latest.
*/
export const DEFAULT_FEED_SHOW_KINDS = PROFILE_FEED_KINDS.filter((k) => k !== kinds.Repost)
/** Order for faux-spells in the feed / spell picker. */ /** Order for faux-spells in the feed / spell picker. */
export const FAUX_SPELL_ORDER = [ export const FAUX_SPELL_ORDER = [
'notifications', 'notifications',

6
src/i18n/locales/de.ts

@ -429,6 +429,7 @@ export default {
All: 'Alle', All: 'Alle',
Reactions: 'Reaktionen', Reactions: 'Reaktionen',
Zaps: 'Zaps', Zaps: 'Zaps',
Boosts: 'Boosts',
Badges: 'Abzeichen', Badges: 'Abzeichen',
'Enjoying Jumble?': 'Gefällt dir Jumble?', 'Enjoying Jumble?': 'Gefällt dir Jumble?',
'Your donation helps me maintain Jumble and make it better! 😊': 'Your donation helps me maintain Jumble and make it better! 😊':
@ -670,7 +671,6 @@ export default {
'No more boosts': 'Keine weiteren Boosts', 'No more boosts': 'Keine weiteren Boosts',
'No boosts yet': 'Noch keine Boosts', 'No boosts yet': 'Noch keine Boosts',
'n more boosts': '{{count}} weitere Boosts', 'n more boosts': '{{count}} weitere Boosts',
Boosts: 'Boosts',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'Folgeliste nicht gefunden. Möchten Sie eine neue erstellen? Wenn Sie zuvor Benutzer gefolgt haben, bestätigen Sie bitte NICHT, da diese Operation dazu führt, dass Sie Ihre vorherige Folgeliste verlieren.', 'Folgeliste nicht gefunden. Möchten Sie eine neue erstellen? Wenn Sie zuvor Benutzer gefolgt haben, bestätigen Sie bitte NICHT, da diese Operation dazu führt, dass Sie Ihre vorherige Folgeliste verlieren.',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -780,6 +780,10 @@ export default {
'Trending on the Default Relays': 'Trending auf den Standard-Relays', 'Trending on the Default Relays': 'Trending auf den Standard-Relays',
'Latest from your follows': 'Neuestes von deinen Follows', 'Latest from your follows': 'Neuestes von deinen Follows',
'Latest from our recommended follows': 'Neuestes von unseren empfohlenen Follows', 'Latest from our recommended follows': 'Neuestes von unseren empfohlenen Follows',
'Follows latest page title': 'Neuestes von Follows',
'Follows latest page description':
'Aktuelle Notizen von Leuten, denen du folgst (ohne Konto: unsere kuratierte Liste). Wir führen Outbox-Relays aus ihren NIP-65-Listen mit deinen Favoriten zusammen und laden in Stapeln. Zeile aufklappen für Notizen oder Profil antippen.',
'Follows latest nav label': 'Follows: neueste',
'Loading follow list…': 'Follow-Liste wird geladen …', 'Loading follow list…': 'Follow-Liste wird geladen …',
'Could not load recommended follows': 'Empfohlene Follows konnten nicht geladen werden', 'Could not load recommended follows': 'Empfohlene Follows konnten nicht geladen werden',
'Your follow list is empty': 'Deine Follow-Liste ist leer', 'Your follow list is empty': 'Deine Follow-Liste ist leer',

6
src/i18n/locales/en.ts

@ -425,6 +425,7 @@ export default {
All: 'All', All: 'All',
Reactions: 'Reactions', Reactions: 'Reactions',
Zaps: 'Zaps', Zaps: 'Zaps',
Boosts: 'Boosts',
Badges: 'Badges', Badges: 'Badges',
'Enjoying Jumble?': 'Enjoying Jumble?', 'Enjoying Jumble?': 'Enjoying Jumble?',
'Your donation helps me maintain Jumble and make it better! 😊': 'Your donation helps me maintain Jumble and make it better! 😊':
@ -660,7 +661,6 @@ export default {
'No more boosts': 'No more boosts', 'No more boosts': 'No more boosts',
'No boosts yet': 'No boosts yet', 'No boosts yet': 'No boosts yet',
'n more boosts': '{{count}} more boosts', 'n more boosts': '{{count}} more boosts',
Boosts: 'Boosts',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'Follow list not found. Do you want to create a new one? If you have followed users before, please DO NOT confirm as this operation will cause you to lose your previous follow list.', 'Follow list not found. Do you want to create a new one? If you have followed users before, please DO NOT confirm as this operation will cause you to lose your previous follow list.',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -766,6 +766,10 @@ export default {
'Trending on the Default Relays': 'Trending on the Default Relays', 'Trending on the Default Relays': 'Trending on the Default Relays',
'Latest from your follows': 'Latest from your follows', 'Latest from your follows': 'Latest from your follows',
'Latest from our recommended follows': 'Latest from our recommended follows', 'Latest from our recommended follows': 'Latest from our recommended follows',
'Follows latest page title': 'Latest from follows',
'Follows latest page description':
'Recent notes from accounts you follow (or a curated list when not signed in), using their outbox relays merged with your favorites. Expand a row for notes or open the profile from the row.',
'Follows latest nav label': 'Follows latest',
'Loading follow list…': 'Loading follow list…', 'Loading follow list…': 'Loading follow list…',
'Could not load recommended follows': 'Could not load recommended follows', 'Could not load recommended follows': 'Could not load recommended follows',
'Your follow list is empty': 'Your follow list is empty', 'Your follow list is empty': 'Your follow list is empty',

52
src/pages/primary/FollowsLatestPage/index.tsx

@ -0,0 +1,52 @@
import LatestFromFollowsSection from '@/components/LatestFromFollowsSection'
import { RefreshButton } from '@/components/RefreshButton'
import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout'
import { TPageRef } from '@/types'
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const FollowsLatestPage = forwardRef<TPageRef>(function FollowsLatestPage(_, ref) {
const { t } = useTranslation()
const [refreshKey, setRefreshKey] = useState(0)
const layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const bumpRefresh = useCallback(() => {
setRefreshKey((k) => k + 1)
}, [])
useImperativeHandle(
ref,
() => ({
scrollToTop: (behavior: ScrollBehavior = 'smooth') => layoutRef.current?.scrollToTop(behavior),
refresh: bumpRefresh
}),
[bumpRefresh]
)
return (
<PrimaryPageLayout
ref={layoutRef}
pageName="follows-latest"
titlebar={null}
displayScrollToTopButton
>
<div className="min-w-0 pt-4 px-4 pb-8">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<h1 className="text-2xl font-bold tracking-tight">{t('Follows latest page title')}</h1>
<p className="max-w-prose text-sm text-muted-foreground leading-relaxed">
{t('Follows latest page description')}
</p>
</div>
<div className="shrink-0 self-start sm:self-center">
<RefreshButton onClick={bumpRefresh} />
</div>
</div>
<LatestFromFollowsSection refreshKey={refreshKey} variant="page" />
</div>
</PrimaryPageLayout>
)
})
FollowsLatestPage.displayName = 'FollowsLatestPage'
export default FollowsLatestPage

11
src/pages/primary/SearchPage/index.tsx

@ -1,4 +1,3 @@
import LatestFromFollowsSection from '@/components/LatestFromFollowsSection'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import SearchBar, { TSearchBarRef } from '@/components/SearchBar' import SearchBar, { TSearchBarRef } from '@/components/SearchBar'
import SearchResult from '@/components/SearchResult' import SearchResult from '@/components/SearchResult'
@ -11,9 +10,7 @@ import { BookOpen } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
type SearchPageProps = { expandFollows?: boolean } const SearchPage = forwardRef<TPageRef>((_props, ref) => {
const SearchPage = forwardRef<TPageRef>((props: SearchPageProps, ref) => {
const { expandFollows } = props ?? {}
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
const { pubkey, relayList } = useNostr() const { pubkey, relayList } = useNostr()
const [input, setInput] = useState('') const [input, setInput] = useState('')
@ -88,13 +85,7 @@ const SearchPage = forwardRef<TPageRef>((props: SearchPageProps, ref) => {
</div> </div>
<div className="h-4"></div> <div className="h-4"></div>
<div key={resultRefreshKey} className="min-w-0"> <div key={resultRefreshKey} className="min-w-0">
{searchParams ? (
<SearchResult searchParams={searchParams} /> <SearchResult searchParams={searchParams} />
) : pubkey ? (
<div className="mb-4 min-w-0 space-y-2">
<LatestFromFollowsSection defaultOpen={expandFollows} />
</div>
) : null}
</div> </div>
</div> </div>
</PrimaryPageLayout> </PrimaryPageLayout>

4
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -9,7 +9,7 @@
* inbox+favorites fill the cap and global kinds/media/hashtags never hit aggr). The **interests** spell * inbox+favorites fill the cap and global kinds/media/hashtags never hit aggr). The **interests** spell
* uses **one** shard: all subscribed topics in one `#t` filter (NIP-01 OR semantics). * uses **one** shard: all subscribed topics in one `#t` filter (NIP-01 OR semantics).
*/ */
import { ExtendedKind, PROFILE_FEED_KINDS, READ_ONLY_RELAY_URLS } from '@/constants' import { DEFAULT_FEED_SHOW_KINDS, ExtendedKind, READ_ONLY_RELAY_URLS } from '@/constants'
import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays' import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays'
import { normalizeTopic } from '@/lib/discussion-topics' import { normalizeTopic } from '@/lib/discussion-topics'
import { userIdToPubkey } from '@/lib/pubkey' import { userIdToPubkey } from '@/lib/pubkey'
@ -182,7 +182,7 @@ export function buildCalendarSpellFilter(): Filter {
export function buildInterestsSubRequests( export function buildInterestsSubRequests(
relayUrls: string[], relayUrls: string[],
rawTopics: string[], rawTopics: string[],
kindsList: number[] = PROFILE_FEED_KINDS kindsList: number[] = DEFAULT_FEED_SHOW_KINDS
): TFeedSubRequest[] { ): TFeedSubRequest[] {
if (!relayUrls.length || !rawTopics.length || !kindsList.length) return [] if (!relayUrls.length || !rawTopics.length || !kindsList.length) return []
const topics = Array.from( const topics = Array.from(

15
src/pages/primary/SpellsPage/index.tsx

@ -35,9 +35,9 @@ import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { import {
ExtendedKind, ExtendedKind,
DEFAULT_FEED_SHOW_KINDS,
FAUX_SPELL_ORDER, FAUX_SPELL_ORDER,
FIRST_RELAY_RESULT_GRACE_MS, FIRST_RELAY_RESULT_GRACE_MS,
PROFILE_FEED_KINDS
} from '@/constants' } from '@/constants'
import { isUserInEventMentions } from '@/lib/event' import { isUserInEventMentions } from '@/lib/event'
import { formatPubkey } from '@/lib/pubkey' import { formatPubkey } from '@/lib/pubkey'
@ -81,7 +81,7 @@ import {
Wand2 Wand2
} from 'lucide-react' } from 'lucide-react'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { verifyEvent } from 'nostr-tools' import { kinds as nostrKinds, verifyEvent } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import CreateSpellDialog from './CreateSpellDialog' import CreateSpellDialog from './CreateSpellDialog'
@ -703,7 +703,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (!pubkey || !interestListEvent) return [] if (!pubkey || !interestListEvent) return []
const topics = interestListEvent.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!) const topics = interestListEvent.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!)
const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays)
return buildInterestsSubRequests(urls, topics, PROFILE_FEED_KINDS) return buildInterestsSubRequests(urls, topics, DEFAULT_FEED_SHOW_KINDS)
} }
if (selectedFauxSpell === 'bookmarks') { if (selectedFauxSpell === 'bookmarks') {
if (!pubkey) return [] if (!pubkey) return []
@ -859,7 +859,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return [ExtendedKind.DISCUSSION] return [ExtendedKind.DISCUSSION]
} }
if (selectedFauxSpell === 'following') { if (selectedFauxSpell === 'following') {
return kindFilterShowKinds // Profile feed kinds omit boosts; show reposts as cards in this faux spell only.
const k = kindFilterShowKinds
if (k.includes(nostrKinds.Repost)) return k
return [...k, nostrKinds.Repost].sort((a, b) => a - b)
} }
if (selectedFauxSpell === 'followPacks') { if (selectedFauxSpell === 'followPacks') {
return [ExtendedKind.FOLLOW_PACK] return [ExtendedKind.FOLLOW_PACK]
@ -871,10 +874,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME] return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME]
} }
if (selectedFauxSpell === 'interests') { if (selectedFauxSpell === 'interests') {
return PROFILE_FEED_KINDS return [...DEFAULT_FEED_SHOW_KINDS]
} }
if (selectedFauxSpell === 'bookmarks') { if (selectedFauxSpell === 'bookmarks') {
return PROFILE_FEED_KINDS return [...DEFAULT_FEED_SHOW_KINDS]
} }
if (!selectedSpell) return [1] if (!selectedSpell) return [1]
const kinds = selectedSpell.tags const kinds = selectedSpell.tags

10
src/pages/secondary/SearchPage/index.tsx

@ -1,4 +1,3 @@
import LatestFromFollowsSection from '@/components/LatestFromFollowsSection'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import SearchBar, { TSearchBarRef } from '@/components/SearchBar' import SearchBar, { TSearchBarRef } from '@/components/SearchBar'
import SearchResult from '@/components/SearchResult' import SearchResult from '@/components/SearchResult'
@ -9,8 +8,8 @@ import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { TSearchParams } from '@/types'
import { BookOpen } from 'lucide-react' import { BookOpen } from 'lucide-react'
import { TSearchParams } from '@/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -151,14 +150,7 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
</div> </div>
<div className="h-4"></div> <div className="h-4"></div>
<div key={resultRefreshKey} className="min-w-0"> <div key={resultRefreshKey} className="min-w-0">
{searchParams ? (
<SearchResult searchParams={searchParams} /> <SearchResult searchParams={searchParams} />
) : (
<div className="mb-4 min-w-0 space-y-2">
<LatestFromFollowsSection />
<SearchResult searchParams={null} />
</div>
)}
</div> </div>
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>

4
src/providers/KindFilterProvider.tsx

@ -1,6 +1,6 @@
import { createContext, useContext, useState, useCallback, useMemo } from 'react' import { createContext, useContext, useState, useCallback, useMemo } from 'react'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { ExtendedKind, PROFILE_FEED_KINDS } from '@/constants' import { DEFAULT_FEED_SHOW_KINDS, ExtendedKind } from '@/constants'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
const KIND_1 = kinds.ShortTextNote const KIND_1 = kinds.ShortTextNote
@ -42,7 +42,7 @@ export const useKindFilter = () => {
} }
export function KindFilterProvider({ children }: { children: React.ReactNode }) { export function KindFilterProvider({ children }: { children: React.ReactNode }) {
const defaultShowKinds = PROFILE_FEED_KINDS const defaultShowKinds = DEFAULT_FEED_SHOW_KINDS
const storedShowKinds = storage.getShowKinds() const storedShowKinds = storage.getShowKinds()
const storedShowKind1OPs = storage.getShowKind1OPs() const storedShowKind1OPs = storage.getShowKind1OPs()
const storedShowKind1Replies = storage.getShowKind1Replies() const storedShowKind1Replies = storage.getShowKind1Replies()

2
src/routes.tsx

@ -46,6 +46,7 @@ const ROUTES = [
{ path: '/notes/:id', element: SR(NotePageLazy) }, { path: '/notes/:id', element: SR(NotePageLazy) },
{ path: '/discussions/notes/:id', element: SR(NotePageLazy) }, { path: '/discussions/notes/:id', element: SR(NotePageLazy) },
{ path: '/search/notes/:id', element: SR(NotePageLazy) }, { path: '/search/notes/:id', element: SR(NotePageLazy) },
{ path: '/follows-latest/notes/:id', element: SR(NotePageLazy) },
{ path: '/profile/notes/:id', element: SR(NotePageLazy) }, { path: '/profile/notes/:id', element: SR(NotePageLazy) },
{ path: '/explore/notes/:id', element: SR(NotePageLazy) }, { path: '/explore/notes/:id', element: SR(NotePageLazy) },
{ path: '/home/notes/:id', element: SR(NotePageLazy) }, { path: '/home/notes/:id', element: SR(NotePageLazy) },
@ -56,6 +57,7 @@ const ROUTES = [
{ path: '/rss/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/rss/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/feed/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/feed/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/search/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/search/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/follows-latest/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/profile/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/profile/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/spells/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/spells/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/explore/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/explore/rss-item/:articleKey', element: SR(RssArticlePageLazy) },

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

@ -3,7 +3,7 @@ import {
ExtendedKind, ExtendedKind,
MEDIA_AUTO_LOAD_POLICY, MEDIA_AUTO_LOAD_POLICY,
NOTIFICATION_LIST_STYLE, NOTIFICATION_LIST_STYLE,
PROFILE_FEED_KINDS, DEFAULT_FEED_SHOW_KINDS,
StorageKey StorageKey
} from '@/constants' } from '@/constants'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
@ -223,7 +223,7 @@ class LocalStorageService {
const showKindsStr = window.localStorage.getItem(StorageKey.SHOW_KINDS) const showKindsStr = window.localStorage.getItem(StorageKey.SHOW_KINDS)
if (!showKindsStr) { if (!showKindsStr) {
this.showKinds = [...PROFILE_FEED_KINDS] this.showKinds = [...DEFAULT_FEED_SHOW_KINDS]
} else { } else {
const showKindsVersionStr = window.localStorage.getItem(StorageKey.SHOW_KINDS_VERSION) const showKindsVersionStr = window.localStorage.getItem(StorageKey.SHOW_KINDS_VERSION)
const showKindsVersion = showKindsVersionStr ? parseInt(showKindsVersionStr) : 0 const showKindsVersion = showKindsVersionStr ? parseInt(showKindsVersionStr) : 0
@ -291,10 +291,11 @@ class LocalStorageService {
} }
} }
} }
// v9: boosts are optional in the same filter list as other kinds; do not auto-enable (leave absent).
this.showKinds = showKinds this.showKinds = showKinds
} }
this.persistSetting(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds)) this.persistSetting(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds))
this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '8') this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '9')
// Feed filter: kind 1 OPs, kind 1 replies, kind 1111 (migrate from legacy showRepliesAndComments if set) // Feed filter: kind 1 OPs, kind 1 replies, kind 1111 (migrate from legacy showRepliesAndComments if set)
const showKind1OPsStr = window.localStorage.getItem(StorageKey.SHOW_KIND_1_OPs) const showKind1OPsStr = window.localStorage.getItem(StorageKey.SHOW_KIND_1_OPs)

Loading…
Cancel
Save