diff --git a/src/PageManager.tsx b/src/PageManager.tsx index b6e79081..9b2e507e 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -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 = { profile: createRef(), relay: createRef(), search: createRef(), + 'follows-latest': createRef(), rss: createRef(), settings: createRef(), spells: createRef() @@ -137,6 +139,11 @@ const getPrimaryPageMap = () => ({ ), + 'follows-latest': ( + + + + ), rss: ( @@ -208,8 +215,16 @@ 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 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 { /* 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 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() { 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() { 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 }) { 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 }) { // 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 }) { // 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 }) { 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 }) { /* 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 }) { } // 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 }) { // 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] diff --git a/src/components/FavoriteRelaysActiveStrip/index.tsx b/src/components/FavoriteRelaysActiveStrip/index.tsx index 2937bbc3..c5390c38 100644 --- a/src/components/FavoriteRelaysActiveStrip/index.tsx +++ b/src/components/FavoriteRelaysActiveStrip/index.tsx @@ -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} /> @@ -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')} > @@ -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')} > @@ -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} /> diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index 792c0751..4e8bced9 100644 --- a/src/components/KindFilter/index.tsx +++ b/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 { 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 = [ { 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({ 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) diff --git a/src/components/LatestFromFollowsSection/index.tsx b/src/components/LatestFromFollowsSection/index.tsx index 38a109a4..d1a832ec 100644 --- a/src/components/LatestFromFollowsSection/index.tsx +++ b/src/components/LatestFromFollowsSection/index.tsx @@ -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 { } } -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 const [postsByPubkey, setPostsByPubkey] = useState>(() => 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 loadingFollowList, isInitialized, acceptEvent, - followsFeedScopeKey + followsFeedScopeKey, + refreshKey ]) const sortedRowPubkeys = useMemo(() => { @@ -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 if (loadingFollowList) { return ( -
+
@@ -357,7 +361,12 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa if (followPubkeys.length === 0) { return ( -
+
{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 } return ( - - - - {heading} - {batchBusy && postsByPubkey.size === 0 ? ( - - ) : null} - - - - -
- {batchBusy && postsByPubkey.size === 0 ? ( -
- - {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- ) : null} - {sortedRowPubkeys.map((pk) => { - const posts = postsByPubkey.get(pk) ?? [] - const count = posts.length - const latest = posts[0]?.created_at - return ( - push(toProfile(pk))} - /> - ) - })} +
+ {batchBusy && postsByPubkey.size === 0 ? ( +
+ + {Array.from({ length: 4 }).map((_, i) => ( + + ))}
- {batchBusy && postsByPubkey.size > 0 ? ( -
- -
- ) : null} - - + ) : null} + {sortedRowPubkeys.map((pk) => { + const posts = postsByPubkey.get(pk) ?? [] + const count = posts.length + const latest = posts[0]?.created_at + return ( + push(toProfile(pk))} + /> + ) + })} + {batchBusy && postsByPubkey.size > 0 ? ( +
+ +
+ ) : null} +
) } diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index 26f79322..2bd602fe 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -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() diff --git a/src/components/Profile/ProfileFeedWithPins.tsx b/src/components/Profile/ProfileFeedWithPins.tsx index e1dc54a9..f88eb79a 100644 --- a/src/components/Profile/ProfileFeedWithPins.tsx +++ b/src/components/Profile/ProfileFeedWithPins.tsx @@ -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 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 if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false return true }, - [showKinds, showKind1OPs, showKind1Replies, showKind1111, hideReplies] + [profileTimelineShowKinds, showKind1OPs, showKind1Replies, showKind1111, hideReplies] ) const restTimeline = useMemo( diff --git a/src/components/Sidebar/FollowsLatestButton.tsx b/src/components/Sidebar/FollowsLatestButton.tsx new file mode 100644 index 00000000..94700581 --- /dev/null +++ b/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 ( + navigate('follows-latest')} + active={current === 'follows-latest' && display && primaryViewType === null} + > + + + ) +} diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 135cdfbc..2e74d813 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -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() { + diff --git a/src/constants.ts b/src/constants.ts index 413b4723..bedf97d1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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', diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 8752074d..7772494f 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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 { '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 { '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', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c8addc4b..a8886b11 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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 { '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 { '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', diff --git a/src/pages/primary/FollowsLatestPage/index.tsx b/src/pages/primary/FollowsLatestPage/index.tsx new file mode 100644 index 00000000..96b21dcd --- /dev/null +++ b/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(function FollowsLatestPage(_, ref) { + const { t } = useTranslation() + const [refreshKey, setRefreshKey] = useState(0) + const layoutRef = useRef(null) + + const bumpRefresh = useCallback(() => { + setRefreshKey((k) => k + 1) + }, []) + + useImperativeHandle( + ref, + () => ({ + scrollToTop: (behavior: ScrollBehavior = 'smooth') => layoutRef.current?.scrollToTop(behavior), + refresh: bumpRefresh + }), + [bumpRefresh] + ) + + return ( + +
+
+
+

{t('Follows latest page title')}

+

+ {t('Follows latest page description')} +

+
+
+ +
+
+ +
+
+ ) +}) + +FollowsLatestPage.displayName = 'FollowsLatestPage' +export default FollowsLatestPage diff --git a/src/pages/primary/SearchPage/index.tsx b/src/pages/primary/SearchPage/index.tsx index 48623a85..d2a8fddb 100644 --- a/src/pages/primary/SearchPage/index.tsx +++ b/src/pages/primary/SearchPage/index.tsx @@ -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' import { Button } from '@/components/ui/button' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' -type SearchPageProps = { expandFollows?: boolean } -const SearchPage = forwardRef((props: SearchPageProps, ref) => { - const { expandFollows } = props ?? {} +const SearchPage = forwardRef((_props, ref) => { const { current, display } = usePrimaryPage() const { pubkey, relayList } = useNostr() const [input, setInput] = useState('') @@ -88,13 +85,7 @@ const SearchPage = forwardRef((props: SearchPageProps, ref) => {
- {searchParams ? ( - - ) : pubkey ? ( -
- -
- ) : null} +
diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index 231781cc..3f8a3e60 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/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 * 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 { 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( diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 8ae3df1d..bc6877cc 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/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 { 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 { 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(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(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(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 diff --git a/src/pages/secondary/SearchPage/index.tsx b/src/pages/secondary/SearchPage/index.tsx index 35e0aed5..145bcfb5 100644 --- a/src/pages/secondary/SearchPage/index.tsx +++ b/src/pages/secondary/SearchPage/index.tsx @@ -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' 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
- {searchParams ? ( - - ) : ( -
- - -
- )} +
diff --git a/src/providers/KindFilterProvider.tsx b/src/providers/KindFilterProvider.tsx index 9c1f1848..776e0ba2 100644 --- a/src/providers/KindFilterProvider.tsx +++ b/src/providers/KindFilterProvider.tsx @@ -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 = () => { } 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() diff --git a/src/routes.tsx b/src/routes.tsx index 059e408c..db04c922 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -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 = [ { 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) }, diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 0838f935..18dd53a7 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -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 { 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 { } } } + // 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)