Browse Source

more refactoring

imwald
Silberengel 1 month ago
parent
commit
463d262116
  1. 222
      src/PageManager.tsx
  2. 5
      src/components/BottomNavigationBar/DiscussionsButton.tsx
  3. 16
      src/components/BottomNavigationBar/ExploreButton.tsx
  4. 23
      src/components/BottomNavigationBar/FeedButton.tsx
  5. 5
      src/components/BottomNavigationBar/NotificationsButton.tsx
  6. 2
      src/components/BottomNavigationBar/index.tsx
  7. 2
      src/components/FeedSwitcher/index.tsx
  8. 211
      src/components/NormalFeed/index.tsx
  9. 17
      src/components/NoteCard/MainNoteCard.tsx
  10. 6
      src/components/NoteCard/RepostNoteCard.tsx
  11. 16
      src/components/NoteCard/index.tsx
  12. 57
      src/components/Profile/ProfileFeedWithPins.tsx
  13. 7
      src/components/Sidebar/DiscussionsButton.tsx
  14. 12
      src/components/Sidebar/FeedButton.tsx
  15. 43
      src/components/Sidebar/HomeButton.tsx
  16. 13
      src/components/Sidebar/NotificationButton.tsx
  17. 38
      src/components/Sidebar/RssButton.tsx
  18. 6
      src/components/Sidebar/SidebarItem.tsx
  19. 10
      src/components/Sidebar/index.tsx
  20. 18
      src/components/Titlebar/ExploreButton.tsx
  21. 1
      src/constants.ts
  22. 10
      src/i18n/locales/de.ts
  23. 13
      src/i18n/locales/en.ts
  24. 48
      src/pages/primary/ExplorePage/index.tsx
  25. 47
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  26. 8
      src/pages/primary/NoteListPage/index.tsx
  27. 78
      src/pages/primary/RssPage/index.tsx
  28. 167
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  29. 610
      src/pages/primary/SpellsPage/index.tsx
  30. 12
      src/pages/secondary/HomePage/index.tsx
  31. 2
      src/pages/secondary/RssFeedSettingsPage/index.tsx
  32. 13
      src/providers/FeedProvider.tsx
  33. 5
      src/routes.tsx

222
src/PageManager.tsx

@ -34,13 +34,15 @@ import {
cloneElement, cloneElement,
createContext, createContext,
createRef, createRef,
isValidElement,
lazy, lazy,
ReactNode, type ReactNode,
RefObject, RefObject,
Suspense, Suspense,
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState useState
} from 'react' } from 'react'
@ -53,6 +55,7 @@ import MePage from './pages/primary/MePage'
import ProfilePage from './pages/primary/ProfilePage' import ProfilePage from './pages/primary/ProfilePage'
import RelayPage from './pages/primary/RelayPage' import RelayPage from './pages/primary/RelayPage'
import SearchPage from './pages/primary/SearchPage' import SearchPage from './pages/primary/SearchPage'
import RssPage from './pages/primary/RssPage'
import { useScreenSize } from './providers/ScreenSizeProvider' import { useScreenSize } from './providers/ScreenSizeProvider'
/** Lazy-loaded so PageManager does not synchronously import SpellsPage (avoids HMR cycle: SpellsPage → PrimaryPageLayout → PageManager → SpellsPage). */ /** Lazy-loaded so PageManager does not synchronously import SpellsPage (avoids HMR cycle: SpellsPage → PrimaryPageLayout → PageManager → SpellsPage). */
@ -66,6 +69,8 @@ import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHel
type TPrimaryPageContext = { type TPrimaryPageContext = {
navigate: (page: TPrimaryPageName, props?: object) => void navigate: (page: TPrimaryPageName, props?: object) => void
current: TPrimaryPageName | null current: TPrimaryPageName | null
/** Props passed to the current primary page (e.g. `{ spell: 'discussions' }` for spells). */
currentPageProps: object | undefined
display: boolean display: boolean
} }
@ -85,23 +90,25 @@ type TStackItem = {
const PRIMARY_PAGE_REF_MAP = { const PRIMARY_PAGE_REF_MAP = {
home: createRef<TPageRef>(), home: createRef<TPageRef>(),
explore: createRef<TPageRef>(), feed: createRef<TPageRef>(),
me: createRef<TPageRef>(), me: createRef<TPageRef>(),
profile: createRef<TPageRef>(), profile: createRef<TPageRef>(),
relay: createRef<TPageRef>(), relay: createRef<TPageRef>(),
search: createRef<TPageRef>(), search: createRef<TPageRef>(),
rss: createRef<TPageRef>(),
spells: createRef<TPageRef>() spells: createRef<TPageRef>()
} }
// Lazy function to create PRIMARY_PAGE_MAP to avoid circular dependency // Lazy function to create PRIMARY_PAGE_MAP to avoid circular dependency
// This is only evaluated when called, not at module load time // This is only evaluated when called, not at module load time
const getPrimaryPageMap = () => ({ const getPrimaryPageMap = () => ({
home: <NoteListPage ref={PRIMARY_PAGE_REF_MAP.home} />, home: <ExplorePage ref={PRIMARY_PAGE_REF_MAP.home} />,
explore: <ExplorePage ref={PRIMARY_PAGE_REF_MAP.explore} />, feed: <NoteListPage ref={PRIMARY_PAGE_REF_MAP.feed} />,
me: <MePage ref={PRIMARY_PAGE_REF_MAP.me} />, me: <MePage ref={PRIMARY_PAGE_REF_MAP.me} />,
profile: <ProfilePage ref={PRIMARY_PAGE_REF_MAP.profile} />, profile: <ProfilePage ref={PRIMARY_PAGE_REF_MAP.profile} />,
relay: <RelayPage ref={PRIMARY_PAGE_REF_MAP.relay} />, relay: <RelayPage ref={PRIMARY_PAGE_REF_MAP.relay} />,
search: <SearchPage ref={PRIMARY_PAGE_REF_MAP.search} />, search: <SearchPage ref={PRIMARY_PAGE_REF_MAP.search} />,
rss: <RssPage ref={PRIMARY_PAGE_REF_MAP.rss} />,
spells: ( spells: (
<Suspense <Suspense
fallback={ fallback={
@ -115,16 +122,30 @@ const getPrimaryPageMap = () => ({
) )
}) })
/** Spells is wrapped in `<Suspense>`; navigated props must go to the lazy page, not the boundary. */
function applyPrimaryPageProps(element: ReactNode, props: object): ReactNode {
if (!isValidElement(element)) return element
if (element.type === Suspense) {
const inner = element.props.children
if (isValidElement(inner)) {
return cloneElement(element, undefined, cloneElement(inner, props))
}
}
return cloneElement(element, props)
}
// Type for primary page names - use the return type of getPrimaryPageMap // Type for primary page names - use the return type of getPrimaryPageMap
export type TPrimaryPageName = keyof ReturnType<typeof getPrimaryPageMap> export type TPrimaryPageName = keyof ReturnType<typeof getPrimaryPageMap>
type TPrimaryPageStateEntry = { name: TPrimaryPageName; element: ReactNode; props?: any } type TPrimaryPageStateEntry = { name: TPrimaryPageName; element: ReactNode; props?: any }
/** /discussions and contextual /discussions/notes/* map to spells + faux discussions. */
function noteContextToPrimaryEntry(pageContext: string): { name: TPrimaryPageName; props?: object } | null { function noteContextToPrimaryEntry(pageContext: string): { name: TPrimaryPageName; props?: object } | null {
if (pageContext === 'discussions') { if (pageContext === 'discussions') {
return { name: 'spells', props: { spell: 'discussions' } } return { name: 'spells', props: { spell: 'discussions' } }
} }
if (pageContext === 'explore') {
return { name: 'home' }
}
const map = getPrimaryPageMap() const map = getPrimaryPageMap()
if (pageContext in map) { if (pageContext in map) {
return { name: pageContext as TPrimaryPageName } return { name: pageContext as TPrimaryPageName }
@ -202,9 +223,9 @@ export function useNoteDrawer() {
// 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', 'explore', 'spells'] const contextualPages: TPrimaryPageName[] = ['search', 'profile', 'feed', 'spells', 'rss', 'home']
if (currentPage && contextualPages.includes(currentPage) && currentPage !== 'home') { if (currentPage && contextualPages.includes(currentPage)) {
return `/${currentPage}/notes/${noteId}` return `/${currentPage}/notes/${noteId}`
} }
@ -215,9 +236,8 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str
function buildRelayUrl(relayUrl: string, currentPage: TPrimaryPageName | null): string { function buildRelayUrl(relayUrl: string, currentPage: TPrimaryPageName | null): string {
const encodedRelayUrl = encodeURIComponent(relayUrl) const encodedRelayUrl = encodeURIComponent(relayUrl)
// Only preserve context for explore page (where you discover relays) if (currentPage === 'home') {
if (currentPage === 'explore') { return `/home/relays/${encodedRelayUrl}`
return `/explore/relays/${encodedRelayUrl}`
} }
return `/relays/${encodedRelayUrl}` return `/relays/${encodedRelayUrl}`
@ -226,7 +246,9 @@ function buildRelayUrl(relayUrl: string, currentPage: TPrimaryPageName | null):
// Helper function to extract noteId and context from URL // Helper function to extract noteId and context from URL
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(/\/(discussions|search|profile|explore|spells)\/notes\/(.+)$/) const contextualMatch = url.match(
/\/(discussions|search|profile|home|feed|spells|explore)\/notes\/(.+)$/
)
if (contextualMatch) { if (contextualMatch) {
return { noteId: contextualMatch[2], context: contextualMatch[1] } return { noteId: contextualMatch[2], context: contextualMatch[1] }
} }
@ -302,7 +324,8 @@ 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 = url.match(/\/(discussions|search|profile|explore|spells)\/relays\/(.+)$/) || const relayUrlMatch =
url.match(/\/(discussions|search|profile|home|feed|spells|explore)\/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\//, ''))
@ -596,7 +619,7 @@ function MainContentArea({
{(() => { {(() => {
try { try {
logger.debug(`Rendering ${name} component`) logger.debug(`Rendering ${name} component`)
return props ? cloneElement(element as React.ReactElement, props) : element return props ? applyPrimaryPageProps(element, props) : element
} catch (error) { } catch (error) {
logger.error(`Error rendering ${name} component:`, error) logger.error(`Error rendering ${name} component:`, error)
return <div>Error rendering {name}: {error instanceof Error ? error.message : String(error)}</div> return <div>Error rendering {name}: {error instanceof Error ? error.message : String(error)}</div>
@ -635,11 +658,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const navigationCounterRef = useRef(0) const navigationCounterRef = useRef(0)
const savedFeedStateRef = useRef<Map<TPrimaryPageName, { const savedFeedStateRef = useRef<Map<TPrimaryPageName, {
tab?: string, tab?: string,
discussionsState?: { selectedTopic: string, timeSpan: '30days' | '90days' | 'all' },
trendingTab?: 'relays' | 'hashtags' | 'calendar' trendingTab?: 'relays' | 'hashtags' | 'calendar'
}>>(new Map()) }>>(new Map())
const currentTabStateRef = useRef<Map<TPrimaryPageName, string>>(new Map()) // Track current tab state for each page const currentTabStateRef = useRef<Map<TPrimaryPageName, string>>(new Map()) // Track current tab state for each page
const currentPageProps = useMemo((): object | undefined => {
const entry = primaryPages.find((p) => p.name === currentPrimaryPage)
return entry?.props as object | undefined
}, [primaryPages, currentPrimaryPage])
const setPrimaryNoteView = (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings') => { const setPrimaryNoteView = (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings') => {
if (view && !primaryNoteView) { if (view && !primaryNoteView) {
// Saving current primary page before showing overlay // Saving current primary page before showing overlay
@ -648,39 +675,18 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Get current tab state from ref (updated by components via events) // Get current tab state from ref (updated by components via events)
const currentTab = currentTabStateRef.current.get(currentPrimaryPage) const currentTab = currentTabStateRef.current.get(currentPrimaryPage)
// Discussions list state when Spells page may host embedded Discussions
let discussionsState: { selectedTopic: string, timeSpan: '30days' | '90days' | 'all' } | undefined = undefined
if (currentPrimaryPage === 'spells') {
// Request discussions state from embedded Discussions (faux-spell) when mounted
const stateEvent = new CustomEvent('requestDiscussionsState')
let receivedState: { selectedTopic: string, timeSpan: '30days' | '90days' | 'all' } | null = null
const handler = ((e: CustomEvent) => {
receivedState = e.detail
}) as EventListener
window.addEventListener('discussionsStateResponse', handler)
window.dispatchEvent(stateEvent)
setTimeout(() => {
window.removeEventListener('discussionsStateResponse', handler)
if (receivedState) {
discussionsState = receivedState
}
}, 10)
}
// Get trending tab if on search page // Get trending tab if on search page
const trendingTab = currentTabStateRef.current.get('search') as 'relays' | 'hashtags' | 'calendar' | undefined const trendingTab = currentTabStateRef.current.get('search') as 'relays' | 'hashtags' | 'calendar' | undefined
// Save state (tab, discussions, trending) if any exists // Save state (tab, trending) if any exists
if (currentTab || discussionsState || trendingTab) { if (currentTab || trendingTab) {
logger.info('PageManager: Saving page state', { logger.info('PageManager: Saving page state', {
page: currentPrimaryPage, page: currentPrimaryPage,
tab: currentTab, tab: currentTab,
discussionsState,
trendingTab trendingTab
}) })
savedFeedStateRef.current.set(currentPrimaryPage, { savedFeedStateRef.current.set(currentPrimaryPage, {
tab: currentTab, tab: currentTab,
discussionsState,
trendingTab trendingTab
}) })
} }
@ -713,17 +719,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
currentTabStateRef.current.set(savedPrimaryPage, savedFeedState.tab) currentTabStateRef.current.set(savedPrimaryPage, savedFeedState.tab)
} }
// Restore Discussions state
if (savedFeedState?.discussionsState && savedPrimaryPage === 'spells') {
logger.info('PageManager: Restoring Discussions state', {
page: savedPrimaryPage,
discussionsState: savedFeedState.discussionsState
})
window.dispatchEvent(new CustomEvent('restoreDiscussionsState', {
detail: { page: savedPrimaryPage, discussionsState: savedFeedState.discussionsState }
}))
}
// Restore trending tab for search page (map legacy 'nostr' to 'relays') // Restore trending tab for search page (map legacy 'nostr' to 'relays')
if (savedFeedState?.trendingTab && savedPrimaryPage === 'search') { if (savedFeedState?.trendingTab && savedPrimaryPage === 'search') {
const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab
@ -812,7 +807,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|explore|spells)\/notes\/(.+)$/) const contextualNoteMatch = pathname.match(/\/(discussions|search|profile|home|feed|spells|explore)\/notes\/(.+)$/)
const standardNoteMatch = pathname.match(/\/notes\/(.+)$/) const standardNoteMatch = pathname.match(/\/notes\/(.+)$/)
const noteUrlMatch = contextualNoteMatch || standardNoteMatch const noteUrlMatch = contextualNoteMatch || standardNoteMatch
@ -881,22 +876,31 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Check if this is a primary page URL - don't push primary pages to secondary stack // Check if this is a primary page URL - don't push primary pages to secondary stack
const pathnameOnly = pathname.split('?')[0].split('#')[0] const pathnameOnly = pathname.split('?')[0].split('#')[0]
const firstSeg = pathnameOnly.slice(1).split('/')[0] const segments = pathnameOnly.split('/').filter(Boolean)
const firstSeg = segments[0] ?? ''
const primaryMap = getPrimaryPageMap()
const isPrimaryPageUrl = const isPrimaryPageUrl =
pathnameOnly === '/' || segments.length === 0 ||
pathnameOnly === '/home' || (segments.length === 1 &&
firstSeg === 'discussions' || (firstSeg === 'discussions' ||
(pathnameOnly.startsWith('/') && firstSeg === 'home' ||
firstSeg in getPrimaryPageMap() && firstSeg === 'explore' ||
!pathnameOnly.match(/^\/(notes|users|relays|settings|profile-editor|mutes|follow-packs)/)) firstSeg in primaryMap))
if (isPrimaryPageUrl) { if (isPrimaryPageUrl) {
// This is a primary page - just navigate to it, don't push to secondary stack // This is a primary page - just navigate to it, don't push to secondary stack
const pageName = const pageName =
pathnameOnly === '/' || pathnameOnly === '/home' ? 'home' : firstSeg segments.length === 0 || (segments.length === 1 && firstSeg === 'home') ? 'home' : firstSeg
if (pageName === 'discussions') { if (pageName === 'explore') {
navigatePrimaryPage('home')
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent('restorePageTab', { detail: { page: 'home', tab: 'explore' } })
)
})
} else if (pageName === 'discussions') {
navigatePrimaryPage('spells', { spell: 'discussions' }) navigatePrimaryPage('spells', { spell: 'discussions' })
} else if (pageName in getPrimaryPageMap()) { } else if (pageName in primaryMap) {
navigatePrimaryPage(pageName as TPrimaryPageName) navigatePrimaryPage(pageName as TPrimaryPageName)
} }
return return
@ -940,7 +944,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} else { } else {
// 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(/^\/(discussions|search|profile|explore|spells)\/notes\//) const contextualNoteMatch = pathname.match(
/^\/(discussions|search|profile|home|feed|spells|explore)\/notes\//
)
if (contextualNoteMatch) { if (contextualNoteMatch) {
const pageContext = contextualNoteMatch[1] const pageContext = contextualNoteMatch[1]
const resolved = noteContextToPrimaryEntry(pageContext) const resolved = noteContextToPrimaryEntry(pageContext)
@ -957,6 +963,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
navigatePrimaryPage('spells', { spell: 'discussions' }) navigatePrimaryPage('spells', { spell: 'discussions' })
return return
} }
if (pageName === 'explore') {
navigatePrimaryPage('home')
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent('restorePageTab', { detail: { page: 'home', tab: 'explore' } })
)
})
return
}
if (pageName && pageName in getPrimaryPageMap()) { if (pageName && pageName in getPrimaryPageMap()) {
// For relay page, check if there's a URL prop // For relay page, check if there's a URL prop
if (pageName === 'relay') { if (pageName === 'relay') {
@ -990,7 +1005,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|explore|spells)\/notes\/(.+)$/) || const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|home|feed|spells|explore)\/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
@ -1036,14 +1051,16 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (!topItem) { if (!topItem) {
// Stack is empty - check if this is a primary page URL or a secondary route // Stack is empty - check if this is a primary page URL or a secondary route
const pathname = state.url.split('?')[0].split('#')[0] const pathname = state.url.split('?')[0].split('#')[0]
const popFirstSeg = pathname.slice(1).split('/')[0] const popSegments = pathname.split('/').filter(Boolean)
const popFirstSeg = popSegments[0] ?? ''
const popPrimaryMap = getPrimaryPageMap()
const isPrimaryPage = const isPrimaryPage =
pathname === '/' || popSegments.length === 0 ||
pathname === '/home' || (popSegments.length === 1 &&
popFirstSeg === 'discussions' || (popFirstSeg === 'discussions' ||
(pathname.startsWith('/') && popFirstSeg === 'home' ||
popFirstSeg in getPrimaryPageMap() && popFirstSeg === 'explore' ||
!pathname.match(/^\/(notes|users|relays|settings|profile-editor|mutes|follow-packs)/)) popFirstSeg in popPrimaryMap))
// If it's a primary page URL, return empty stack (right panel will close) // If it's a primary page URL, return empty stack (right panel will close)
if (isPrimaryPage) { if (isPrimaryPage) {
@ -1053,7 +1070,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
setTimeout(() => { setTimeout(() => {
setDrawerNoteId(null) setDrawerNoteId(null)
// Ensure URL matches the primary page // Ensure URL matches the primary page
const pageUrl = pathname === '/' || pathname === '/home' ? '/' : pathname const pageUrl =
popSegments.length === 0 || (popSegments.length === 1 && popFirstSeg === 'home')
? '/'
: `/${popFirstSeg}`
window.history.replaceState(null, '', pageUrl) window.history.replaceState(null, '', pageUrl)
}, 350) }, 350)
} }
@ -1061,7 +1081,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|explore|spells)\/notes\/(.+)$/) || const noteUrlMatch = state.url.match(/\/(discussions|search|profile|home|feed|spells|explore)\/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]
@ -1112,7 +1132,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|explore|spells)\/notes\/(.+)$/) || const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|home|feed|spells|explore)\/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]
@ -1182,17 +1202,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
} }
// Restore Discussions state
if (savedFeedState?.discussionsState && currentPrimaryPage === 'spells') {
logger.info('PageManager: Browser back - Restoring Discussions state', {
page: currentPrimaryPage,
discussionsState: savedFeedState.discussionsState
})
window.dispatchEvent(new CustomEvent('restoreDiscussionsState', {
detail: { page: currentPrimaryPage, discussionsState: savedFeedState.discussionsState }
}))
}
// Restore trending tab for search page // Restore trending tab for search page
if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') { if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') {
const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab
@ -1221,13 +1230,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Update primary pages and current page // Update primary pages and current page
setPrimaryPages((prev) => { setPrimaryPages((prev) => {
const exists = prev.find((p) => p.name === page) const exists = prev.find((p) => p.name === page)
if (exists && props) { if (exists) {
exists.props = props exists.props = props
return [...prev] return [...prev]
} else if (!exists) {
return [...prev, { name: page, element: getPrimaryPageMap()[page], props }]
} }
return prev return [...prev, { name: page, element: getPrimaryPageMap()[page], props }]
}) })
setCurrentPrimaryPage(page) setCurrentPrimaryPage(page)
@ -1260,7 +1267,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
logger.component('PageManager', 'Current secondary stack length', { length: prevStack.length }) logger.component('PageManager', 'Current secondary stack length', { length: prevStack.length })
// For relay pages, clear the stack and start fresh to avoid confusion // For relay pages, clear the stack and start fresh to avoid confusion
if (url.startsWith('/relays/')) { if (
url.startsWith('/relays/') ||
url.startsWith('/home/relays/') ||
url.startsWith('/explore/relays/')
) {
logger.component('PageManager', 'Clearing stack for relay navigation') logger.component('PageManager', 'Clearing stack for relay navigation')
const { newStack, newItem } = pushNewPageToStack([], url, maxStackSize, 0) const { newStack, newItem } = pushNewPageToStack([], url, maxStackSize, 0)
logger.component('PageManager', 'New stack created', { logger.component('PageManager', 'New stack created', {
@ -1316,17 +1327,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
} }
// Restore Discussions state
if (savedFeedState?.discussionsState && currentPrimaryPage === 'spells') {
logger.info('PageManager: Desktop - Restoring Discussions state', {
page: currentPrimaryPage,
discussionsState: savedFeedState.discussionsState
})
window.dispatchEvent(new CustomEvent('restoreDiscussionsState', {
detail: { page: currentPrimaryPage, discussionsState: savedFeedState.discussionsState }
}))
}
// Restore trending tab for search page // Restore trending tab for search page
if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') { if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') {
const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab
@ -1385,17 +1385,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
} }
// Restore Discussions state
if (savedFeedState?.discussionsState && currentPrimaryPage === 'spells') {
logger.info('PageManager: Mobile/Single-pane - Restoring Discussions state', {
page: currentPrimaryPage,
discussionsState: savedFeedState.discussionsState
})
window.dispatchEvent(new CustomEvent('restoreDiscussionsState', {
detail: { page: currentPrimaryPage, discussionsState: savedFeedState.discussionsState }
}))
}
// Restore trending tab for search page // Restore trending tab for search page
if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') { if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') {
const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab
@ -1427,17 +1416,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
} }
// Restore Discussions state
if (savedFeedState?.discussionsState && currentPrimaryPage === 'spells') {
logger.info('PageManager: Desktop - Restoring Discussions state', {
page: currentPrimaryPage,
discussionsState: savedFeedState.discussionsState
})
window.dispatchEvent(new CustomEvent('restoreDiscussionsState', {
detail: { page: currentPrimaryPage, discussionsState: savedFeedState.discussionsState }
}))
}
// Restore trending tab for search page // Restore trending tab for search page
if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') { if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') {
const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab
@ -1481,6 +1459,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
value={{ value={{
navigate: navigatePrimaryPage, navigate: navigatePrimaryPage,
current: currentPrimaryPage, current: currentPrimaryPage,
currentPageProps,
display: secondaryStack.length === 0 display: secondaryStack.length === 0
}} }}
> >
@ -1562,7 +1541,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none' secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none'
}} }}
> >
{props ? cloneElement(element as React.ReactElement, props) : element} {props ? applyPrimaryPageProps(element, props) : element}
</div> </div>
))} ))}
</> </>
@ -1602,6 +1581,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
value={{ value={{
navigate: navigatePrimaryPage, navigate: navigatePrimaryPage,
current: currentPrimaryPage, current: currentPrimaryPage,
currentPageProps,
display: true display: true
}} }}
> >

5
src/components/BottomNavigationBar/DiscussionsButton.tsx

@ -3,11 +3,12 @@ import { MessageCircle } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem' import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function DiscussionsButton() { export default function DiscussionsButton() {
const { navigate, current, display } = usePrimaryPage() const { navigate, current, currentPageProps, display } = usePrimaryPage()
const spell = (currentPageProps as { spell?: string } | undefined)?.spell
return ( return (
<BottomNavigationBarItem <BottomNavigationBarItem
active={current === 'spells' && display} active={current === 'spells' && display && spell === 'discussions'}
onClick={() => navigate('spells', { spell: 'discussions' })} onClick={() => navigate('spells', { spell: 'discussions' })}
> >
<MessageCircle /> <MessageCircle />

16
src/components/BottomNavigationBar/ExploreButton.tsx

@ -1,16 +0,0 @@
import { usePrimaryPage } from '@/PageManager'
import { Compass } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function ExploreButton() {
const { navigate, current, display } = usePrimaryPage()
return (
<BottomNavigationBarItem
active={current === 'explore' && display}
onClick={() => navigate('explore')}
>
<Compass />
</BottomNavigationBarItem>
)
}

23
src/components/BottomNavigationBar/FeedButton.tsx

@ -0,0 +1,23 @@
import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager'
import { Newspaper } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function FeedButton() {
const { navigate, current, display } = usePrimaryPage()
const { primaryViewType, setPrimaryNoteView } = usePrimaryNoteView()
return (
<BottomNavigationBarItem
active={current === 'feed' && display && primaryViewType === null}
onClick={() => {
if (primaryViewType !== null) {
setPrimaryNoteView(null)
} else {
navigate('feed')
}
}}
>
<Newspaper />
</BottomNavigationBarItem>
)
}

5
src/components/BottomNavigationBar/NotificationsButton.tsx

@ -4,12 +4,13 @@ import { Bell } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem' import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function NotificationsButton() { export default function NotificationsButton() {
const { navigate } = usePrimaryPage() const { navigate, current, currentPageProps, display } = usePrimaryPage()
const { checkLogin } = useNostr() const { checkLogin } = useNostr()
const spell = (currentPageProps as { spell?: string } | undefined)?.spell
return ( return (
<BottomNavigationBarItem <BottomNavigationBarItem
active={false} active={current === 'spells' && display && spell === 'notifications'}
onClick={() => checkLogin(() => navigate('spells', { spell: 'notifications' }))} onClick={() => checkLogin(() => navigate('spells', { spell: 'notifications' }))}
> >
<Bell /> <Bell />

2
src/components/BottomNavigationBar/index.tsx

@ -1,4 +1,5 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import FeedButton from './FeedButton'
import HomeButton from './HomeButton' import HomeButton from './HomeButton'
import DiscussionsButton from './DiscussionsButton' import DiscussionsButton from './DiscussionsButton'
import NotificationsButton from './NotificationsButton' import NotificationsButton from './NotificationsButton'
@ -19,6 +20,7 @@ export default function BottomNavigationBar() {
> >
<WriteButton /> <WriteButton />
<DiscussionsButton /> <DiscussionsButton />
<FeedButton />
<HomeButton /> <HomeButton />
<SpellsButton /> <SpellsButton />
<SearchButton /> <SearchButton />

2
src/components/FeedSwitcher/index.tsx

@ -19,7 +19,7 @@ export default function FeedSwitcher({ close }: { close?: () => void }) {
// Filter out blocked relays for display // Filter out blocked relays for display
const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay)) const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay))
// Feed rows that exist here follow FAUX_SPELL_ORDER where applicable: favorite-relays → following → bookmarks. // Feed rows: aggregate favorites → following → bookmarks (see FAUX_SPELL_ORDER for spell picker order).
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{visibleRelays.length > 0 && ( {visibleRelays.length > 0 && (

211
src/components/NormalFeed/index.tsx

@ -5,16 +5,9 @@ import { useKindFilter } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { TFeedSubRequest, TNoteListMode } from '@/types' import { TFeedSubRequest, TNoteListMode } from '@/types'
import { forwardRef, useLayoutEffect, useMemo, useRef, useState, useEffect } from 'react' import { forwardRef, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import KindFilter from '../KindFilter' import KindFilter from '../KindFilter'
import { RefreshButton } from '../RefreshButton' import { RefreshButton } from '../RefreshButton'
import RssFeedList from '../RssFeedList'
import { useNostr } from '@/providers/NostrProvider'
import rssFeedService from '@/services/rss-feed.service'
import { DEFAULT_RSS_FEEDS } from '@/constants'
import { Rss, Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
const NormalFeed = forwardRef<TNoteListRef, { const NormalFeed = forwardRef<TNoteListRef, {
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
@ -23,142 +16,47 @@ const NormalFeed = forwardRef<TNoteListRef, {
showRelayCloseReason?: boolean showRelayCloseReason?: boolean
/** When set (e.g. on Home), tabs are rendered in layout subHeader instead of in-feed; avoids overlap */ /** When set (e.g. on Home), tabs are rendered in layout subHeader instead of in-feed; avoids overlap */
setSubHeader?: (node: React.ReactNode) => void setSubHeader?: (node: React.ReactNode) => void
}>(function NormalFeed({ }>(function NormalFeed(
{
subRequests, subRequests,
areAlgoRelays = false, areAlgoRelays = false,
isMainFeed = false, isMainFeed = false,
showRelayCloseReason = false, showRelayCloseReason = false,
setSubHeader setSubHeader
}, ref) { },
ref
) {
logger.debug('NormalFeed component rendering with:', { subRequests, areAlgoRelays, isMainFeed }) logger.debug('NormalFeed component rendering with:', { subRequests, areAlgoRelays, isMainFeed })
const { t } = useTranslation()
const { hideUntrustedNotes } = useUserTrust() const { hideUntrustedNotes } = useUserTrust()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilter() const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilter()
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds) const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
const [listMode, setListMode] = useState<TNoteListMode>(() => { const [listMode, setListMode] = useState<TNoteListMode>(() => {
// Get stored mode preference
const storedMode = storage.getNoteListMode() const storedMode = storage.getNoteListMode()
// For main feed, only allow 'posts' or 'postsAndReplies' as valid values
// Default to 'posts' if no valid preference is stored
if (isMainFeed) { if (isMainFeed) {
if (storedMode === 'posts' || storedMode === 'postsAndReplies') { if (storedMode === 'posts' || storedMode === 'postsAndReplies') {
return storedMode return storedMode
} }
return 'posts' return 'posts'
} }
// For non-main feeds, use stored mode or default to 'posts'
return storedMode || 'posts' return storedMode || 'posts'
}) })
const internalNoteListRef = useRef<TNoteListRef>(null) const internalNoteListRef = useRef<TNoteListRef>(null)
const noteListRef = ref || internalNoteListRef const noteListRef = ref || internalNoteListRef
const [showRssFeed, setShowRssFeed] = useState(() => storage.getShowRssFeed())
const [activeTab, setActiveTab] = useState<string>(listMode)
const [rssRefreshKey, setRssRefreshKey] = useState(0)
const { pubkey, rssFeedListEvent } = useNostr()
// Sync activeTab with listMode when listMode changes (but not when switching to RSS) const tabs = useMemo(
useEffect(() => { (): TabDefinition[] => [
if (activeTab !== 'rss' && activeTab !== listMode) { { value: 'posts', label: 'Notes' },
setActiveTab(listMode) { value: 'postsAndReplies', label: 'Replies' }
} ],
}, [listMode, activeTab]) []
)
// Check showRssFeed setting on mount and listen for changes
useEffect(() => {
const checkShowRssFeed = () => {
const currentShowRssFeed = storage.getShowRssFeed()
setShowRssFeed(currentShowRssFeed)
}
// Check on mount
checkShowRssFeed()
// Listen for storage changes (polling approach - check every second)
const intervalId = setInterval(checkShowRssFeed, 1000)
// Also listen for custom event if RSS setting changes
const handleRssSettingChange = () => {
checkShowRssFeed()
}
window.addEventListener('rssFeedSettingChanged', handleRssSettingChange)
return () => {
clearInterval(intervalId)
window.removeEventListener('rssFeedSettingChanged', handleRssSettingChange)
}
}, [])
// Handle RSS tab visibility when showRssFeed changes
useEffect(() => {
// If RSS tab is hidden while it's active, switch to posts
if (!showRssFeed && activeTab === 'rss') {
setActiveTab('posts')
setListMode('posts')
}
}, [showRssFeed, activeTab])
// Listen for custom event to switch to RSS tab
useEffect(() => {
const handleSwitchToRss = () => {
if (showRssFeed) {
setActiveTab('rss')
// Dispatch event to notify sidebar that RSS tab is active
window.dispatchEvent(new CustomEvent('rssTabStateChanged', { detail: { active: true } }))
if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.scrollToTop('smooth')
}
}
}
window.addEventListener('switchToRssFeed', handleSwitchToRss)
return () => {
window.removeEventListener('switchToRssFeed', handleSwitchToRss)
}
}, [showRssFeed, noteListRef])
// Listen for custom event to switch to Notes tab
useEffect(() => {
const handleSwitchToNotes = () => {
// Switch to posts (Notes) tab
setListMode('posts')
setActiveTab('posts')
// Dispatch event to notify sidebar that RSS tab is not active
window.dispatchEvent(new CustomEvent('rssTabStateChanged', { detail: { active: false } }))
if (isMainFeed) {
storage.setNoteListMode('posts')
}
if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.scrollToTop('smooth')
}
}
window.addEventListener('switchToNotesTab', handleSwitchToNotes)
return () => {
window.removeEventListener('switchToNotesTab', handleSwitchToNotes)
}
}, [isMainFeed, noteListRef])
// Dispatch initial RSS tab state on mount and when activeTab changes
useEffect(() => {
window.dispatchEvent(new CustomEvent('rssTabStateChanged', {
detail: { active: activeTab === 'rss' }
}))
}, [activeTab])
const handleListModeChange = (mode: TNoteListMode | string) => { const handleListModeChange = (mode: TNoteListMode | string) => {
if (mode === 'rss') {
setActiveTab('rss')
// Dispatch event to notify sidebar that RSS tab is active
window.dispatchEvent(new CustomEvent('rssTabStateChanged', { detail: { active: true } }))
return
}
const noteListMode = mode as TNoteListMode const noteListMode = mode as TNoteListMode
setListMode(noteListMode) setListMode(noteListMode)
setActiveTab(noteListMode)
// Dispatch event to notify sidebar that RSS tab is not active
window.dispatchEvent(new CustomEvent('rssTabStateChanged', { detail: { active: false } }))
if (isMainFeed) { if (isMainFeed) {
storage.setNoteListMode(noteListMode) storage.setNoteListMode(noteListMode)
window.dispatchEvent(new CustomEvent('noteListModeChanged'))
} }
if (noteListRef && typeof noteListRef !== 'function') { if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.scrollToTop('smooth') noteListRef.current?.scrollToTop('smooth')
@ -172,104 +70,38 @@ const NormalFeed = forwardRef<TNoteListRef, {
} }
} }
// Build tabs array conditionally
const tabs = useMemo((): TabDefinition[] => {
const baseTabs: TabDefinition[] = [
{ value: 'posts', label: 'Notes' },
{ value: 'postsAndReplies', label: 'Replies' }
]
if (showRssFeed) {
baseTabs.push({ value: 'rss', label: 'RSS', icon: <Rss className="size-4" /> })
}
return baseTabs
}, [showRssFeed])
// Determine current tab value
const currentTabValue = activeTab
const tabsElement = ( const tabsElement = (
<Tabs <Tabs
value={currentTabValue} value={listMode}
tabs={tabs} tabs={tabs}
onTabChange={(tab) => { onTabChange={(tab) => handleListModeChange(tab)}
handleListModeChange(tab)
}}
options={ options={
<> <>
{activeTab === 'rss' && showRssFeed && ( <RefreshButton
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => { onClick={() => {
window.dispatchEvent(new CustomEvent('toggleRssFilters'))
}}
title={t('Toggle filters')}
>
<Search className="h-4 w-4" />
</Button>
)}
<RefreshButton onClick={() => {
if (activeTab === 'rss') {
let feedUrls: string[] = []
if (pubkey && rssFeedListEvent) {
try {
const urls = rssFeedListEvent.tags
.filter(tag => tag[0] === 'u' && tag[1])
.map(tag => tag[1] as string)
.filter((url): url is string => {
if (typeof url !== 'string') return false
const trimmed = url.trim()
return trimmed.length > 0
})
feedUrls = urls
} catch (e) {
feedUrls = []
}
} else {
feedUrls = DEFAULT_RSS_FEEDS
}
logger.info('[NormalFeed] Manual refresh: triggering RSS background refresh', { feedCount: feedUrls.length })
rssFeedService.backgroundRefreshFeeds(feedUrls).catch(err => {
logger.error('[NormalFeed] Manual refresh: background refresh failed', { error: err })
})
if (pubkey) {
window.dispatchEvent(new CustomEvent('rssFeedListUpdated', {
detail: { pubkey, feedUrls, eventId: 'manual-refresh' }
}))
}
setRssRefreshKey(prev => prev + 1)
} else {
if (noteListRef && typeof noteListRef !== 'function') { if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.refresh() noteListRef.current?.refresh()
} }
} }}
}} /> />
{activeTab !== 'rss' && (
<KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} /> <KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} />
)}
</> </>
} }
/> />
) )
// When used on Home, render tabs in layout subHeader so they don't overlap content
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isMainFeed || !setSubHeader) return if (!isMainFeed || !setSubHeader) return
setSubHeader(tabsElement) setSubHeader(tabsElement)
return () => setSubHeader(null) return () => setSubHeader(null)
}, [isMainFeed, setSubHeader, currentTabValue, activeTab, showRssFeed, temporaryShowKinds]) }, [isMainFeed, setSubHeader, listMode, temporaryShowKinds])
const renderTabsInFeed = !(isMainFeed && setSubHeader) const renderTabsInFeed = !(isMainFeed && setSubHeader)
return ( return (
<> <>
{renderTabsInFeed && tabsElement} {renderTabsInFeed && tabsElement}
<div className="pt-2 min-w-0"> <div className="min-w-0 pt-2">
{activeTab === 'rss' ? (
<RssFeedList key={rssRefreshKey} />
) : (
<NoteList <NoteList
ref={noteListRef} ref={noteListRef}
showKinds={temporaryShowKinds} showKinds={temporaryShowKinds}
@ -282,7 +114,6 @@ const NormalFeed = forwardRef<TNoteListRef, {
areAlgoRelays={areAlgoRelays} areAlgoRelays={areAlgoRelays}
showRelayCloseReason={showRelayCloseReason} showRelayCloseReason={showRelayCloseReason}
/> />
)}
</div> </div>
</> </>
) )

17
src/components/NoteCard/MainNoteCard.tsx

@ -2,7 +2,9 @@ import { Separator } from '@/components/ui/separator'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { useSmartNoteNavigation } from '@/PageManager' import { useSmartNoteNavigation } from '@/PageManager'
import client from '@/services/client.service' import client from '@/services/client.service'
import { Pin } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import Collapsible from '../Collapsible' import Collapsible from '../Collapsible'
import Note from '../Note' import Note from '../Note'
import NoteStats from '../NoteStats' import NoteStats from '../NoteStats'
@ -13,14 +15,18 @@ export default function MainNoteCard({
className, className,
reposter, reposter,
embedded, embedded,
originalNoteId originalNoteId,
pinned = false
}: { }: {
event: Event event: Event
className?: string className?: string
reposter?: string reposter?: string
embedded?: boolean embedded?: boolean
originalNoteId?: string originalNoteId?: string
/** Profile (or other) pinned highlight */
pinned?: boolean
}) { }) {
const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
return ( return (
@ -48,6 +54,15 @@ export default function MainNoteCard({
}} }}
> >
<div className={`clickable ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'py-3'}`} style={embedded ? { position: 'relative', isolation: 'isolate', overflow: 'visible' } : undefined}> <div className={`clickable ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'py-3'}`} style={embedded ? { position: 'relative', isolation: 'isolate', overflow: 'visible' } : undefined}>
{pinned && !embedded && (
<div
className="flex items-center gap-1.5 px-4 pb-1 text-muted-foreground"
role="img"
aria-label={t('Pinned note')}
>
<Pin className="size-4 shrink-0" strokeWidth={1.5} aria-hidden />
</div>
)}
<Collapsible alwaysExpand={embedded}> <Collapsible alwaysExpand={embedded}>
<RepostDescription className={embedded ? '' : 'px-4'} reposter={reposter} /> <RepostDescription className={embedded ? '' : 'px-4'} reposter={reposter} />
<Note <Note

6
src/components/NoteCard/RepostNoteCard.tsx

@ -11,11 +11,13 @@ import MainNoteCard from './MainNoteCard'
export default function RepostNoteCard({ export default function RepostNoteCard({
event, event,
className, className,
filterMutedNotes = true filterMutedNotes = true,
pinned = false
}: { }: {
event: Event event: Event
className?: string className?: string
filterMutedNotes?: boolean filterMutedNotes?: boolean
pinned?: boolean
}) { }) {
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
@ -72,5 +74,5 @@ export default function RepostNoteCard({
if (!targetEvent || shouldHide) return null if (!targetEvent || shouldHide) return null
return <MainNoteCard className={className} reposter={event.pubkey} event={targetEvent} /> return <MainNoteCard className={className} reposter={event.pubkey} event={targetEvent} pinned={pinned} />
} }

16
src/components/NoteCard/index.tsx

@ -10,11 +10,13 @@ import RepostNoteCard from './RepostNoteCard'
const NoteCard = memo(function NoteCard({ const NoteCard = memo(function NoteCard({
event, event,
className, className,
filterMutedNotes = true filterMutedNotes = true,
pinned = false
}: { }: {
event: Event event: Event
className?: string className?: string
filterMutedNotes?: boolean filterMutedNotes?: boolean
pinned?: boolean
}) { }) {
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
@ -31,17 +33,23 @@ const NoteCard = memo(function NoteCard({
if (event.kind === kinds.Repost) { if (event.kind === kinds.Repost) {
return ( return (
<RepostNoteCard event={event} className={className} filterMutedNotes={filterMutedNotes} /> <RepostNoteCard
event={event}
className={className}
filterMutedNotes={filterMutedNotes}
pinned={pinned}
/>
) )
} }
return <MainNoteCard event={event} className={className} /> return <MainNoteCard event={event} className={className} pinned={pinned} />
}, (prevProps, nextProps) => { }, (prevProps, nextProps) => {
// Custom comparison function for memo // Custom comparison function for memo
return ( return (
prevProps.event.id === nextProps.event.id && prevProps.event.id === nextProps.event.id &&
prevProps.event.created_at === nextProps.event.created_at && prevProps.event.created_at === nextProps.event.created_at &&
prevProps.className === nextProps.className && prevProps.className === nextProps.className &&
prevProps.filterMutedNotes === nextProps.filterMutedNotes prevProps.filterMutedNotes === nextProps.filterMutedNotes &&
prevProps.pinned === nextProps.pinned
) )
}) })

57
src/components/Profile/ProfileFeedWithPins.tsx

@ -1,22 +1,45 @@
import NoteCard from '@/components/NoteCard' import NoteCard from '@/components/NoteCard'
import ProfileSearchBar from '@/components/ui/ProfileSearchBar' import ProfileSearchBar from '@/components/ui/ProfileSearchBar'
import RetroRefreshButton from '@/components/ui/RetroRefreshButton' import RetroRefreshButton from '@/components/ui/RetroRefreshButton'
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind, PROFILE_FEED_KINDS } from '@/constants' import { ExtendedKind, PROFILE_FEED_KINDS } from '@/constants'
import { isReplyNoteEvent } from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { useProfilePins } from '@/hooks/useProfilePins' import { useProfilePins } from '@/hooks/useProfilePins'
import { useProfileTimeline } from '@/hooks/useProfileTimeline' import { useProfileTimeline } from '@/hooks/useProfileTimeline'
import { useKindFilter } from '@/providers/KindFilterProvider'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import { Event } from 'nostr-tools' import storage from '@/services/local-storage.service'
import { Event, kinds } 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 { Skeleton } from '@/components/ui/skeleton'
const INITIAL_SHOW_COUNT = 25 const INITIAL_SHOW_COUNT = 25
const LOAD_MORE_COUNT = 25 const LOAD_MORE_COUNT = 25
function useHideRepliesLikeMainFeed() {
const [hideReplies, setHideReplies] = useState(() => {
const m = storage.getNoteListMode()
return m !== 'postsAndReplies'
})
useEffect(() => {
const sync = () => {
const m = storage.getNoteListMode()
setHideReplies(m !== 'postsAndReplies')
}
window.addEventListener('noteListModeChanged', sync)
return () => window.removeEventListener('noteListModeChanged', sync)
}, [])
return hideReplies
}
const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => { const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { zapReplyThreshold } = useZap() const { zapReplyThreshold } = useZap()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilter()
const hideReplies = useHideRepliesLikeMainFeed()
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT)
@ -49,9 +72,24 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
const pinIds = useMemo(() => new Set(pinEvents.map((e) => e.id)), [pinEvents]) const pinIds = useMemo(() => new Set(pinEvents.map((e) => e.id)), [pinEvents])
const passesMainFeedTimelineRules = useCallback(
(event: Event) => {
if (!showKinds.includes(event.kind)) return false
if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event)
if (hideReplies && isReply) return false
if (isReply && !showKind1Replies) return false
if (!isReply && !showKind1OPs) return false
}
if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false
return true
},
[showKinds, showKind1OPs, showKind1Replies, showKind1111, hideReplies]
)
const restTimeline = useMemo( const restTimeline = useMemo(
() => timelineEvents.filter((e) => !pinIds.has(e.id)), () => timelineEvents.filter((e) => !pinIds.has(e.id)).filter(passesMainFeedTimelineRules),
[timelineEvents, pinIds] [timelineEvents, pinIds, passesMainFeedTimelineRules]
) )
const applySearch = useCallback( const applySearch = useCallback(
@ -71,6 +109,8 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest]) const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest])
const pinnedDisplayIds = useMemo(() => new Set(filteredPins.map((e) => e.id)), [filteredPins])
useEffect(() => { useEffect(() => {
setShowCount(INITIAL_SHOW_COUNT) setShowCount(INITIAL_SHOW_COUNT)
}, [searchQuery, pubkey]) }, [searchQuery, pubkey])
@ -174,10 +214,15 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
<div key={event.id}> <div key={event.id}>
{index === filteredPins.length && filteredPins.length > 0 && filteredRest.length > 0 && ( {index === filteredPins.length && filteredPins.length > 0 && filteredRest.length > 0 && (
<div className="text-xs text-muted-foreground px-2 py-1 border-t border-border/60 mt-2 pt-2"> <div className="text-xs text-muted-foreground px-2 py-1 border-t border-border/60 mt-2 pt-2">
{t('Posts')} {t('Feed')}
</div> </div>
)} )}
<NoteCard className="w-full" event={event} filterMutedNotes={false} /> <NoteCard
className="w-full"
event={event}
filterMutedNotes={false}
pinned={pinnedDisplayIds.has(event.id)}
/>
</div> </div>
))} ))}
</div> </div>

7
src/components/Sidebar/DiscussionsButton.tsx

@ -5,14 +5,17 @@ import SidebarItem from './SidebarItem'
export default function DiscussionsButton() { export default function DiscussionsButton() {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate, current, display } = usePrimaryPage() const { navigate, current, currentPageProps, display } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView() const { primaryViewType } = usePrimaryNoteView()
const spell = (currentPageProps as { spell?: string } | undefined)?.spell
return ( return (
<SidebarItem <SidebarItem
title={t('Discussions')} title={t('Discussions')}
onClick={() => navigate('spells', { spell: 'discussions' })} onClick={() => navigate('spells', { spell: 'discussions' })}
active={display && current === 'spells' && primaryViewType === null} active={
display && current === 'spells' && primaryViewType === null && spell === 'discussions'
}
> >
<MessageCircle strokeWidth={3} /> <MessageCircle strokeWidth={3} />
</SidebarItem> </SidebarItem>

12
src/components/Sidebar/ExploreButton.tsx → src/components/Sidebar/FeedButton.tsx

@ -1,20 +1,20 @@
import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager'
import { Compass } from 'lucide-react' import { Newspaper } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import SidebarItem from './SidebarItem' import SidebarItem from './SidebarItem'
export default function RelaysButton() { export default function FeedButton() {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate, current, display } = usePrimaryPage() const { navigate, current, display } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView() const { primaryViewType } = usePrimaryNoteView()
return ( return (
<SidebarItem <SidebarItem
title={t('Explore')} title={t('Feed')}
onClick={() => navigate('explore')} onClick={() => navigate('feed')}
active={display && current === 'explore' && primaryViewType === null} active={display && current === 'feed' && primaryViewType === null}
> >
<Compass strokeWidth={3} /> <Newspaper strokeWidth={3} />
</SidebarItem> </SidebarItem>
) )
} }

43
src/components/Sidebar/HomeButton.tsx

@ -1,55 +1,16 @@
import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager'
import { Home } from 'lucide-react' import { Home } from 'lucide-react'
import SidebarItem from './SidebarItem' import SidebarItem from './SidebarItem'
import storage from '@/services/local-storage.service'
import { useState, useEffect } from 'react'
export default function HomeButton() { export default function HomeButton() {
const { navigate, current, display } = usePrimaryPage() const { navigate, current, display } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView() const { primaryViewType } = usePrimaryNoteView()
const showRssFeed = storage.getShowRssFeed()
const [rssTabActive, setRssTabActive] = useState(false)
// Listen for RSS tab state changes
useEffect(() => {
const handleRssTabStateChange = (event: CustomEvent<{ active: boolean }>) => {
setRssTabActive(event.detail.active)
}
window.addEventListener('rssTabStateChanged', handleRssTabStateChange as EventListener)
// Check initial state
setRssTabActive(false) // Default to false, will be updated by event
return () => {
window.removeEventListener('rssTabStateChanged', handleRssTabStateChange as EventListener)
}
}, [])
// Home is active when on home page, but NOT when RSS tab is active (RSS button handles that)
const isActive = display && current === 'home' && primaryViewType === null && !(showRssFeed && rssTabActive)
const handleClick = () => {
// Navigate to home if not already there
if (current !== 'home' || primaryViewType !== null) {
navigate('home')
// Wait a bit for navigation to complete, then switch to Notes tab
setTimeout(() => {
window.dispatchEvent(new CustomEvent('switchToNotesTab'))
}, 100)
} else {
// Already on home, just switch to Notes tab (if RSS is active)
if (showRssFeed && rssTabActive) {
window.dispatchEvent(new CustomEvent('switchToNotesTab'))
}
}
}
return ( return (
<SidebarItem <SidebarItem
title="Home" title="Home"
onClick={handleClick} onClick={() => navigate('home')}
active={isActive} active={display && current === 'home' && primaryViewType === null}
> >
<Home strokeWidth={3} /> <Home strokeWidth={3} />
</SidebarItem> </SidebarItem>

13
src/components/Sidebar/NotificationButton.tsx

@ -1,17 +1,24 @@
import { usePrimaryPage } from '@/PageManager' import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Bell } from 'lucide-react' import { Bell } from 'lucide-react'
import SidebarItem from './SidebarItem' import SidebarItem from './SidebarItem'
export default function NotificationButton() { export default function NotificationButton() {
const { navigate } = usePrimaryPage() const { navigate, current, currentPageProps, display } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView()
const { checkLogin } = useNostr() const { checkLogin } = useNostr()
const spell = (currentPageProps as { spell?: string } | undefined)?.spell
return ( return (
<SidebarItem <SidebarItem
title="notifications" title="notifications"
onClick={() => checkLogin(() => navigate('spells', { spell: 'notifications' }))} onClick={() => checkLogin(() => navigate('spells', { spell: 'notifications' }))}
active={false} active={
display &&
current === 'spells' &&
primaryViewType === null &&
spell === 'notifications'
}
> >
<Bell strokeWidth={3} /> <Bell strokeWidth={3} />
</SidebarItem> </SidebarItem>

38
src/components/Sidebar/RssButton.tsx

@ -2,51 +2,19 @@ import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager'
import { Rss } from 'lucide-react' import { Rss } from 'lucide-react'
import SidebarItem from './SidebarItem' import SidebarItem from './SidebarItem'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { useState, useEffect } from 'react'
export default function RssButton() { export default function RssButton() {
const { navigate, current, display } = usePrimaryPage() const { navigate, current, display } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView() const { primaryViewType } = usePrimaryNoteView()
const showRssFeed = storage.getShowRssFeed() const showRssFeed = storage.getShowRssFeed()
const [rssTabActive, setRssTabActive] = useState(false)
// Listen for RSS tab state changes if (!showRssFeed) return null
useEffect(() => {
const handleRssTabStateChange = (event: CustomEvent<{ active: boolean }>) => {
setRssTabActive(event.detail.active)
}
window.addEventListener('rssTabStateChanged', handleRssTabStateChange as EventListener)
// Check initial state
setRssTabActive(false) // Default to false, will be updated by event
return () => {
window.removeEventListener('rssTabStateChanged', handleRssTabStateChange as EventListener)
}
}, [])
// RSS is active when on home page, RSS tab is actually active, and RSS feed is enabled const isActive = display && current === 'rss' && primaryViewType === null
const isActive = display && current === 'home' && primaryViewType === null && showRssFeed && rssTabActive
const handleClick = () => {
// Navigate to home if not already there
if (current !== 'home' || primaryViewType !== null) {
navigate('home')
// Wait a bit for navigation to complete, then switch to RSS
setTimeout(() => {
window.dispatchEvent(new CustomEvent('switchToRssFeed'))
}, 100)
} else {
// Already on home, just switch to RSS tab
window.dispatchEvent(new CustomEvent('switchToRssFeed'))
}
}
return ( return (
<SidebarItem title="RSS Feed" onClick={handleClick} active={isActive}> <SidebarItem title="RSS Feed" onClick={() => navigate('rss')} active={isActive}>
<Rss strokeWidth={3} /> <Rss strokeWidth={3} />
</SidebarItem> </SidebarItem>
) )
} }

6
src/components/Sidebar/SidebarItem.tsx

@ -12,7 +12,7 @@ const SidebarItem = forwardRef<
return ( return (
<Button <Button
className={cn( className={cn(
'flex shadow-none items-center transition-colors duration-500 bg-transparent w-12 h-12 xl:w-full xl:h-auto p-3 m-0 xl:py-2 xl:px-3 rounded-lg xl:justify-start gap-4 text-lg font-semibold [&_svg]:size-full xl:[&_svg]:size-4', 'flex shadow-none items-center transition-colors duration-500 bg-transparent w-12 h-12 xl:w-full xl:h-auto xl:min-w-0 p-3 m-0 xl:py-2 xl:pl-3 xl:pr-4 rounded-lg xl:justify-start gap-3 text-lg font-semibold [&_svg]:size-full xl:[&_svg]:size-4 xl:[&_svg]:shrink-0',
active && 'text-primary hover:text-primary bg-primary/10 hover:bg-primary/10', active && 'text-primary hover:text-primary bg-primary/10 hover:bg-primary/10',
className className
)} )}
@ -22,7 +22,9 @@ const SidebarItem = forwardRef<
{...props} {...props}
> >
{children} {children}
<div className="max-xl:hidden">{t(description ?? title)}</div> <div className="max-xl:hidden min-w-0 flex-1 text-left break-words leading-snug pr-0.5">
{t(description ?? title)}
</div>
</Button> </Button>
) )
}) })

10
src/components/Sidebar/index.tsx

@ -4,7 +4,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import AccountButton from './AccountButton' import AccountButton from './AccountButton'
import KeyboardShortcutsHelpSidebarButton from './KeyboardShortcutsHelpSidebarButton' import KeyboardShortcutsHelpSidebarButton from './KeyboardShortcutsHelpSidebarButton'
import DiscussionsButton from './DiscussionsButton' import DiscussionsButton from './DiscussionsButton'
import RelaysButton from './ExploreButton' import FeedButton from './FeedButton'
import HomeButton from './HomeButton' import HomeButton from './HomeButton'
import NotificationButton from './NotificationButton' import NotificationButton from './NotificationButton'
import PostButton from './PostButton' import PostButton from './PostButton'
@ -13,15 +13,13 @@ import SearchButton from './SearchButton'
import SettingsButton from './SettingsButton' import SettingsButton from './SettingsButton'
import SpellsButton from './SpellsButton' import SpellsButton from './SpellsButton'
import PaneModeToggle from './PaneModeToggle' import PaneModeToggle from './PaneModeToggle'
import storage from '@/services/local-storage.service'
export default function PrimaryPageSidebar() { export default function PrimaryPageSidebar() {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const showRssFeed = storage.getShowRssFeed()
if (isSmallScreen) return null if (isSmallScreen) return null
return ( return (
<div className="w-16 xl:w-52 flex flex-col pb-2 pt-4 px-2 xl:px-4 justify-between h-full shrink-0"> <div className="w-16 xl:w-52 flex flex-col pb-2 pt-4 px-2 xl:pl-4 xl:pr-6 justify-between h-full shrink-0">
<div className="space-y-2"> <div className="space-y-2">
<div className="px-3 xl:px-4 mb-6 w-full"> <div className="px-3 xl:px-4 mb-6 w-full">
<Icon className="xl:hidden" /> <Icon className="xl:hidden" />
@ -33,12 +31,12 @@ export default function PrimaryPageSidebar() {
</div> </div>
</div> </div>
<HomeButton /> <HomeButton />
<RelaysButton /> <FeedButton />
<DiscussionsButton /> <DiscussionsButton />
<NotificationButton /> <NotificationButton />
<SearchButton /> <SearchButton />
<SpellsButton /> <SpellsButton />
{showRssFeed && <RssButton />} <RssButton />
<SettingsButton /> <SettingsButton />
<PostButton /> <PostButton />
</div> </div>

18
src/components/Titlebar/ExploreButton.tsx

@ -1,18 +0,0 @@
import { usePrimaryPage } from '@/PageManager'
import { Button } from '@/components/ui/button'
import { Compass } from 'lucide-react'
export default function ExploreButton() {
const { navigate, current, display } = usePrimaryPage()
return (
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => navigate('explore')}
className={current === 'explore' && display ? 'bg-accent/50' : ''}
>
<Compass />
</Button>
)
}

1
src/constants.ts

@ -279,7 +279,6 @@ export const PROFILE_FEED_KINDS = SUPPORTED_KINDS.filter(
/** 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 = [
'favorite-relays',
'notifications', 'notifications',
'discussions', 'discussions',
'following', 'following',

10
src/i18n/locales/de.ts

@ -453,6 +453,14 @@ export default {
'Refresh results': 'Ergebnisse aktualisieren', 'Refresh results': 'Ergebnisse aktualisieren',
Poll: 'Umfrage', Poll: 'Umfrage',
Media: 'Medien', Media: 'Medien',
Interests: 'Interessen',
Calendar: 'Kalender',
'No subscribed interests yet.':
'Noch keine Interessen abonniert. Themen in den Einstellungen hinzufügen, um sie hier zu sehen.',
'No bookmarked notes with id tags yet.':
'Noch keine Lesezeichen mit Ereignis-IDs. Nur klassische (e-Tag-) Lesezeichen erscheinen in diesem Feed.',
'No follows or relays to load yet.': 'Noch keine Follows oder Relays zum Laden.',
'Nothing to load for this feed.': 'Für diesen Feed gibt es nichts zu laden.',
'Republish to ...': 'Erneut veröffentlichen zu ...', 'Republish to ...': 'Erneut veröffentlichen zu ...',
'Successfully republish to your write relays': 'Successfully republish to your write relays':
'Erfolgreich erneut zu deinen Schreib-Relays veröffentlicht', 'Erfolgreich erneut zu deinen Schreib-Relays veröffentlicht',
@ -710,6 +718,8 @@ export default {
'Comma-separated topics': 'Themen, komma-getrennt', 'Comma-separated topics': 'Themen, komma-getrennt',
Mode: 'Modus', Mode: 'Modus',
Feed: 'Feed', Feed: 'Feed',
'Favorites Feed': 'Favoriten-Feed',
'Pinned note': 'Angehefteter Beitrag',
Fetch: 'Abrufen', Fetch: 'Abrufen',
'Fetch once, then stop.': 'Einmal abrufen, dann stoppen.', 'Fetch once, then stop.': 'Einmal abrufen, dann stoppen.',
'Live feed; keeps updating.': 'Live-Feed; wird fortgesetzt aktualisiert.', 'Live feed; keeps updating.': 'Live-Feed; wird fortgesetzt aktualisiert.',

13
src/i18n/locales/en.ts

@ -5,6 +5,9 @@ export default {
'New Note': 'New Note', 'New Note': 'New Note',
Post: 'Post', Post: 'Post',
Home: 'Home', Home: 'Home',
Feed: 'Feed',
'Favorites Feed': 'Favorites Feed',
'Pinned note': 'Pinned note',
'Relay settings': 'Relays and Storage Settings', 'Relay settings': 'Relays and Storage Settings',
Settings: 'Settings', Settings: 'Settings',
SidebarRelays: 'Relays', SidebarRelays: 'Relays',
@ -65,6 +68,7 @@ export default {
"username's used relays": "{{username}}'s used relays", "username's used relays": "{{username}}'s used relays",
"username's muted": "{{username}}'s muted", "username's muted": "{{username}}'s muted",
Login: 'Login', Login: 'Login',
'Please log in to view notifications.': 'Please log in to view notifications.',
'Follows you': 'Follows you', 'Follows you': 'Follows you',
'Relay Settings': 'Relays and Storage Settings', 'Relay Settings': 'Relays and Storage Settings',
'Relays and Storage Settings': 'Relays and Storage Settings', 'Relays and Storage Settings': 'Relays and Storage Settings',
@ -509,6 +513,14 @@ export default {
'Refresh results': 'Refresh results', 'Refresh results': 'Refresh results',
Poll: 'Poll', Poll: 'Poll',
Media: 'Media', Media: 'Media',
Interests: 'Interests',
Calendar: 'Calendar',
'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.',
'No bookmarked notes with id tags yet.':
'No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.',
'No follows or relays to load yet.': 'No follows or relays to load yet.',
'Nothing to load for this feed.': 'Nothing to load for this feed.',
'Republish to ...': 'Republish to ...', 'Republish to ...': 'Republish to ...',
'All available relays': 'All available relays', 'All available relays': 'All available relays',
'All active relays (monitoring list)': 'All active relays (monitoring list)', 'All active relays (monitoring list)': 'All active relays (monitoring list)',
@ -663,6 +675,7 @@ export default {
'nested events': 'nested events', 'nested events': 'nested events',
'Loading RSS feeds...': 'Loading RSS feeds...', 'Loading RSS feeds...': 'Loading RSS feeds...',
'No RSS feed items available': 'No RSS feed items available', 'No RSS feed items available': 'No RSS feed items available',
'Show or hide the RSS page and sidebar entry': 'Show or hide the RSS page and sidebar entry',
'Refreshing feeds...': 'Refreshing feeds...', 'Refreshing feeds...': 'Refreshing feeds...',
'All feeds': 'All feeds', 'All feeds': 'All feeds',
'All time': 'All time', 'All time': 'All time',

48
src/pages/primary/ExplorePage/index.tsx

@ -1,22 +1,30 @@
import Explore from '@/components/Explore' import Explore from '@/components/Explore'
import FollowingFavoriteRelayList from '@/components/FollowingFavoriteRelayList' import FollowingFavoriteRelayList from '@/components/FollowingFavoriteRelayList'
import Tabs from '@/components/Tabs' import Tabs from '@/components/Tabs'
import VersionUpdateBanner from '@/components/VersionUpdateBanner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { Compass, Plus } from 'lucide-react' import { Compass, Plus } from 'lucide-react'
import { forwardRef, useState, useEffect } from 'react' import { forwardRef, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
type TExploreTabs = 'following' | 'explore' type TExploreTabs = 'explore' | 'following'
function normalizeHomeTab(restored: string): TExploreTabs {
if (restored === 'following') return 'following'
// Removed "favorites" tab — treat saved state as Explore
return 'explore'
}
const ExplorePage = forwardRef((_, ref) => { const ExplorePage = forwardRef((_, ref) => {
const { t } = useTranslation()
const [tab, setTab] = useState<TExploreTabs>('explore') const [tab, setTab] = useState<TExploreTabs>('explore')
// Listen for tab restoration from PageManager // Listen for tab restoration from PageManager
useEffect(() => { useEffect(() => {
const handleRestore = (e: CustomEvent<{ page: string, tab: string }>) => { const handleRestore = (e: CustomEvent<{ page: string; tab: string }>) => {
if (e.detail.page === 'explore' && e.detail.tab) { if (e.detail.page === 'home' && e.detail.tab) {
setTab(e.detail.tab as TExploreTabs) setTab(normalizeHomeTab(e.detail.tab))
} }
} }
window.addEventListener('restorePageTab', handleRestore as EventListener) window.addEventListener('restorePageTab', handleRestore as EventListener)
@ -26,27 +34,33 @@ const ExplorePage = forwardRef((_, ref) => {
return ( return (
<PrimaryPageLayout <PrimaryPageLayout
ref={ref} ref={ref}
pageName="explore" pageName="home"
titlebar={<ExplorePageTitlebar />} titlebar={<ExplorePageTitlebar t={t} />}
subHeader={ subHeader={
<Tabs <Tabs
value={tab} value={tab}
tabs={[ tabs={[
{ value: 'explore', label: 'Explore' }, { value: 'explore', label: t('Explore') },
{ value: 'following', label: "Following's Favorites" } { value: 'following', label: t("Following's Favorites") }
]} ]}
onTabChange={(tab) => { onTabChange={(next) => {
setTab(tab as TExploreTabs) setTab(next as TExploreTabs)
window.dispatchEvent(new CustomEvent('pageTabChanged', { window.dispatchEvent(
detail: { page: 'explore', tab: tab } new CustomEvent('pageTabChanged', {
})) detail: { page: 'home', tab: next }
})
)
}} }}
/> />
} }
displayScrollToTopButton displayScrollToTopButton
> >
<div className="min-w-0 pt-2"> <div className="min-w-0 pt-2">
{tab === 'following' ? <FollowingFavoriteRelayList /> : <Explore />} <div className="px-2">
<VersionUpdateBanner />
</div>
{tab === 'explore' && <Explore />}
{tab === 'following' && <FollowingFavoriteRelayList />}
</div> </div>
</PrimaryPageLayout> </PrimaryPageLayout>
) )
@ -54,9 +68,7 @@ const ExplorePage = forwardRef((_, ref) => {
ExplorePage.displayName = 'ExplorePage' ExplorePage.displayName = 'ExplorePage'
export default ExplorePage export default ExplorePage
function ExplorePageTitlebar() { function ExplorePageTitlebar({ t }: { t: (key: string) => string }) {
const { t } = useTranslation()
return ( return (
<div className="flex gap-2 justify-between h-full"> <div className="flex gap-2 justify-between h-full">
<div className="flex gap-2 items-center h-full pl-3"> <div className="flex gap-2 items-center h-full pl-3">

47
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -8,9 +8,12 @@ import { kinds } from 'nostr-tools'
import React, { useEffect, useMemo, useState, useRef } from 'react' import React, { useEffect, useMemo, useState, useRef } from 'react'
export default function RelaysFeed({ export default function RelaysFeed({
setSubHeader setSubHeader,
kindsOverride
}: { }: {
setSubHeader?: (node: React.ReactNode) => void setSubHeader?: (node: React.ReactNode) => void
/** When set, subscription kinds (fixed list); otherwise uses KindFilterProvider. */
kindsOverride?: number[]
}) { }) {
logger.debug('RelaysFeed component rendering') logger.debug('RelaysFeed component rendering')
const { feedInfo, relayUrls } = useFeed() const { feedInfo, relayUrls } = useFeed()
@ -75,30 +78,38 @@ export default function RelaysFeed({
relayInfoFetchedRef.current = false relayInfoFetchedRef.current = false
}, [relayUrls]) }, [relayUrls])
// Early returns for invalid feed types const defaultKinds =
if (feedInfo.feedType !== 'relay' && feedInfo.feedType !== 'relays' && feedInfo.feedType !== 'all-favorites') { kindsOverride && kindsOverride.length > 0
return null ? kindsOverride
} : showKinds.length > 0
? showKinds
// CRITICAL: Don't render feed if relayUrls is empty - this would cause subscription to fail : [kinds.ShortTextNote]
if (relayUrls.length === 0) {
logger.debug('RelaysFeed: relayUrls is empty, not rendering feed')
return null
}
// CRITICAL: Provide proper filter with default kinds - NoteList requires kinds in filter const canRenderFeed =
// Use showKinds from KindFilterProvider if available, otherwise default to kind 1 (feedInfo.feedType === 'relay' ||
const defaultKinds = showKinds.length > 0 ? showKinds : [kinds.ShortTextNote] feedInfo.feedType === 'relays' ||
feedInfo.feedType === 'all-favorites') &&
relayUrls.length > 0
// Memoize subRequests with proper filter - this ensures NoteList gets valid filter // Hooks must run every render — never place useMemo after conditional returns.
const subRequests = useMemo(() => { const subRequests = useMemo(() => {
return [{ if (!canRenderFeed) return []
return [
{
urls: relayUrls, urls: relayUrls,
filter: { filter: {
kinds: defaultKinds kinds: defaultKinds
} }
}] }
}, [relayUrls, defaultKinds]) ]
}, [canRenderFeed, relayUrls, defaultKinds, kindsOverride])
if (!canRenderFeed) {
if (relayUrls.length === 0) {
logger.debug('RelaysFeed: relayUrls is empty, not rendering feed')
}
return null
}
logger.component('RelaysFeed', 'Rendering NormalFeed', { logger.component('RelaysFeed', 'Rendering NormalFeed', {
subRequests: subRequests.length, subRequests: subRequests.length,

8
src/pages/primary/NoteListPage/index.tsx

@ -21,7 +21,6 @@ import React, {
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { KeyboardShortcutsHelpButton } from '@/components/KeyboardShortcutsHelp' import { KeyboardShortcutsHelpButton } from '@/components/KeyboardShortcutsHelp'
import ExploreButton from '@/components/Titlebar/ExploreButton'
import AccountButton from '@/components/Titlebar/AccountButton' import AccountButton from '@/components/Titlebar/AccountButton'
import FollowingFeed from './FollowingFeed' import FollowingFeed from './FollowingFeed'
import RelaysFeed from './RelaysFeed' import RelaysFeed from './RelaysFeed'
@ -111,7 +110,7 @@ const NoteListPage = forwardRef((_, ref) => {
return ( return (
<PrimaryPageLayout <PrimaryPageLayout
pageName="home" pageName="feed"
ref={layoutRef} ref={layoutRef}
titlebar={ titlebar={
<NoteListPageTitlebar <NoteListPageTitlebar
@ -144,12 +143,13 @@ function NoteListPageTitlebar({
showRelayDetails?: boolean showRelayDetails?: boolean
setShowRelayDetails?: Dispatch<SetStateAction<boolean>> setShowRelayDetails?: Dispatch<SetStateAction<boolean>>
}) { }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { setPrimaryNoteView } = usePrimaryNoteView() const { setPrimaryNoteView } = usePrimaryNoteView()
return ( return (
<div className="relative flex gap-1 items-center h-full justify-between"> <div className="relative flex gap-1 items-center h-full justify-between">
<div className="flex gap-1 items-center"> <div className="flex gap-2 items-center h-full pl-3">
<ExploreButton /> <div className="text-lg font-semibold">{t('Favorites Feed')}</div>
</div> </div>
{isSmallScreen && ( {isSmallScreen && (
<div className="absolute left-1/2 transform -translate-x-1/2 z-10"> <div className="absolute left-1/2 transform -translate-x-1/2 z-10">

78
src/pages/primary/RssPage/index.tsx

@ -0,0 +1,78 @@
import RssFeedList from '@/components/RssFeedList'
import { RefreshButton } from '@/components/RefreshButton'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { Button } from '@/components/ui/button'
import { DEFAULT_RSS_FEEDS } from '@/constants'
import logger from '@/lib/logger'
import { useNostr } from '@/providers/NostrProvider'
import rssFeedService from '@/services/rss-feed.service'
import { Rss, Search } from 'lucide-react'
import { forwardRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const RssPage = forwardRef((_, ref) => {
const { t } = useTranslation()
const { pubkey, rssFeedListEvent } = useNostr()
const [rssRefreshKey, setRssRefreshKey] = useState(0)
const handleRefresh = () => {
let feedUrls: string[] = []
if (pubkey && rssFeedListEvent) {
try {
feedUrls = rssFeedListEvent.tags
.filter((tag) => tag[0] === 'u' && tag[1])
.map((tag) => tag[1] as string)
.filter((url): url is string => typeof url === 'string' && url.trim().length > 0)
} catch {
feedUrls = []
}
} else {
feedUrls = DEFAULT_RSS_FEEDS
}
rssFeedService.backgroundRefreshFeeds(feedUrls).catch((err) => {
logger.error('[RssPage] Background refresh failed', { error: err })
})
if (pubkey) {
window.dispatchEvent(
new CustomEvent('rssFeedListUpdated', {
detail: { pubkey, feedUrls, eventId: 'manual-refresh' }
})
)
}
setRssRefreshKey((k) => k + 1)
}
return (
<PrimaryPageLayout
ref={ref}
pageName="rss"
titlebar={
<div className="flex h-full w-full items-center justify-between gap-2 pr-1">
<div className="flex items-center gap-2 pl-3">
<Rss className="size-5" />
<div className="text-lg font-semibold">{t('RSS Feed')}</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => window.dispatchEvent(new CustomEvent('toggleRssFilters'))}
title={t('Toggle filters')}
>
<Search className="h-4 w-4" />
</Button>
<RefreshButton onClick={handleRefresh} />
</div>
</div>
}
displayScrollToTopButton
>
<div className="min-w-0 px-2 pt-2">
<RssFeedList key={rssRefreshKey} />
</div>
</PrimaryPageLayout>
)
})
RssPage.displayName = 'RssPage'
export default RssPage

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

@ -0,0 +1,167 @@
/**
* Built-in faux spells use the same NoteList path as kind-777 REQ spells.
*/
import {
DEFAULT_FAVORITE_RELAYS,
ExtendedKind,
FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS,
PROFILE_FEED_KINDS
} from '@/constants'
import { normalizeTopic } from '@/lib/discussion-topics'
import { normalizeUrl } from '@/lib/url'
import type { TFeedSubRequest, TRelayList, TNotificationType } from '@/types'
import { kinds, type Event, type Filter } from 'nostr-tools'
const NOTIFICATION_LIMIT = 500
const DISCUSSION_LIMIT = 500
const MAX_BOOKMARK_IDS = 250
export const MEDIA_SPELL_KINDS = [
ExtendedKind.PICTURE,
ExtendedKind.VIDEO,
ExtendedKind.SHORT_VIDEO,
ExtendedKind.VOICE
] as const
/** Relays for “global” faux feeds (media, calendar): visible favorites or defaults. */
export function fauxFavoriteRelayUrls(favoriteRelays: string[], blockedRelays: string[]): string[] {
const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b))
const visible = favoriteRelays.filter((r) => {
const k = normalizeUrl(r) || r
return k && !blocked.has(k)
})
const base = visible.length > 0 ? visible : DEFAULT_FAVORITE_RELAYS
return dedupe(base.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[])
}
export function notificationRelayUrls(
relayList: TRelayList | null | undefined,
favoriteRelays: string[]
): string[] {
const read = relayList?.read ?? []
if (read.length > 0) return dedupe(read.slice(0, 5))
if (favoriteRelays.length > 0) return dedupe(favoriteRelays.slice(0, 5))
return dedupe(FAST_READ_RELAY_URLS.slice(0, 5))
}
function dedupe(urls: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const u of urls) {
const k = normalizeUrl(u) || u
if (!k || seen.has(k)) continue
seen.add(k)
out.push(k)
}
return out
}
export function notificationFilterKinds(notificationType: TNotificationType): number[] {
switch (notificationType) {
case 'mentions':
return [
kinds.ShortTextNote,
ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.POLL,
ExtendedKind.PUBLIC_MESSAGE,
ExtendedKind.DISCUSSION
]
case 'reactions':
return [kinds.Reaction, kinds.Repost, ExtendedKind.POLL_RESPONSE]
case 'zaps':
return [kinds.Zap]
default:
return [
kinds.ShortTextNote,
kinds.Repost,
kinds.Reaction,
kinds.Zap,
ExtendedKind.COMMENT,
ExtendedKind.POLL_RESPONSE,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.POLL,
ExtendedKind.PUBLIC_MESSAGE,
ExtendedKind.DISCUSSION
]
}
}
export function buildNotificationFilter(pubkey: string, notificationType: TNotificationType): Filter {
return {
kinds: notificationFilterKinds(notificationType),
limit: NOTIFICATION_LIMIT,
'#p': [pubkey]
}
}
/** Relay set for discussion threads (kind 11), aligned with DiscussionsPage’s merged list (sync). */
export function discussionRelayUrls(
relayList: TRelayList | null | undefined,
favoriteRelays: string[],
blockedRelays: string[]
): string[] {
const read = relayList?.read ?? []
const write = relayList?.write ?? []
const merged = [...read, ...write, ...favoriteRelays, ...FAST_READ_RELAY_URLS, ...FAST_WRITE_RELAY_URLS]
const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b))
const seen = new Set<string>()
const out: string[] = []
for (const u of merged) {
const k = normalizeUrl(u) || u
if (!k || seen.has(k) || blocked.has(k)) continue
seen.add(k)
out.push(k)
}
return out
}
export function buildDiscussionFilter(): Filter {
return {
kinds: [ExtendedKind.DISCUSSION],
limit: DISCUSSION_LIMIT
}
}
export function buildMediaSpellFilter(): Filter {
return { kinds: [...MEDIA_SPELL_KINDS], limit: 500 }
}
export function buildCalendarSpellFilter(): Filter {
return {
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME],
limit: 200
}
}
/** One subrequest per topic (OR). Uses same kind set as the main profile/favorites feed. */
export function buildInterestsSubRequests(
relayUrls: string[],
rawTopics: string[],
kindsList: number[] = PROFILE_FEED_KINDS
): TFeedSubRequest[] {
if (!relayUrls.length || !rawTopics.length || !kindsList.length) return []
const topics = Array.from(
new Set(rawTopics.map((t) => normalizeTopic(t)).filter((t) => t.length > 0))
)
if (!topics.length) return []
return topics.map((topic) => ({
urls: relayUrls,
filter: {
kinds: kindsList,
'#t': [topic],
limit: 400
}
}))
}
/** Bookmark list e-tags only (hex ids); addressable (a-tag) bookmarks need separate fetches. */
export function buildBookmarksSubRequests(bookmarkListEvent: Event | null, urls: string[]): TFeedSubRequest[] {
if (!bookmarkListEvent?.tags?.length || !urls.length) return []
const ids = bookmarkListEvent.tags
.filter((t) => t[0] === 'e' && t[1] && /^[a-f0-9]{64}$/i.test(t[1]))
.map((t) => t[1] as string)
if (!ids.length) return []
return [{ urls, filter: { ids: ids.slice(0, MAX_BOOKMARK_IDS), limit: MAX_BOOKMARK_IDS } }]
}

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

@ -1,10 +1,7 @@
import HideUntrustedContentButton from '@/components/HideUntrustedContentButton' import HideUntrustedContentButton from '@/components/HideUntrustedContentButton'
import NoteList from '@/components/NoteList' import NoteList from '@/components/NoteList'
import NotificationList from '@/components/NotificationList'
import { RefreshButton } from '@/components/RefreshButton'
import Tabs from '@/components/Tabs' import Tabs from '@/components/Tabs'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -28,13 +25,17 @@ import {
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username' import Username from '@/components/Username'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { usePrimaryPage } from '@/PageManager'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { showPublishingError } from '@/lib/publishing-feedback' import { showPublishingError } from '@/lib/publishing-feedback'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useKindFilter } from '@/providers/KindFilterProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { ExtendedKind } from '@/constants' import storage from '@/services/local-storage.service'
import { ExtendedKind, FAUX_SPELL_ORDER, PROFILE_FEED_KINDS } from '@/constants'
import { formatPubkey } from '@/lib/pubkey' import { formatPubkey } from '@/lib/pubkey'
import { import {
buildSpellCatalogAuthors, buildSpellCatalogAuthors,
@ -44,23 +45,26 @@ import {
isSpellEvent, isSpellEvent,
SPELL_CATALOG_SYNC_LIMIT, SPELL_CATALOG_SYNC_LIMIT,
SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS, SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS,
spellEventToFilter, spellEventToFilter
spellHasExplicitRelays,
spellIsCount
} from '@/services/spell.service' } from '@/services/spell.service'
import { TFeedSubRequest } from '@/types' import { TFeedSubRequest, type TNotificationType } from '@/types'
import { import {
Bell, Bell,
Bookmark,
CalendarDays,
Check, Check,
ChevronDown, ChevronDown,
Copy, Copy,
FileText, FileText,
Hash,
Image as ImageIcon,
MessageSquare, MessageSquare,
MoreVertical, MoreVertical,
Pencil, Pencil,
Plus, Plus,
Star, Star,
Trash2, Trash2,
Users,
Wand2 Wand2
} from 'lucide-react' } from 'lucide-react'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
@ -68,10 +72,20 @@ import { verifyEvent } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import CreateSpellDialog from './CreateSpellDialog' import CreateSpellDialog from './CreateSpellDialog'
import {
buildBookmarksSubRequests,
buildCalendarSpellFilter,
buildDiscussionFilter,
buildInterestsSubRequests,
buildMediaSpellFilter,
buildNotificationFilter,
discussionRelayUrls,
fauxFavoriteRelayUrls,
MEDIA_SPELL_KINDS,
notificationFilterKinds,
notificationRelayUrls
} from './fauxSpellFeeds'
import type { TPageRef } from '@/types' import type { TPageRef } from '@/types'
import type { TNotificationType } from '@/types'
import { isTouchDevice } from '@/lib/utils'
import DiscussionsPage from '@/pages/primary/DiscussionsPage'
/** Primary + optional subtitle (npub and/or short id). When grouped under an author header, omit npub. */ /** Primary + optional subtitle (npub and/or short id). When grouped under an author header, omit npub. */
function spellPickerPrimaryAndSecondary( function spellPickerPrimaryAndSecondary(
@ -166,30 +180,75 @@ function SpellSheetOptionRow({
) )
} }
const FAUX_SPELL_NAMES = ['notifications', 'discussions'] as const type FauxSpellName = (typeof FAUX_SPELL_ORDER)[number]
type FauxSpellName = (typeof FAUX_SPELL_NAMES)[number]
function isFauxSpellName(s: string): s is FauxSpellName { function isFauxSpellName(s: string): s is FauxSpellName {
return FAUX_SPELL_NAMES.includes(s as FauxSpellName) return (FAUX_SPELL_ORDER as readonly string[]).includes(s)
}
function useNoteListHideReplies() {
const [hideReplies, setHideReplies] = useState(() => storage.getNoteListMode() === 'posts')
useEffect(() => {
const sync = () => setHideReplies(storage.getNoteListMode() === 'posts')
window.addEventListener('noteListModeChanged', sync)
return () => window.removeEventListener('noteListModeChanged', sync)
}, [])
return hideReplies
} }
const SpellsPage = forwardRef<TPageRef>(function SpellsPage({ spell: spellProp }: { spell?: string }, ref) { function fauxSpellLabelKey(name: FauxSpellName): string {
switch (name) {
case 'notifications':
return 'Notifications'
case 'discussions':
return 'Discussions'
case 'following':
return 'Following'
case 'media':
return 'Media'
case 'interests':
return 'Interests'
case 'bookmarks':
return 'Bookmarks'
case 'calendar':
return 'Calendar'
default:
return 'Spells'
}
}
const FAUX_SPELL_ICON: Record<FauxSpellName, typeof Bell> = {
notifications: Bell,
discussions: MessageSquare,
following: Users,
media: ImageIcon,
interests: Hash,
bookmarks: Bookmark,
calendar: CalendarDays
}
const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
{ spell: spellProp }: { spell?: string },
ref
) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, relayList, attemptDelete } = useNostr() const { navigate: navigatePrimary } = usePrimaryPage()
const { pubkey, relayList, attemptDelete, bookmarkListEvent, interestListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const {
showKinds: kindFilterShowKinds,
showKind1OPs,
showKind1Replies,
showKind1111
} = useKindFilter()
const hideRepliesFollowing = useNoteListHideReplies()
const [spells, setSpells] = useState<Event[]>([]) const [spells, setSpells] = useState<Event[]>([])
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set()) const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set())
const [selectedSpell, setSelectedSpell] = useState<Event | null>(null) const [selectedSpell, setSelectedSpell] = useState<Event | null>(null)
const [selectedFauxSpell, setSelectedFauxSpell] = useState<FauxSpellName | null>(null) const [selectedFauxSpell, setSelectedFauxSpell] = useState<FauxSpellName | null>(null)
const [notificationType, setNotificationType] = useState<TNotificationType>('all') const [notificationType, setNotificationType] = useState<TNotificationType>('all')
const notificationListRef = useRef<{ refresh: () => void }>(null)
const supportTouch = useMemo(() => isTouchDevice(), [])
useEffect(() => {
if (spellProp && isFauxSpellName(spellProp)) {
setSelectedFauxSpell(spellProp)
setSelectedSpell(null)
}
}, [spellProp])
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [spellToEdit, setSpellToEdit] = useState<Event | null>(null) const [spellToEdit, setSpellToEdit] = useState<Event | null>(null)
const [spellToClone, setSpellToClone] = useState<Event | null>(null) const [spellToClone, setSpellToClone] = useState<Event | null>(null)
@ -199,22 +258,40 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage({ spell: spellProp }
const [spellsCatalogSyncing, setSpellsCatalogSyncing] = useState(false) const [spellsCatalogSyncing, setSpellsCatalogSyncing] = useState(false)
const spellCatalogCloserRef = useRef<(() => void) | null>(null) const spellCatalogCloserRef = useRef<(() => void) | null>(null)
const [spellPickerOpen, setSpellPickerOpen] = useState(false) const [spellPickerOpen, setSpellPickerOpen] = useState(false)
/** COUNT spells: per-relay breakdown + distinct total */
const [spellCount, setSpellCount] = useState<{ useEffect(() => {
loading: boolean if (spellProp && isFauxSpellName(spellProp)) {
rows: { url: string; count: number | null; error?: string }[] setSelectedFauxSpell(spellProp)
totalDistinct: number | null setSelectedSpell(null)
error: 'none' | 'login' | 'invalid' | 'failed' }
mayHitLimit: boolean }, [spellProp])
usedExplicitRelays: boolean
}>({ const [followingSubRequests, setFollowingSubRequests] = useState<TFeedSubRequest[]>([])
loading: false, const [followingFeedLoading, setFollowingFeedLoading] = useState(false)
rows: [],
totalDistinct: null, useEffect(() => {
error: 'none', if (selectedFauxSpell !== 'following' || !pubkey) {
mayHitLimit: false, setFollowingSubRequests([])
usedExplicitRelays: false setFollowingFeedLoading(false)
}) return
}
let cancelled = false
setFollowingFeedLoading(true)
void (async () => {
try {
const followings = await client.fetchFollowings(pubkey)
const req = await client.generateSubRequestsForPubkeys([pubkey, ...followings], pubkey)
if (!cancelled) setFollowingSubRequests(req)
} catch {
if (!cancelled) setFollowingSubRequests([])
} finally {
if (!cancelled) setFollowingFeedLoading(false)
}
})()
return () => {
cancelled = true
}
}, [selectedFauxSpell, pubkey])
const loadSpells = useCallback(async () => { const loadSpells = useCallback(async () => {
const [events, ids] = await Promise.all([ const [events, ids] = await Promise.all([
@ -327,164 +404,74 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage({ spell: spellProp }
client.fetchFollowings(pubkey).then(setContacts).catch(() => setContacts([])) client.fetchFollowings(pubkey).then(setContacts).catch(() => setContacts([]))
}, [pubkey]) }, [pubkey])
// Memoize subRequests to prevent NoteList from re-subscribing when array reference changes const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
// This ensures the array reference only changes when the actual content changes if (!selectedFauxSpell || selectedFauxSpell === 'following') return []
const subRequests = useMemo<TFeedSubRequest[]>(() => {
if (!selectedSpell) { if (selectedFauxSpell === 'notifications') {
return [] if (!pubkey) return []
const urls = notificationRelayUrls(relayList, favoriteRelays)
if (!urls.length) return []
return [{ urls, filter: buildNotificationFilter(pubkey, notificationType) }]
} }
if (spellIsCount(selectedSpell)) { if (selectedFauxSpell === 'discussions') {
return [] const urls = discussionRelayUrls(relayList, favoriteRelays, blockedRelays)
if (!urls.length) return []
return [{ urls, filter: buildDiscussionFilter() }]
} }
const relayListWrite = relayList?.write ?? [] if (selectedFauxSpell === 'media') {
const ctx = { const urls = fauxFavoriteRelayUrls(favoriteRelays, blockedRelays)
pubkey, if (!urls.length) return []
contacts return [{ urls, filter: buildMediaSpellFilter() }]
} }
const filter = spellEventToFilter(selectedSpell, ctx) if (selectedFauxSpell === 'calendar') {
if (!filter) { const urls = fauxFavoriteRelayUrls(favoriteRelays, blockedRelays)
return [] if (!urls.length) return []
} return [{ urls, filter: buildCalendarSpellFilter() }]
const relays = getRelaysForSpell(selectedSpell, { relayListWrite })
if (!relays.length) {
return []
} }
return [{ urls: relays, filter }] if (selectedFauxSpell === 'interests') {
}, [selectedSpell, pubkey, contacts, relayList?.write]) if (!pubkey || !interestListEvent) return []
const topics = interestListEvent.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!)
useEffect(() => { const urls = fauxFavoriteRelayUrls(favoriteRelays, blockedRelays)
if (!selectedSpell) { return buildInterestsSubRequests(urls, topics, PROFILE_FEED_KINDS)
setSpellCount({
loading: false,
rows: [],
totalDistinct: null,
error: 'none',
mayHitLimit: false,
usedExplicitRelays: false
})
return
} }
if (spellIsCount(selectedSpell)) { if (selectedFauxSpell === 'bookmarks') {
return if (!pubkey) return []
const urls = notificationRelayUrls(relayList, favoriteRelays)
return buildBookmarksSubRequests(bookmarkListEvent, urls)
} }
setSpellCount({ return []
loading: false, }, [
rows: [], selectedFauxSpell,
totalDistinct: null, pubkey,
error: 'none', notificationType,
mayHitLimit: false, relayList,
usedExplicitRelays: false favoriteRelays,
}) blockedRelays,
}, [selectedSpell]) interestListEvent,
bookmarkListEvent
])
useEffect(() => { const fauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!selectedSpell || !spellIsCount(selectedSpell)) { if (selectedFauxSpell === 'following') return followingSubRequests
return return syncFauxSubRequests
} }, [selectedFauxSpell, followingSubRequests, syncFauxSubRequests])
let cancelled = false
const spellSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!selectedSpell) return []
const relayListWrite = relayList?.write ?? [] const relayListWrite = relayList?.write ?? []
const ctx = { pubkey, contacts } const ctx = { pubkey, contacts }
const usedExplicitRelays = spellHasExplicitRelays(selectedSpell)
const needsLogin =
!pubkey &&
selectedSpell.tags.some(
(tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts'))
)
if (needsLogin) {
setSpellCount({
loading: false,
rows: [],
totalDistinct: null,
error: 'login',
mayHitLimit: false,
usedExplicitRelays
})
return
}
const filter = spellEventToFilter(selectedSpell, ctx) const filter = spellEventToFilter(selectedSpell, ctx)
if (!filter) { if (!filter) return []
setSpellCount({ const relays = getRelaysForSpell(selectedSpell, { relayListWrite })
loading: false, if (!relays.length) return []
rows: [], return [{ urls: relays, filter }]
totalDistinct: null,
error: 'invalid',
mayHitLimit: false,
usedExplicitRelays
})
return
}
const relays = getRelaysForSpell(selectedSpell, { relayListWrite }, { mergeDefaultReadRelays: false })
if (!relays.length) {
setSpellCount({
loading: false,
rows: [],
totalDistinct: null,
error: 'failed',
mayHitLimit: false,
usedExplicitRelays
})
return
}
setSpellCount({
loading: true,
rows: [],
totalDistinct: null,
error: 'none',
mayHitLimit: false,
usedExplicitRelays
})
;(async () => {
const rows: { url: string; count: number | null; error?: string }[] = []
const allIds = new Set<string>()
try {
for (const url of relays) {
if (cancelled) return
const { events, connectionError } = await client.fetchEventsFromSingleRelay(url, filter, {
globalTimeout: 28_000
})
if (cancelled) return
if (connectionError) {
rows.push({ url, count: null, error: connectionError })
} else {
const c = new Set(events.map((e) => e.id)).size
rows.push({ url, count: c })
events.forEach((e) => allIds.add(e.id))
}
}
if (cancelled) return
const lim = filter.limit
const totalDistinct = allIds.size
const mayHitLimit = typeof lim === 'number' && lim > 0 && totalDistinct >= lim
setSpellCount({
loading: false,
rows,
totalDistinct,
error: 'none',
mayHitLimit,
usedExplicitRelays
})
} catch {
if (!cancelled) {
setSpellCount({
loading: false,
rows,
totalDistinct: null,
error: 'failed',
mayHitLimit: false,
usedExplicitRelays
})
}
}
})()
return () => {
cancelled = true
}
}, [selectedSpell, pubkey, contacts, relayList?.write]) }, [selectedSpell, pubkey, contacts, relayList?.write])
const subRequests = useMemo<TFeedSubRequest[]>(() => {
if (selectedFauxSpell) return fauxSubRequests
return spellSubRequests
}, [selectedFauxSpell, fauxSubRequests, spellSubRequests])
const toggleFavorite = useCallback(async (spellId: string) => { const toggleFavorite = useCallback(async (spellId: string) => {
const ids = await indexedDb.getSpellFavoriteIds() const ids = await indexedDb.getSpellFavoriteIds()
const set = new Set(ids) const set = new Set(ids)
@ -561,39 +548,89 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage({ spell: spellProp }
}, [selectedSpell?.id]) }, [selectedSpell?.id])
const showKinds = useMemo(() => { const showKinds = useMemo(() => {
if (selectedFauxSpell === 'notifications') {
return notificationFilterKinds(notificationType)
}
if (selectedFauxSpell === 'discussions') {
return [ExtendedKind.DISCUSSION]
}
if (selectedFauxSpell === 'following') {
return kindFilterShowKinds
}
if (selectedFauxSpell === 'media') {
return [...MEDIA_SPELL_KINDS]
}
if (selectedFauxSpell === 'calendar') {
return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME]
}
if (selectedFauxSpell === 'interests') {
return PROFILE_FEED_KINDS
}
if (selectedFauxSpell === 'bookmarks') {
return PROFILE_FEED_KINDS
}
if (!selectedSpell) return [1] if (!selectedSpell) return [1]
const kinds = selectedSpell.tags const kinds = selectedSpell.tags
.filter((tag) => tag[0] === 'k') .filter((tag) => tag[0] === 'k')
.map((tag) => parseInt(tag[1], 10)) .map((tag) => parseInt(tag[1], 10))
.filter((n) => !Number.isNaN(n)) .filter((n) => !Number.isNaN(n))
return kinds.length ? kinds : [1] return kinds.length ? kinds : [1]
}, [selectedSpell?.id, showKindsTagKey]) }, [
selectedFauxSpell,
notificationType,
selectedSpell?.id,
showKindsTagKey,
kindFilterShowKinds
])
const spellMenuLabel = useCallback( const spellMenuLabel = useCallback(
(spell: Event) => (favoriteIds.has(spell.id) ? `${getSpellName(spell)}` : getSpellName(spell)), (spell: Event) => (favoriteIds.has(spell.id) ? `${getSpellName(spell)}` : getSpellName(spell)),
[favoriteIds] [favoriteIds]
) )
const pickSpell = useCallback((spell: Event | null) => { const pickSpell = useCallback(
(spell: Event | null) => {
setSelectedSpell(spell) setSelectedSpell(spell)
setSelectedFauxSpell(null) setSelectedFauxSpell(null)
setSpellPickerOpen(false) setSpellPickerOpen(false)
}, []) navigatePrimary('spells')
},
[navigatePrimary]
)
const clearSpellSelection = useCallback(() => { const clearSpellSelection = useCallback(() => {
setSelectedSpell(null) setSelectedSpell(null)
setSelectedFauxSpell(null) setSelectedFauxSpell(null)
setSpellPickerOpen(false) setSpellPickerOpen(false)
}, []) navigatePrimary('spells')
}, [navigatePrimary])
const pickFauxSpell = useCallback((name: FauxSpellName | null) => { const pickFauxSpell = useCallback(
(name: FauxSpellName | null) => {
setSelectedFauxSpell(name) setSelectedFauxSpell(name)
setSelectedSpell(null) setSelectedSpell(null)
setSpellPickerOpen(false) setSpellPickerOpen(false)
}, []) if (name) navigatePrimary('spells', { spell: name })
else navigatePrimary('spells')
},
[navigatePrimary]
)
const selectedSpellIsOwn = !!(pubkey && selectedSpell && selectedSpell.pubkey === pubkey) const selectedSpellIsOwn = !!(pubkey && selectedSpell && selectedSpell.pubkey === pubkey)
const fauxNoteListUseFilterAsIs = useMemo(() => {
if (!selectedFauxSpell) return true
return selectedFauxSpell !== 'following' && selectedFauxSpell !== 'bookmarks'
}, [selectedFauxSpell])
const fauxFeedEmptyMessage = useMemo(() => {
if (!selectedFauxSpell || fauxSubRequests.length > 0) return null
if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.')
if (selectedFauxSpell === 'bookmarks') return t('No bookmarked notes with id tags yet.')
if (selectedFauxSpell === 'following') return t('No follows or relays to load yet.')
return t('Nothing to load for this feed.')
}, [selectedFauxSpell, fauxSubRequests.length, t])
return ( return (
<PrimaryPageLayout <PrimaryPageLayout
ref={ref} ref={ref}
@ -626,10 +663,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage({ spell: spellProp }
variant="outline" variant="outline"
className="min-w-0 flex-1 justify-between font-normal sm:max-w-md" className="min-w-0 flex-1 justify-between font-normal sm:max-w-md"
title={ title={
selectedFauxSpell === 'notifications' selectedFauxSpell
? t('Notifications') ? t(fauxSpellLabelKey(selectedFauxSpell))
: selectedFauxSpell === 'discussions'
? t('Discussions')
: selectedSpell : selectedSpell
? spellMenuLabel(selectedSpell) ? spellMenuLabel(selectedSpell)
: undefined : undefined
@ -639,10 +674,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage({ spell: spellProp }
onClick={() => setSpellPickerOpen(true)} onClick={() => setSpellPickerOpen(true)}
> >
<span className="truncate"> <span className="truncate">
{selectedFauxSpell === 'notifications' {selectedFauxSpell
? t('Notifications') ? t(fauxSpellLabelKey(selectedFauxSpell))
: selectedFauxSpell === 'discussions'
? t('Discussions')
: selectedSpell : selectedSpell
? spellMenuLabel(selectedSpell) ? spellMenuLabel(selectedSpell)
: t('Select a spell…')} : t('Select a spell…')}
@ -664,44 +697,41 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage({ spell: spellProp }
role="listbox" role="listbox"
aria-label={t('Select a spell…')} aria-label={t('Select a spell…')}
> >
{pubkey && ( {FAUX_SPELL_ORDER.map((name) => {
if (
(name === 'notifications' ||
name === 'following' ||
name === 'bookmarks' ||
name === 'interests') &&
!pubkey
) {
return null
}
const Icon = FAUX_SPELL_ICON[name]
const selected = selectedFauxSpell === name
return (
<button <button
key={name}
type="button" type="button"
role="option" role="option"
aria-selected={selectedFauxSpell === 'notifications'} aria-selected={selected}
className={cn( className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors', 'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors',
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
selectedFauxSpell === 'notifications' && 'bg-accent/50' selected && 'bg-accent/50'
)} )}
onClick={() => pickFauxSpell(selectedFauxSpell === 'notifications' ? null : 'notifications')} onClick={() => pickFauxSpell(selected ? null : name)}
> >
<span className="flex size-4 shrink-0 items-center justify-center"> <span className="flex size-4 shrink-0 items-center justify-center">
{selectedFauxSpell === 'notifications' ? <Check className="size-4" aria-hidden /> : null} {selected ? <Check className="size-4" aria-hidden /> : null}
</span> </span>
<Bell className="size-4 shrink-0" /> <Icon className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left font-medium"> <span className="min-w-0 flex-1 truncate text-left font-medium">
{t('Notifications')} {t(fauxSpellLabelKey(name))}
</span>
</button>
)}
<button
type="button"
role="option"
aria-selected={selectedFauxSpell === 'discussions'}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors',
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
selectedFauxSpell === 'discussions' && 'bg-accent/50'
)}
onClick={() => pickFauxSpell(selectedFauxSpell === 'discussions' ? null : 'discussions')}
>
<span className="flex size-4 shrink-0 items-center justify-center">
{selectedFauxSpell === 'discussions' ? <Check className="size-4" aria-hidden /> : null}
</span> </span>
<MessageSquare className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left font-medium">{t('Discussions')}</span>
</button> </button>
)
})}
<button <button
type="button" type="button"
role="option" role="option"
@ -891,10 +921,27 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage({ spell: spellProp }
<p className="text-sm text-muted-foreground">{t('No spells yet. Create one with the button above.')}</p> <p className="text-sm text-muted-foreground">{t('No spells yet. Create one with the button above.')}</p>
)} )}
{/* Feed */} {/* Feed — faux spells and kind-777 spells all use NoteList */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col"> <div className="flex min-h-0 min-w-0 flex-1 flex-col">
{selectedFauxSpell === 'notifications' ? ( {selectedFauxSpell === 'notifications' && !pubkey ? (
<div className="py-8 text-center text-muted-foreground">
{t('Please log in to view notifications.')}
</div>
) : selectedFauxSpell === 'following' && !pubkey ? (
<div className="py-8 text-center text-muted-foreground">
{t('Please login to view following feed')}
</div>
) : selectedFauxSpell === 'bookmarks' && !pubkey ? (
<div className="py-8 text-center text-muted-foreground">
{t('Please login to view bookmarks')}
</div>
) : selectedFauxSpell === 'following' && followingFeedLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">{t('loading...')}</div>
) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div>
) : selectedFauxSpell && fauxSubRequests.length > 0 ? (
<> <>
{selectedFauxSpell === 'notifications' ? (
<div className="shrink-0 flex items-center justify-between gap-2 px-1 pb-2"> <div className="shrink-0 flex items-center justify-between gap-2 px-1 pb-2">
<Tabs <Tabs
value={notificationType} value={notificationType}
@ -905,110 +952,25 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage({ spell: spellProp }
{ value: 'zaps', label: t('Zaps') } { value: 'zaps', label: t('Zaps') }
]} ]}
onTabChange={(tab) => setNotificationType(tab as TNotificationType)} onTabChange={(tab) => setNotificationType(tab as TNotificationType)}
options={!supportTouch ? <RefreshButton onClick={() => notificationListRef.current?.refresh()} /> : null}
/> />
<HideUntrustedContentButton type="notifications" size="titlebar-icon" /> <HideUntrustedContentButton type="notifications" size="titlebar-icon" />
</div> </div>
<div className="min-h-0 min-w-0 flex-1">
<NotificationList
ref={notificationListRef}
notificationType={notificationType}
/>
</div>
</>
) : selectedFauxSpell === 'discussions' ? (
<div className="min-h-0 min-w-0 flex-1">
<DiscussionsPage embedded />
</div>
) : selectedSpell ? (
spellIsCount(selectedSpell) ? (
<div className="flex flex-col items-center justify-center gap-3 py-10 px-4">
{spellCount.error === 'login' ? (
<p className="text-center text-muted-foreground">
{t('Log in to run this spell (it uses $me or $contacts).')}
</p>
) : spellCount.error === 'invalid' ? (
<p className="text-center text-muted-foreground">
{t(
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add write relays in settings.'
)}
</p>
) : spellCount.error === 'failed' ? (
<p className="text-center text-muted-foreground">
{t('Spell count failed. Check relays or try again.')}
</p>
) : spellCount.loading ? (
<div className="flex w-full max-w-md flex-col items-center gap-3">
<Skeleton className="h-12 w-24" />
<Skeleton className="h-32 w-full max-w-lg" />
<p className="text-sm text-muted-foreground">{t('Counting matching events…')}</p>
</div>
) : (
<>
<div className="text-5xl font-semibold tabular-nums tracking-tight text-foreground">
{spellCount.totalDistinct ?? '—'}
</div>
<p className="max-w-md text-center text-sm text-muted-foreground">
{t('COUNT spell total distinct explanation')}
</p>
<div className="w-full max-w-3xl overflow-x-auto rounded-md border border-border">
<table className="w-full min-w-[20rem] border-collapse text-sm">
<thead>
<tr className="border-b border-border bg-muted/40 text-left text-muted-foreground">
<th className="px-3 py-2 font-medium">{t('Relay URL')}</th>
<th className="w-28 px-3 py-2 font-medium">{t('Count')}</th>
</tr>
</thead>
<tbody>
{spellCount.rows.map((r) => (
<tr key={r.url} className="border-b border-border/60 last:border-0">
<td className="break-all px-3 py-2 align-top font-mono text-xs">{r.url}</td>
<td className="px-3 py-2 align-top tabular-nums">
{r.error ? (
<span className="text-destructive">{r.error}</span>
) : (
(r.count ?? '—')
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{spellCount.usedExplicitRelays &&
spellCount.rows.some((r) => r.error) &&
!spellCount.loading ? (
<div className="flex max-w-md flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground">
{t('COUNT spell relay errors hint')}
</p>
<Button
variant="outline"
className="gap-2"
onClick={() => {
setSpellToEdit(selectedSpell)
setCreateOpen(true)
}}
>
<Wand2 className="size-4" />
{t('Edit spell relays')}
</Button>
</div>
) : null} ) : null}
{spellCount.mayHitLimit ? ( <div className="min-h-0 min-w-0 flex-1">
<p className="max-w-md text-center text-xs text-amber-600 dark:text-amber-500">
{t('COUNT spell may be capped by limit')}
</p>
) : null}
</>
)}
</div>
) : subRequests.length > 0 ? (
<NoteList <NoteList
subRequests={subRequests} subRequests={subRequests}
showKinds={showKinds} showKinds={showKinds}
useFilterAsIs useFilterAsIs={fauxNoteListUseFilterAsIs}
showKind1OPs={selectedFauxSpell === 'following' ? showKind1OPs : true}
showKind1Replies={selectedFauxSpell === 'following' ? showKind1Replies : true}
showKind1111={selectedFauxSpell === 'following' ? showKind1111 : true}
hideReplies={selectedFauxSpell === 'following' ? hideRepliesFollowing : false}
/> />
</div>
</>
) : selectedSpell ? (
subRequests.length > 0 ? (
<NoteList subRequests={subRequests} showKinds={showKinds} useFilterAsIs />
) : !pubkey && ) : !pubkey &&
selectedSpell.tags.some( selectedSpell.tags.some(
(tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts')) (tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts'))

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

@ -72,7 +72,17 @@ const HomePage = forwardRef(({ index }: { index?: number }, ref) => {
))} ))}
</div> </div>
<div className="flex mt-2 justify-center"> <div className="flex mt-2 justify-center">
<Button variant="ghost" onClick={() => navigateToPrimaryPage('explore')}> <Button
variant="ghost"
onClick={() => {
navigateToPrimaryPage('home')
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent('restorePageTab', { detail: { page: 'home', tab: 'explore' } })
)
})
}}
>
<div>{t('Explore more')}</div> <div>{t('Explore more')}</div>
<ArrowRight /> <ArrowRight />
</Button> </Button>

2
src/pages/secondary/RssFeedSettingsPage/index.tsx

@ -487,7 +487,7 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
/> />
</div> </div>
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
{t('Show or hide the RSS feed tab in the main feed')} {t('Show or hide the RSS page and sidebar entry')}
</div> </div>
</div> </div>

13
src/providers/FeedProvider.tsx

@ -234,13 +234,12 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
// Update relay URLs when favoriteRelays change and we're in all-favorites mode // Update relay URLs when favoriteRelays change and we're in all-favorites mode
useEffect(() => { useEffect(() => {
if (feedInfo.feedType === 'all-favorites') { if (feedInfo.feedType !== 'all-favorites') return
// Filter out blocked relays const visibleRelays = favoriteRelays.filter((relay) => !blockedRelays.includes(relay))
const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay)) const finalRelays = visibleRelays.length > 0 ? visibleRelays : DEFAULT_FAVORITE_RELAYS
logger.debug('Updating relay URLs for all-favorites:', visibleRelays) logger.debug('Updating relay URLs for all-favorites:', finalRelays)
setRelayUrls(visibleRelays) setRelayUrls(finalRelays)
} }, [feedInfo.feedType, favoriteRelays, blockedRelays])
}, [pubkey, isInitialized, favoriteRelays, blockedRelays, relaySets])
return ( return (
<FeedContext.Provider <FeedContext.Provider

5
src/routes.tsx

@ -29,6 +29,8 @@ const ROUTES = [
{ path: '/search/notes/:id', element: <NotePage /> }, { path: '/search/notes/:id', element: <NotePage /> },
{ path: '/profile/notes/:id', element: <NotePage /> }, { path: '/profile/notes/:id', element: <NotePage /> },
{ path: '/explore/notes/:id', element: <NotePage /> }, { path: '/explore/notes/:id', element: <NotePage /> },
{ path: '/home/notes/:id', element: <NotePage /> },
{ path: '/feed/notes/:id', element: <NotePage /> },
{ path: '/spells/notes/:id', element: <NotePage /> }, { path: '/spells/notes/:id', element: <NotePage /> },
{ path: '/users', element: <ProfileListPage /> }, { path: '/users', element: <ProfileListPage /> },
{ path: '/users/:id', element: <ProfilePage /> }, { path: '/users/:id', element: <ProfilePage /> },
@ -36,7 +38,8 @@ const ROUTES = [
{ path: '/users/:id/relays', element: <OthersRelaySettingsPage /> }, { path: '/users/:id/relays', element: <OthersRelaySettingsPage /> },
{ path: '/relays/:url', element: <RelayPage /> }, { path: '/relays/:url', element: <RelayPage /> },
{ path: '/relays/:url/reviews', element: <RelayReviewsPage /> }, { path: '/relays/:url/reviews', element: <RelayReviewsPage /> },
// Contextual relay route (only for explore page where you discover relays) // Contextual relay routes (home = explore; legacy /explore)
{ path: '/home/relays/:url', element: <RelayPage /> },
{ path: '/explore/relays/:url', element: <RelayPage /> }, { path: '/explore/relays/:url', element: <RelayPage /> },
{ path: '/search', element: <SearchPage /> }, { path: '/search', element: <SearchPage /> },
{ path: '/settings', element: <SettingsPage /> }, { path: '/settings', element: <SettingsPage /> },

Loading…
Cancel
Save