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. 104
      src/components/LatestFromFollowsSection/index.tsx
  5. 4
      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. 13
      src/pages/primary/SearchPage/index.tsx
  14. 4
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  15. 15
      src/pages/primary/SpellsPage/index.tsx
  16. 12
      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')) @@ -73,6 +73,7 @@ const MePageLazy = lazy(() => import('./pages/primary/MePage'))
const ProfilePageLazy = lazy(() => import('./pages/primary/ProfilePage'))
const RelayPageLazy = lazy(() => import('./pages/primary/RelayPage'))
const SearchPageLazy = lazy(() => import('./pages/primary/SearchPage'))
const FollowsLatestPageLazy = lazy(() => import('./pages/primary/FollowsLatestPage'))
const RssPageLazy = lazy(() => import('./pages/primary/RssPage'))
const SettingsPrimaryPageLazy = lazy(() => import('./pages/primary/SettingsPrimaryPage'))
@ -99,6 +100,7 @@ const PRIMARY_PAGE_REF_MAP = { @@ -99,6 +100,7 @@ const PRIMARY_PAGE_REF_MAP = {
profile: createRef<TPageRef>(),
relay: createRef<TPageRef>(),
search: createRef<TPageRef>(),
'follows-latest': createRef<TPageRef>(),
rss: createRef<TPageRef>(),
settings: createRef<TPageRef>(),
spells: createRef<TPageRef>()
@ -137,6 +139,11 @@ const getPrimaryPageMap = () => ({ @@ -137,6 +139,11 @@ const getPrimaryPageMap = () => ({
<SearchPageLazy ref={PRIMARY_PAGE_REF_MAP.search} />
</Suspense>
),
'follows-latest': (
<Suspense fallback={primaryPageLazyFallback}>
<FollowsLatestPageLazy ref={PRIMARY_PAGE_REF_MAP['follows-latest']} />
</Suspense>
),
rss: (
<Suspense fallback={primaryPageLazyFallback}>
<RssPageLazy ref={PRIMARY_PAGE_REF_MAP.rss} />
@ -208,7 +215,15 @@ export { useSecondaryPage, useSecondaryPageOptional } @@ -208,7 +215,15 @@ export { useSecondaryPage, useSecondaryPageOptional }
// Helper function to build contextual note URL
function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): string {
// 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)) {
return `/${currentPage}/notes/${noteId}`
@ -219,7 +234,15 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str @@ -219,7 +234,15 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str
function buildRssArticleUrl(articleUrl: string, currentPage: TPrimaryPageName | null): string {
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)) {
return `/${currentPage}/rss-item/${key}`
}
@ -237,7 +260,7 @@ function secondaryUrlIsRssArticle(url: string): boolean { @@ -237,7 +260,7 @@ function secondaryUrlIsRssArticle(url: string): boolean {
/* keep path */
}
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)
)
}
@ -322,7 +345,7 @@ function restoredPrimaryBrowserUrl(pathname: string, fullUrlForQuery: string): s @@ -322,7 +345,7 @@ function restoredPrimaryBrowserUrl(pathname: string, fullUrlForQuery: string): s
function parseNoteUrl(url: string): { noteId: string; context?: string } {
// Match patterns like /discussions/notes/{noteId} or /notes/{noteId}
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) {
return { noteId: contextualMatch[2], context: contextualMatch[1] }
@ -458,7 +481,9 @@ export function useSmartRelayNavigation() { @@ -458,7 +481,9 @@ export function useSmartRelayNavigation() {
const navigateToRelay = (url: string) => {
// Extract relay URL from path (handles both /relays/{url} and /{context}/relays/{url})
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\/(.+)$/)
const relayUrl = relayUrlMatch ? decodeURIComponent(relayUrlMatch[relayUrlMatch.length - 1]) : decodeURIComponent(url.replace(/.*\/relays\//, ''))
@ -493,7 +518,9 @@ export function useSmartRelayNavigationOptional() { @@ -493,7 +518,9 @@ export function useSmartRelayNavigationOptional() {
const { current: currentPrimaryPage } = primaryPage
const navigateToRelay = (url: string) => {
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\/(.+)$/)
const relayUrl = relayUrlMatch ? decodeURIComponent(relayUrlMatch[relayUrlMatch.length - 1]) : decodeURIComponent(url.replace(/.*\/relays\//, ''))
const contextualUrl = buildRelayUrl(relayUrl, currentPrimaryPage)
@ -1012,7 +1039,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1012,7 +1039,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const pathname = window.location.pathname
// 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 noteUrlMatch = contextualNoteMatch || standardNoteMatch
@ -1081,7 +1108,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1081,7 +1108,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// RSS article in side panel: /{context}/rss-item/{key} or /rss-item/{key}
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 rssArticleKey = contextualRssMatch?.[2] ?? standardRssMatch?.[1]
@ -1216,7 +1243,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1216,7 +1243,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Check if pathname matches a primary page name
// First, check if it's a contextual note URL (e.g., /discussions/notes/...)
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) {
const pageContext = contextualNoteMatch[1]
@ -1281,7 +1308,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1281,7 +1308,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const urlToCheck = state?.url || window.location.pathname
// 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\/(.+)$/)
const noteIdToShow = noteUrlMatch ? noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] : null
@ -1303,7 +1330,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1303,7 +1330,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
/* keep pathname */
}
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) {
const resolvedPop = noteContextToPrimaryEntry(ctxRssPop[1])
@ -1394,7 +1421,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -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})
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\/(.+)$/)
if (noteUrlMatch) {
const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0]
@ -1445,7 +1472,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1445,7 +1472,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Extract noteId from top item's URL or from state.url
const topItemUrl = newStack[newStack.length - 1]?.url || state?.url
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\/(.+)$/)
if (topNoteUrlMatch) {
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?: @@ -310,7 +310,7 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
avatarSize="small"
labelClassName="text-[0.7rem] font-medium text-muted-foreground"
stackClassName="w-full min-w-0 max-w-full"
onOpenFollowsNotes={pubkey ? () => navigate('search', { expandFollows: true }) : undefined}
onOpenFollowsNotes={pubkey ? () => navigate('follows-latest') : undefined}
/>
</div>
</div>
@ -408,7 +408,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st @@ -408,7 +408,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
className="size-7 shrink-0"
aria-label={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" />
</Button>
@ -429,7 +429,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st @@ -429,7 +429,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
className="size-8 shrink-0"
aria-label={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" />
</Button>
@ -446,7 +446,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st @@ -446,7 +446,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
avatarSize="xSmall"
labelClassName="text-[0.6rem] font-medium text-muted-foreground xl:px-1"
stackClassName="w-full max-xl:items-center"
onOpenFollowsNotes={pubkey ? () => navigate('search', { expandFollows: true }) : undefined}
onOpenFollowsNotes={pubkey ? () => navigate('follows-latest') : undefined}
/>
</div>
</div>

13
src/components/KindFilter/index.tsx

@ -3,7 +3,7 @@ import { Checkbox } from '@/components/ui/checkbox' @@ -3,7 +3,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'
import { Label } from '@/components/ui/label'
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 { useKindFilter } from '@/providers/KindFilterProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
@ -25,7 +25,8 @@ const KIND_FILTER_OPTIONS = [ @@ -25,7 +25,8 @@ const KIND_FILTER_OPTIONS = [
{ kindGroup: [ExtendedKind.VIDEO, ExtendedKind.SHORT_VIDEO], label: 'Video Posts' },
{ kindGroup: [ExtendedKind.DISCUSSION], label: 'Discussions' },
{ 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(
@ -210,13 +211,7 @@ export default function KindFilter({ @@ -210,13 +211,7 @@ export default function KindFilter({
variant="secondary"
onClick={() => {
setTemporaryShowKinds(
SUPPORTED_KINDS.filter(
(k) =>
k !== kinds.Repost &&
k !== ExtendedKind.PUBLICATION &&
k !== KIND_1 &&
k !== KIND_1111
)
PROFILE_FEED_KINDS.filter((k) => k !== KIND_1 && k !== KIND_1111)
)
setTemporaryShowKind1OPs(true)
setTemporaryShowKind1Replies(true)

104
src/components/LatestFromFollowsSection/index.tsx

@ -25,7 +25,7 @@ import { useUserTrust } from '@/contexts/user-trust-context' @@ -25,7 +25,7 @@ import { useUserTrust } from '@/contexts/user-trust-context'
import { queryService, replaceableEventService } from '@/services/client.service'
import type { TRelayList } from '@/types'
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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -94,7 +94,15 @@ function recommendedCuratorHexPubkey(): string | null { @@ -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 { push } = useSecondaryPage()
const { pubkey, followListEvent, isInitialized, isAccountSessionHydrating } = useNostr()
@ -113,8 +121,6 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa @@ -113,8 +121,6 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa
const [postsByPubkey, setPostsByPubkey] = useState<Map<string, NostrEvent[]>>(() => new Map())
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 followPubkeys = pubkey ? (loggedInFollowPubkeys ?? []) : guestFollowPubkeys
@ -323,7 +329,8 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa @@ -323,7 +329,8 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa
loadingFollowList,
isInitialized,
acceptEvent,
followsFeedScopeKey
followsFeedScopeKey,
refreshKey
])
const sortedRowPubkeys = useMemo(() => {
@ -337,10 +344,7 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa @@ -337,10 +344,7 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa
return [...withPosts, ...withoutPosts]
}, [followPubkeys, postsByPubkey])
const heading =
followsLabel === 'recommended'
? t('Latest from our recommended follows')
: t('Latest from your follows')
const vertical = variant === 'page' ? '' : 'mb-6'
if (!isInitialized) {
return null
@ -348,7 +352,7 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa @@ -348,7 +352,7 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa
if (loadingFollowList) {
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-72 max-w-full" />
</div>
@ -357,7 +361,12 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa @@ -357,7 +361,12 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa
if (followPubkeys.length === 0) {
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'
? t('Could not load recommended follows')
: t('Your follow list is empty')}
@ -366,51 +375,36 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa @@ -366,51 +375,36 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa
}
return (
<Collapsible open={sectionOpen} onOpenChange={setSectionOpen} className="min-w-0">
<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 ? (
<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" />
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-md" />
))}
</div>
) : null}
{sortedRowPubkeys.map((pk) => {
const posts = postsByPubkey.get(pk) ?? []
const count = posts.length
const latest = posts[0]?.created_at
return (
<FollowPulseRow
key={pk}
pubkey={pk}
count={count}
latestCreatedAt={latest}
posts={posts}
onOpenProfile={() => push(toProfile(pk))}
/>
)
})}
<div className="min-w-0 space-y-0 rounded-lg border border-border/60 overflow-hidden">
{batchBusy && postsByPubkey.size === 0 ? (
<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" />
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-md" />
))}
</div>
{batchBusy && postsByPubkey.size > 0 ? (
<div className="mt-2 px-1">
<Skeleton className="h-3 w-28" aria-hidden />
</div>
) : null}
</CollapsibleContent>
</Collapsible>
) : null}
{sortedRowPubkeys.map((pk) => {
const posts = postsByPubkey.get(pk) ?? []
const count = posts.length
const latest = posts[0]?.created_at
return (
<FollowPulseRow
key={pk}
pubkey={pk}
count={count}
latestCreatedAt={latest}
posts={posts}
onOpenProfile={() => push(toProfile(pk))}
/>
)
})}
{batchBusy && postsByPubkey.size > 0 ? (
<div className="px-4 py-2 border-t border-border/50">
<Skeleton className="h-3 w-28" aria-hidden />
</div>
) : null}
</div>
)
}

4
src/components/NoteOptions/useMenuActions.tsx

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

9
src/components/Profile/ProfileFeedWithPins.tsx

@ -42,6 +42,11 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string @@ -42,6 +42,11 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
const { isEventDeleted } = useDeletedEvent()
const { zapReplyThreshold } = useZap()
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 [searchQuery, setSearchQuery] = useState('')
const [isRefreshing, setIsRefreshing] = useState(false)
@ -77,7 +82,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string @@ -77,7 +82,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
const passesMainFeedTimelineRules = useCallback(
(event: Event) => {
if (!showKinds.includes(event.kind)) return false
if (!profileTimelineShowKinds.includes(event.kind)) return false
if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event)
if (hideReplies && isReply) return false
@ -87,7 +92,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string @@ -87,7 +92,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false
return true
},
[showKinds, showKind1OPs, showKind1Replies, showKind1111, hideReplies]
[profileTimelineShowKinds, showKind1OPs, showKind1Replies, showKind1111, hideReplies]
)
const restTimeline = useMemo(

21
src/components/Sidebar/FollowsLatestButton.tsx

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

12
src/constants.ts

@ -366,16 +366,24 @@ export const SUPPORTED_KINDS = [ @@ -366,16 +366,24 @@ export const SUPPORTED_KINDS = [
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(
(k) =>
k !== kinds.Repost &&
k !== ExtendedKind.PUBLICATION &&
k !== ExtendedKind.PUBLICATION_CONTENT &&
k !== ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION &&
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. */
export const FAUX_SPELL_ORDER = [
'notifications',

6
src/i18n/locales/de.ts

@ -429,6 +429,7 @@ export default { @@ -429,6 +429,7 @@ export default {
All: 'Alle',
Reactions: 'Reaktionen',
Zaps: 'Zaps',
Boosts: 'Boosts',
Badges: 'Abzeichen',
'Enjoying Jumble?': 'Gefällt dir Jumble?',
'Your donation helps me maintain Jumble and make it better! 😊':
@ -670,7 +671,6 @@ export default { @@ -670,7 +671,6 @@ export default {
'No more boosts': 'Keine weiteren Boosts',
'No boosts yet': 'Noch keine Boosts',
'n more boosts': '{{count}} weitere Boosts',
Boosts: 'Boosts',
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.',
MuteListNotFoundConfirmation:
@ -780,6 +780,10 @@ export default { @@ -780,6 +780,10 @@ export default {
'Trending on the Default Relays': 'Trending auf den Standard-Relays',
'Latest from your follows': 'Neuestes von deinen 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 …',
'Could not load recommended follows': 'Empfohlene Follows konnten nicht geladen werden',
'Your follow list is empty': 'Deine Follow-Liste ist leer',

6
src/i18n/locales/en.ts

@ -425,6 +425,7 @@ export default { @@ -425,6 +425,7 @@ export default {
All: 'All',
Reactions: 'Reactions',
Zaps: 'Zaps',
Boosts: 'Boosts',
Badges: 'Badges',
'Enjoying Jumble?': 'Enjoying Jumble?',
'Your donation helps me maintain Jumble and make it better! 😊':
@ -660,7 +661,6 @@ export default { @@ -660,7 +661,6 @@ export default {
'No more boosts': 'No more boosts',
'No boosts yet': 'No boosts yet',
'n more boosts': '{{count}} more boosts',
Boosts: 'Boosts',
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.',
MuteListNotFoundConfirmation:
@ -766,6 +766,10 @@ export default { @@ -766,6 +766,10 @@ export default {
'Trending on the Default Relays': 'Trending on the Default Relays',
'Latest from your follows': 'Latest from your 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…',
'Could not load recommended follows': 'Could not load recommended follows',
'Your follow list is empty': 'Your follow list is empty',

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

@ -0,0 +1,52 @@ @@ -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

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

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

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

@ -9,7 +9,7 @@ @@ -9,7 +9,7 @@
* 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).
*/
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 { normalizeTopic } from '@/lib/discussion-topics'
import { userIdToPubkey } from '@/lib/pubkey'
@ -182,7 +182,7 @@ export function buildCalendarSpellFilter(): Filter { @@ -182,7 +182,7 @@ export function buildCalendarSpellFilter(): Filter {
export function buildInterestsSubRequests(
relayUrls: string[],
rawTopics: string[],
kindsList: number[] = PROFILE_FEED_KINDS
kindsList: number[] = DEFAULT_FEED_SHOW_KINDS
): TFeedSubRequest[] {
if (!relayUrls.length || !rawTopics.length || !kindsList.length) return []
const topics = Array.from(

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

@ -35,9 +35,9 @@ import indexedDb from '@/services/indexed-db.service' @@ -35,9 +35,9 @@ import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service'
import {
ExtendedKind,
DEFAULT_FEED_SHOW_KINDS,
FAUX_SPELL_ORDER,
FIRST_RELAY_RESULT_GRACE_MS,
PROFILE_FEED_KINDS
} from '@/constants'
import { isUserInEventMentions } from '@/lib/event'
import { formatPubkey } from '@/lib/pubkey'
@ -81,7 +81,7 @@ import { @@ -81,7 +81,7 @@ import {
Wand2
} from 'lucide-react'
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 { useTranslation } from 'react-i18next'
import CreateSpellDialog from './CreateSpellDialog'
@ -703,7 +703,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -703,7 +703,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (!pubkey || !interestListEvent) return []
const topics = interestListEvent.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!)
const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays)
return buildInterestsSubRequests(urls, topics, PROFILE_FEED_KINDS)
return buildInterestsSubRequests(urls, topics, DEFAULT_FEED_SHOW_KINDS)
}
if (selectedFauxSpell === 'bookmarks') {
if (!pubkey) return []
@ -859,7 +859,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -859,7 +859,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return [ExtendedKind.DISCUSSION]
}
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') {
return [ExtendedKind.FOLLOW_PACK]
@ -871,10 +874,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -871,10 +874,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME]
}
if (selectedFauxSpell === 'interests') {
return PROFILE_FEED_KINDS
return [...DEFAULT_FEED_SHOW_KINDS]
}
if (selectedFauxSpell === 'bookmarks') {
return PROFILE_FEED_KINDS
return [...DEFAULT_FEED_SHOW_KINDS]
}
if (!selectedSpell) return [1]
const kinds = selectedSpell.tags

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

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

4
src/providers/KindFilterProvider.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { createContext, useContext, useState, useCallback, useMemo } from 'react'
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'
const KIND_1 = kinds.ShortTextNote
@ -42,7 +42,7 @@ export const useKindFilter = () => { @@ -42,7 +42,7 @@ export const useKindFilter = () => {
}
export function KindFilterProvider({ children }: { children: React.ReactNode }) {
const defaultShowKinds = PROFILE_FEED_KINDS
const defaultShowKinds = DEFAULT_FEED_SHOW_KINDS
const storedShowKinds = storage.getShowKinds()
const storedShowKind1OPs = storage.getShowKind1OPs()
const storedShowKind1Replies = storage.getShowKind1Replies()

2
src/routes.tsx

@ -46,6 +46,7 @@ const ROUTES = [ @@ -46,6 +46,7 @@ const ROUTES = [
{ path: '/notes/:id', element: SR(NotePageLazy) },
{ path: '/discussions/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: '/explore/notes/:id', element: SR(NotePageLazy) },
{ path: '/home/notes/:id', element: SR(NotePageLazy) },
@ -56,6 +57,7 @@ const ROUTES = [ @@ -56,6 +57,7 @@ const ROUTES = [
{ path: '/rss/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/feed/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: '/spells/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 { @@ -3,7 +3,7 @@ import {
ExtendedKind,
MEDIA_AUTO_LOAD_POLICY,
NOTIFICATION_LIST_STYLE,
PROFILE_FEED_KINDS,
DEFAULT_FEED_SHOW_KINDS,
StorageKey
} from '@/constants'
import { kinds } from 'nostr-tools'
@ -223,7 +223,7 @@ class LocalStorageService { @@ -223,7 +223,7 @@ class LocalStorageService {
const showKindsStr = window.localStorage.getItem(StorageKey.SHOW_KINDS)
if (!showKindsStr) {
this.showKinds = [...PROFILE_FEED_KINDS]
this.showKinds = [...DEFAULT_FEED_SHOW_KINDS]
} else {
const showKindsVersionStr = window.localStorage.getItem(StorageKey.SHOW_KINDS_VERSION)
const showKindsVersion = showKindsVersionStr ? parseInt(showKindsVersionStr) : 0
@ -291,10 +291,11 @@ class LocalStorageService { @@ -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.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)
const showKind1OPsStr = window.localStorage.getItem(StorageKey.SHOW_KIND_1_OPs)

Loading…
Cancel
Save