Browse Source

improve refresh

imwald
Silberengel 1 month ago
parent
commit
e35e7eb2e4
  1. 50
      src/PageManager.tsx
  2. 46
      src/components/BookmarkList/index.tsx
  3. 14
      src/components/NormalFeed/index.tsx
  4. 4
      src/components/Profile/ProfileFeedWithPins.tsx
  5. 16
      src/components/Profile/index.tsx
  6. 24
      src/components/Relay/index.tsx
  7. 55
      src/components/ui/RetroRefreshButton.tsx
  8. 33
      src/hooks/useFetchEvent.tsx
  9. 4
      src/hooks/useFetchFollowings.tsx
  10. 44
      src/pages/primary/ExplorePage/index.tsx
  11. 38
      src/pages/primary/MePage/index.tsx
  12. 21
      src/pages/primary/NoteListPage/FollowingFeed.tsx
  13. 25
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  14. 34
      src/pages/primary/NoteListPage/index.tsx
  15. 54
      src/pages/primary/ProfilePage/index.tsx
  16. 39
      src/pages/primary/RelayPage/index.tsx
  17. 23
      src/pages/primary/RssPage/index.tsx
  18. 38
      src/pages/primary/SearchPage/index.tsx
  19. 34
      src/pages/primary/SettingsPrimaryPage/index.tsx
  20. 32
      src/pages/primary/SpellsPage/index.tsx
  21. 20
      src/pages/secondary/CacheSettingsPage/index.tsx
  22. 20
      src/pages/secondary/FollowingListPage/index.tsx
  23. 25
      src/pages/secondary/GeneralSettingsPage/index.tsx
  24. 22
      src/pages/secondary/MuteListPage/index.tsx
  25. 11
      src/pages/secondary/NotFoundPage/index.tsx
  26. 40
      src/pages/secondary/NoteListPage/index.tsx
  27. 57
      src/pages/secondary/NotePage/index.tsx
  28. 20
      src/pages/secondary/OthersRelaySettingsPage/index.tsx
  29. 25
      src/pages/secondary/PostSettingsPage/index.tsx
  30. 17
      src/pages/secondary/ProfileListPage/index.tsx
  31. 27
      src/pages/secondary/ProfilePage/index.tsx
  32. 28
      src/pages/secondary/RelayPage/index.tsx
  33. 27
      src/pages/secondary/RelayReviewsPage/index.tsx
  34. 25
      src/pages/secondary/RelaySettingsPage/index.tsx
  35. 95
      src/pages/secondary/RssArticlePage/index.tsx
  36. 64
      src/pages/secondary/RssFeedSettingsPage/index.tsx
  37. 40
      src/pages/secondary/SearchPage/index.tsx
  38. 27
      src/pages/secondary/SettingsPage/index.tsx
  39. 25
      src/pages/secondary/TranslationPage/index.tsx
  40. 25
      src/pages/secondary/WalletPage/index.tsx
  41. 6
      src/types/index.d.ts

50
src/PageManager.tsx

@ -1,3 +1,4 @@
import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -204,6 +205,9 @@ const PrimaryNoteViewContext = createContext<{
getNavigationCounter: () => number getNavigationCounter: () => number
/** Top URL in the secondary stack (right panel), or undefined if empty. Used so settings sub-pages open in the panel instead of behind it. */ /** Top URL in the secondary stack (right panel), or undefined if empty. Used so settings sub-pages open in the panel instead of behind it. */
getTopSecondaryUrl: () => string | undefined getTopSecondaryUrl: () => string | undefined
/** Primary overlay (mobile / narrow): child calls this to expose refresh for the chrome bar. */
registerPrimaryPanelRefresh: (fn: (() => void) | null) => void
triggerPrimaryPanelRefresh: () => void
} | undefined>(undefined) } | undefined>(undefined)
const NoteDrawerContext = createContext<{ const NoteDrawerContext = createContext<{
@ -599,13 +603,15 @@ function MainContentArea({
currentPrimaryPage, currentPrimaryPage,
primaryNoteView, primaryNoteView,
primaryViewType, primaryViewType,
goBack goBack,
onPrimaryPanelRefresh
}: { }: {
primaryPages: { name: TPrimaryPageName; element: ReactNode; props?: any }[] primaryPages: { name: TPrimaryPageName; element: ReactNode; props?: any }[]
currentPrimaryPage: TPrimaryPageName currentPrimaryPage: TPrimaryPageName
primaryNoteView: ReactNode | null primaryNoteView: ReactNode | null
primaryViewType: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null primaryViewType: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null
goBack: () => void goBack: () => void
onPrimaryPanelRefresh: () => void
}) { }) {
const [, forceUpdate] = useState(0) const [, forceUpdate] = useState(0)
@ -658,7 +664,9 @@ function MainContentArea({
{getPageTitle(primaryViewType, window.location.pathname)} {getPageTitle(primaryViewType, window.location.pathname)}
</div> </div>
</div> </div>
<div className="flex-1 w-0"></div> <div className="flex flex-1 w-0 justify-end pr-1">
<RefreshButton onClick={onPrimaryPanelRefresh} />
</div>
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{primaryNoteView} {primaryNoteView}
@ -717,6 +725,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const [drawerNoteId, setDrawerNoteId] = useState<string | null>(null) const [drawerNoteId, setDrawerNoteId] = useState<string | null>(null)
const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode()) const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode())
const navigationCounterRef = useRef(0) const navigationCounterRef = useRef(0)
const primaryPanelRefreshRef = useRef<(() => void) | null>(null)
const registerPrimaryPanelRefresh = useCallback((fn: (() => void) | null) => {
primaryPanelRefreshRef.current = fn
}, [])
const triggerPrimaryPanelRefresh = useCallback(() => {
primaryPanelRefreshRef.current?.()
}, [])
const savedFeedStateRef = useRef<Map<TPrimaryPageName, { tab?: string }>>(new Map()) const savedFeedStateRef = useRef<Map<TPrimaryPageName, { tab?: string }>>(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 savedPrimaryPagePropsRef = useRef<object | undefined>(undefined) const savedPrimaryPagePropsRef = useRef<object | undefined>(undefined)
@ -1571,7 +1586,17 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
> >
<CurrentRelaysProvider> <CurrentRelaysProvider>
<NotificationProvider> <NotificationProvider>
<PrimaryNoteViewContext.Provider value={{ setPrimaryNoteView, primaryViewType, getNavigationCounter: () => navigationCounterRef.current, getTopSecondaryUrl: () => secondaryStack.length > 0 ? secondaryStack[secondaryStack.length - 1].url : undefined }}> <PrimaryNoteViewContext.Provider
value={{
setPrimaryNoteView,
primaryViewType,
getNavigationCounter: () => navigationCounterRef.current,
getTopSecondaryUrl: () =>
secondaryStack.length > 0 ? secondaryStack[secondaryStack.length - 1].url : undefined,
registerPrimaryPanelRefresh,
triggerPrimaryPanelRefresh
}}
>
<NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId }}> <NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId }}>
{primaryNoteView ? ( {primaryNoteView ? (
// Show primary note view with back button on mobile // Show primary note view with back button on mobile
@ -1582,9 +1607,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</span> </span>
</div> </div>
<div className="flex gap-1 p-1 items-center justify-between font-semibold border-b"> <div className="flex gap-1 p-1 items-center justify-between font-semibold border-b">
<div className="flex items-center flex-1 w-0"> <div className="flex min-w-0 flex-1 items-center">
<Button <Button
className="flex gap-1 items-center w-fit max-w-full justify-start pl-2 pr-3" className="flex min-w-0 max-w-full gap-1 justify-start pl-2 pr-3"
variant="ghost" variant="ghost"
size="titlebar-icon" size="titlebar-icon"
title="Back to feed" title="Back to feed"
@ -1600,6 +1625,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</div> </div>
</Button> </Button>
</div> </div>
<RefreshButton onClick={triggerPrimaryPanelRefresh} />
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{primaryNoteView} {primaryNoteView}
@ -1700,7 +1726,17 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
> >
<CurrentRelaysProvider> <CurrentRelaysProvider>
<NotificationProvider> <NotificationProvider>
<PrimaryNoteViewContext.Provider value={{ setPrimaryNoteView, primaryViewType, getNavigationCounter: () => navigationCounterRef.current, getTopSecondaryUrl: () => secondaryStack.length > 0 ? secondaryStack[secondaryStack.length - 1].url : undefined }}> <PrimaryNoteViewContext.Provider
value={{
setPrimaryNoteView,
primaryViewType,
getNavigationCounter: () => navigationCounterRef.current,
getTopSecondaryUrl: () =>
secondaryStack.length > 0 ? secondaryStack[secondaryStack.length - 1].url : undefined,
registerPrimaryPanelRefresh,
triggerPrimaryPanelRefresh
}}
>
<NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId }}> <NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId }}>
<div className="flex flex-col items-center bg-surface-background"> <div className="flex flex-col items-center bg-surface-background">
<div <div
@ -1725,6 +1761,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
primaryNoteView={primaryNoteView} primaryNoteView={primaryNoteView}
primaryViewType={primaryViewType} primaryViewType={primaryViewType}
goBack={goBack} goBack={goBack}
onPrimaryPanelRefresh={triggerPrimaryPanelRefresh}
/> />
</div> </div>
{/* Right: secondary stack — max width so left pane keeps space on small desktops */} {/* Right: secondary stack — max width so left pane keeps space on small desktops */}
@ -1763,6 +1800,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
primaryNoteView={primaryNoteView} primaryNoteView={primaryNoteView}
primaryViewType={primaryViewType} primaryViewType={primaryViewType}
goBack={goBack} goBack={goBack}
onPrimaryPanelRefresh={triggerPrimaryPanelRefresh}
/> />
</div> </div>
) )

46
src/components/BookmarkList/index.tsx

@ -1,15 +1,20 @@
import { useFetchEvent } from '@/hooks' import { useFetchEvent } from '@/hooks'
import { PROFILE_FETCH_RELAY_URLS } from '@/constants'
import { getLatestEvent } from '@/lib/event'
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag' import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
import { normalizeUrl } from '@/lib/url'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useEffect, useMemo, useRef, useState } from 'react' import { queryService } from '@/services/client.service'
import { kinds } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const SHOW_COUNT = 10 const SHOW_COUNT = 10
export default function BookmarkList() { const BookmarkList = forwardRef(function BookmarkList(_, ref) {
const { t } = useTranslation() const { t } = useTranslation()
const { bookmarkListEvent } = useNostr() const { bookmarkListEvent, pubkey, relayList, updateBookmarkListEvent } = useNostr()
const eventIds = useMemo(() => { const eventIds = useMemo(() => {
if (!bookmarkListEvent) return [] if (!bookmarkListEvent) return []
@ -28,6 +33,36 @@ export default function BookmarkList() {
const [showCount, setShowCount] = useState(SHOW_COUNT) const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null) const bottomRef = useRef<HTMLDivElement | null>(null)
useImperativeHandle(
ref,
() => ({
refresh: async () => {
if (!pubkey) return
const urls = Array.from(
new Set(
[
...PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u),
...(relayList?.write ?? []).map((u) => normalizeUrl(u) || u)
].filter(Boolean)
)
).slice(0, 12)
if (urls.length === 0) return
try {
const events = await queryService.fetchEvents(urls, {
kinds: [kinds.BookmarkList],
authors: [pubkey],
limit: 5
})
const latest = getLatestEvent(events)
if (latest) await updateBookmarkListEvent(latest)
} catch {
/* ignore */
}
}
}),
[pubkey, relayList, updateBookmarkListEvent]
)
useEffect(() => { useEffect(() => {
const options = { const options = {
root: null, root: null,
@ -85,7 +120,10 @@ export default function BookmarkList() {
)} )}
</div> </div>
) )
} })
BookmarkList.displayName = 'BookmarkList'
export default BookmarkList
function BookmarkedNote({ eventId }: { eventId: string }) { function BookmarkedNote({ eventId }: { eventId: string }) {
const { event, isFetching } = useFetchEvent(eventId) const { event, isFetching } = useFetchEvent(eventId)

14
src/components/NormalFeed/index.tsx

@ -6,7 +6,6 @@ import storage from '@/services/local-storage.service'
import { TFeedSubRequest, TNoteListMode } from '@/types' import { TFeedSubRequest, TNoteListMode } from '@/types'
import { forwardRef, useLayoutEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useLayoutEffect, useMemo, useRef, useState } from 'react'
import KindFilter from '../KindFilter' import KindFilter from '../KindFilter'
import { RefreshButton } from '../RefreshButton'
const NormalFeed = forwardRef<TNoteListRef, { const NormalFeed = forwardRef<TNoteListRef, {
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
@ -71,18 +70,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
value={listMode} value={listMode}
tabs={tabs} tabs={tabs}
onTabChange={(tab) => handleListModeChange(tab)} onTabChange={(tab) => handleListModeChange(tab)}
options={ options={<KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} />}
<>
<RefreshButton
onClick={() => {
if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.refresh()
}
}}
/>
<KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} />
</>
}
/> />
) )

4
src/components/Profile/ProfileFeedWithPins.tsx

@ -1,6 +1,5 @@
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 { Skeleton } from '@/components/ui/skeleton' 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 { isReplyNoteEvent } from '@/lib/event'
@ -159,7 +158,6 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
placeholder={t('Search posts...')} placeholder={t('Search posts...')}
className="w-64 max-w-full" className="w-64 max-w-full"
/> />
<RetroRefreshButton onClick={refreshAll} size="sm" className="flex-shrink-0" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
@ -179,7 +177,6 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
placeholder={t('Search posts...')} placeholder={t('Search posts...')}
className="w-64 max-w-full" className="w-64 max-w-full"
/> />
<RetroRefreshButton onClick={refreshAll} size="sm" className="flex-shrink-0" />
</div> </div>
<div className="flex justify-center py-8 text-sm text-muted-foreground"> <div className="flex justify-center py-8 text-sm text-muted-foreground">
{searchQuery.trim() ? t('No posts match your search') : t('No posts found')} {searchQuery.trim() ? t('No posts match your search') : t('No posts found')}
@ -196,7 +193,6 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
placeholder={t('Search posts...')} placeholder={t('Search posts...')}
className="w-64 max-w-full" className="w-64 max-w-full"
/> />
<RetroRefreshButton onClick={refreshAll} size="sm" className="flex-shrink-0" />
</div> </div>
{isRefreshing && ( {isRefreshing && (
<div className="px-4 py-2 text-center text-sm text-green-500">🔄 {t('Refreshing posts...')}</div> <div className="px-4 py-2 text-center text-sm text-green-500">🔄 {t('Refreshing posts...')}</div>

16
src/components/Profile/index.tsx

@ -28,7 +28,7 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code, Gift, Link } from 'lucide-react' import { Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code, Gift, Link } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState, type Ref } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -153,10 +153,20 @@ function mergePaymentMethods(
return out return out
} }
export default function Profile({ id }: { id?: string }) { export default function Profile({
id,
feedRef
}: {
id?: string
/** When set, exposes {@link ProfileFeedWithPins} `refresh` for titlebars / parent pages. */
feedRef?: Ref<{ refresh: () => void }>
}) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { navigate: navigatePrimary } = usePrimaryPage() const { navigate: navigatePrimary } = usePrimaryPage()
const internalFeedRef = useRef<{ refresh: () => void }>(null)
const profileFeedRef = feedRef ?? internalFeedRef
const { profile, isFetching } = useFetchProfile(id) const { profile, isFetching } = useFetchProfile(id)
const { pubkey: accountPubkey } = useNostr() const { pubkey: accountPubkey } = useNostr()
const [paymentInfo, setPaymentInfo] = useState<ReturnType<typeof getPaymentInfoFromEvent> | null>(null) const [paymentInfo, setPaymentInfo] = useState<ReturnType<typeof getPaymentInfoFromEvent> | null>(null)
@ -562,7 +572,7 @@ export default function Profile({ id }: { id?: string }) {
</div> </div>
</div> </div>
</div> </div>
<ProfileFeedWithPins pubkey={pubkey} /> <ProfileFeedWithPins ref={profileFeedRef} pubkey={pubkey} />
{openPublicMessageTo && ( {openPublicMessageTo && (
<PostEditor <PostEditor
open={!!openPublicMessageTo} open={!!openPublicMessageTo}

24
src/components/Relay/index.tsx

@ -1,22 +1,26 @@
import NormalFeed from '@/components/NormalFeed' import NormalFeed from '@/components/NormalFeed'
import type { TNoteListRef } from '@/components/NoteList'
import RelayInfo from '@/components/RelayInfo' import RelayInfo from '@/components/RelayInfo'
import SearchInput from '@/components/SearchInput' import SearchInput from '@/components/SearchInput'
import { useFetchRelayInfo } from '@/hooks' import { useFetchRelayInfo } from '@/hooks'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { TNoteListRef } from '@/components/NoteList'
import NotFound from '../NotFound' import NotFound from '../NotFound'
export default function Relay({ url, className }: { url?: string; className?: string }) { const Relay = forwardRef<TNoteListRef, { url?: string; className?: string }>(function Relay(
{ url, className },
ref
) {
const { t } = useTranslation() const { t } = useTranslation()
const { addRelayUrls, removeRelayUrls } = useCurrentRelays() const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url]) const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url])
const { relayInfo } = useFetchRelayInfo(normalizedUrl) const { relayInfo } = useFetchRelayInfo(normalizedUrl)
const [searchInput, setSearchInput] = useState('') const [searchInput, setSearchInput] = useState('')
const [debouncedInput, setDebouncedInput] = useState(searchInput) const [debouncedInput, setDebouncedInput] = useState(searchInput)
const noteListRef = useRef<TNoteListRef>(null) const internalNoteListRef = useRef<TNoteListRef>(null)
const noteListRef = ref ?? internalNoteListRef
useEffect(() => { useEffect(() => {
if (normalizedUrl) { if (normalizedUrl) {
@ -44,8 +48,9 @@ export default function Relay({ url, className }: { url?: string; className?: st
const handleRelayRefresh = (event: CustomEvent) => { const handleRelayRefresh = (event: CustomEvent) => {
const { relayUrl } = event.detail const { relayUrl } = event.detail
if (normalizeUrl(relayUrl) === normalizedUrl) { if (normalizeUrl(relayUrl) === normalizedUrl) {
// Trigger a refresh of the note list if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.refresh() noteListRef.current?.refresh()
}
} }
} }
@ -54,7 +59,7 @@ export default function Relay({ url, className }: { url?: string; className?: st
return () => { return () => {
window.removeEventListener('relay-refresh-needed', handleRelayRefresh as EventListener) window.removeEventListener('relay-refresh-needed', handleRelayRefresh as EventListener)
} }
}, [normalizedUrl]) }, [normalizedUrl, noteListRef])
if (!normalizedUrl) { if (!normalizedUrl) {
return <NotFound /> return <NotFound />
@ -80,4 +85,7 @@ export default function Relay({ url, className }: { url?: string; className?: st
/> />
</div> </div>
) )
} })
Relay.displayName = 'Relay'
export default Relay

55
src/components/ui/RetroRefreshButton.tsx

@ -1,55 +0,0 @@
import { Button } from '@/components/ui/button'
import { RefreshCw } from 'lucide-react'
import { cn } from '@/lib/utils'
interface RetroRefreshButtonProps {
onClick: () => void
isLoading?: boolean
className?: string
size?: 'sm' | 'md' | 'lg'
}
export default function RetroRefreshButton({
onClick,
isLoading = false,
className,
size = 'md'
}: RetroRefreshButtonProps) {
const sizeClasses = {
sm: 'h-8 w-8 p-1',
md: 'h-10 w-10 p-2',
lg: 'h-12 w-12 p-3'
}
const iconSizes = {
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-6 w-6'
}
return (
<Button
onClick={onClick}
disabled={isLoading}
className={cn(
'bg-background text-foreground border-2 border-green-500 hover:bg-muted hover:border-green-400',
'dark:bg-background dark:text-foreground dark:border-green-500 dark:hover:bg-muted dark:hover:border-green-400',
'transition-all duration-200 ease-in-out',
'shadow-lg hover:shadow-xl',
'rounded-lg',
'disabled:opacity-50 disabled:cursor-not-allowed',
sizeClasses[size],
className
)}
variant="outline"
>
<RefreshCw
className={cn(
'text-green-500 transition-transform duration-200',
isLoading && 'animate-spin',
iconSizes[size]
)}
/>
</Button>
)
}

33
src/hooks/useFetchEvent.tsx

@ -3,7 +3,7 @@ import { useReply } from '@/providers/ReplyProvider'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import { navigationEventStore } from '@/services/navigation-event-store' import { navigationEventStore } from '@/services/navigation-event-store'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
export function useFetchEvent(eventId?: string, initialEvent?: Event) { export function useFetchEvent(eventId?: string, initialEvent?: Event) {
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
@ -11,6 +11,11 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) {
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const [event, setEvent] = useState<Event | undefined>(initialEvent) const [event, setEvent] = useState<Event | undefined>(initialEvent)
const [isFetching, setIsFetching] = useState(!initialEvent) const [isFetching, setIsFetching] = useState(!initialEvent)
const [refetchToken, setRefetchToken] = useState(0)
const refetch = useCallback(() => {
setRefetchToken((n) => n + 1)
}, [])
useEffect(() => { useEffect(() => {
if (!eventId) { if (!eventId) {
@ -19,8 +24,14 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) {
return return
} }
const skipShortcuts = refetchToken > 0
// If we have an initial event that matches the eventId, use it and skip fetching // If we have an initial event that matches the eventId, use it and skip fetching
if (initialEvent && (initialEvent.id === eventId || eventId.includes(initialEvent.id))) { if (
!skipShortcuts &&
initialEvent &&
(initialEvent.id === eventId || eventId.includes(initialEvent.id))
) {
if (!isEventDeleted(initialEvent)) { if (!isEventDeleted(initialEvent)) {
setEvent(initialEvent) setEvent(initialEvent)
addReplies([initialEvent]) addReplies([initialEvent])
@ -30,12 +41,14 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) {
} }
// Check navigation event store first (events passed through navigation) // Check navigation event store first (events passed through navigation)
const navigationEvent = navigationEventStore.getEvent(eventId) if (!skipShortcuts) {
if (navigationEvent && !isEventDeleted(navigationEvent)) { const navigationEvent = navigationEventStore.getEvent(eventId)
setEvent(navigationEvent) if (navigationEvent && !isEventDeleted(navigationEvent)) {
addReplies([navigationEvent]) setEvent(navigationEvent)
setIsFetching(false) addReplies([navigationEvent])
return setIsFetching(false)
return
}
} }
setIsFetching(true) setIsFetching(true)
@ -56,7 +69,7 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) {
} }
fetchEvent() fetchEvent()
}, [eventId, initialEvent, isEventDeleted, addReplies]) }, [eventId, initialEvent, isEventDeleted, addReplies, refetchToken])
useEffect(() => { useEffect(() => {
if (event && isEventDeleted(event)) { if (event && isEventDeleted(event)) {
@ -64,5 +77,5 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) {
} }
}, [isEventDeleted, event]) }, [isEventDeleted, event])
return { isFetching, error, event } return { isFetching, error, event, refetch }
} }

4
src/hooks/useFetchFollowings.tsx

@ -4,7 +4,7 @@ import { kinds } from 'nostr-tools'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
export function useFetchFollowings(pubkey?: string | null) { export function useFetchFollowings(pubkey?: string | null, refreshNonce = 0) {
const [followListEvent, setFollowListEvent] = useState<Event | null>(null) const [followListEvent, setFollowListEvent] = useState<Event | null>(null)
const [followings, setFollowings] = useState<string[]>([]) const [followings, setFollowings] = useState<string[]>([])
const [isFetching, setIsFetching] = useState(true) const [isFetching, setIsFetching] = useState(true)
@ -26,7 +26,7 @@ export function useFetchFollowings(pubkey?: string | null) {
} }
init() init()
}, [pubkey]) }, [pubkey, refreshNonce])
return { followings, followListEvent, isFetching } return { followings, followListEvent, isFetching }
} }

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

@ -9,11 +9,13 @@ import { Input } from '@/components/ui/input'
import { toRelay } from '@/lib/link' import { toRelay } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { isWebsocketUrl, normalizeUrl, simplifyUrl } from '@/lib/url' import { isWebsocketUrl, normalizeUrl, simplifyUrl } from '@/lib/url'
import { RefreshButton } from '@/components/RefreshButton'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { useSmartRelayNavigation } from '@/PageManager' import { useSmartRelayNavigation } from '@/PageManager'
import nip66Service from '@/services/nip66.service' import nip66Service from '@/services/nip66.service'
import { TPageRef } from '@/types'
import { ArrowRight, Compass, Plus } from 'lucide-react' import { ArrowRight, Compass, Plus } from 'lucide-react'
import { forwardRef, FormEvent, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, FormEvent, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -65,9 +67,22 @@ function normalizeHomeTab(restored: string): TExploreTabs {
return 'explore' return 'explore'
} }
const ExplorePage = forwardRef((_, ref) => { const ExplorePage = forwardRef<TPageRef>((_, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const [tab, setTab] = useState<TExploreTabs>('explore') const [tab, setTab] = useState<TExploreTabs>('explore')
const layoutRef = useRef<TPageRef>(null)
const [contentRefreshKey, setContentRefreshKey] = useState(0)
const bumpExploreContent = () => setContentRefreshKey((k) => k + 1)
useImperativeHandle(
ref,
() => ({
scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior),
refresh: bumpExploreContent
}),
[]
)
// Listen for tab restoration from PageManager // Listen for tab restoration from PageManager
useEffect(() => { useEffect(() => {
@ -82,9 +97,9 @@ const ExplorePage = forwardRef((_, ref) => {
return ( return (
<PrimaryPageLayout <PrimaryPageLayout
ref={ref} ref={layoutRef}
pageName="explore" pageName="explore"
titlebar={<ExplorePageTitlebar />} titlebar={<ExplorePageTitlebar onRefresh={bumpExploreContent} />}
subHeader={ subHeader={
<Tabs <Tabs
value={tab} value={tab}
@ -110,14 +125,22 @@ const ExplorePage = forwardRef((_, ref) => {
<VersionUpdateBanner /> <VersionUpdateBanner />
</div> </div>
{tab === 'explore' && ( {tab === 'explore' && (
<> <div key={contentRefreshKey} className="min-w-0">
<ExploreFavoriteRelays /> <ExploreFavoriteRelays />
<ExploreRelaySearchSection /> <ExploreRelaySearchSection />
<Explore /> <Explore />
</> </div>
)}
{tab === 'reviews' && (
<div key={contentRefreshKey} className="min-w-0">
<ExploreRelayReviews />
</div>
)}
{tab === 'following' && (
<div key={contentRefreshKey} className="min-w-0">
<FollowingFavoriteRelayList />
</div>
)} )}
{tab === 'reviews' && <ExploreRelayReviews />}
{tab === 'following' && <FollowingFavoriteRelayList />}
</div> </div>
</PrimaryPageLayout> </PrimaryPageLayout>
) )
@ -125,7 +148,7 @@ const ExplorePage = forwardRef((_, ref) => {
ExplorePage.displayName = 'ExplorePage' ExplorePage.displayName = 'ExplorePage'
export default ExplorePage export default ExplorePage
function ExplorePageTitlebar() { function ExplorePageTitlebar({ onRefresh }: { onRefresh: () => void }) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
@ -134,6 +157,8 @@ function ExplorePageTitlebar() {
<Compass className="size-5 shrink-0" /> <Compass className="size-5 shrink-0" />
<div className="text-lg font-semibold">{t('Explore')}</div> <div className="text-lg font-semibold">{t('Explore')}</div>
</div> </div>
<div className="flex shrink-0 items-center gap-1">
<RefreshButton onClick={onRefresh} />
<Button <Button
variant="ghost" variant="ghost"
size="titlebar-icon" size="titlebar-icon"
@ -148,6 +173,7 @@ function ExplorePageTitlebar() {
<Plus size={16} /> <Plus size={16} />
{t('Submit Relay')} {t('Submit Relay')}
</Button> </Button>
</div>
</div> </div>
) )
} }

38
src/pages/primary/MePage/index.tsx

@ -6,7 +6,8 @@ import NpubQrCode from '@/components/NpubQrCode'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { SimpleUserAvatar } from '@/components/UserAvatar' import { SimpleUserAvatar } from '@/components/UserAvatar'
import { SimpleUsername } from '@/components/Username' import { SimpleUsername } from '@/components/Username'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import { RefreshButton } from '@/components/RefreshButton'
import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout'
import { toProfile, toRelaySettings, toWallet } from '@/lib/link' import { toProfile, toRelaySettings, toWallet } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
@ -19,25 +20,39 @@ import {
UserRound, UserRound,
Wallet Wallet
} from 'lucide-react' } from 'lucide-react'
import { forwardRef, HTMLProps, useState, type KeyboardEvent, type MouseEvent } from 'react' import { TPageRef } from '@/types'
import { forwardRef, HTMLProps, useImperativeHandle, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const MePage = forwardRef((_, ref) => { const MePage = forwardRef<TPageRef>((_, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const [loginDialogOpen, setLoginDialogOpen] = useState(false) const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
const layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const [contentKey, setContentKey] = useState(0)
const bumpMe = () => setContentKey((k) => k + 1)
useImperativeHandle(
ref,
() => ({
scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior),
refresh: bumpMe
}),
[]
)
if (!pubkey) { if (!pubkey) {
return ( return (
<PrimaryPageLayout <PrimaryPageLayout
ref={ref} ref={layoutRef}
pageName="me" pageName="me"
titlebar={<MePageTitlebar />} titlebar={<MePageTitlebar onRefresh={bumpMe} />}
hideTitlebarBottomBorder hideTitlebarBottomBorder
> >
<div className="min-w-0 pt-2 flex flex-col p-4 gap-4 overflow-auto"> <div key={contentKey} className="min-w-0 pt-2 flex flex-col p-4 gap-4 overflow-auto">
<AccountManager /> <AccountManager />
</div> </div>
</PrimaryPageLayout> </PrimaryPageLayout>
@ -46,12 +61,12 @@ const MePage = forwardRef((_, ref) => {
return ( return (
<PrimaryPageLayout <PrimaryPageLayout
ref={ref} ref={layoutRef}
pageName="me" pageName="me"
titlebar={<MePageTitlebar />} titlebar={<MePageTitlebar onRefresh={bumpMe} />}
hideTitlebarBottomBorder hideTitlebarBottomBorder
> >
<div className="min-w-0 pt-2"> <div key={contentKey} className="min-w-0 pt-2">
<div className="flex gap-4 items-center p-4"> <div className="flex gap-4 items-center p-4">
<SimpleUserAvatar userId={pubkey} size="big" /> <SimpleUserAvatar userId={pubkey} size="big" />
<div className="space-y-1 flex-1 w-0"> <div className="space-y-1 flex-1 w-0">
@ -100,11 +115,12 @@ const MePage = forwardRef((_, ref) => {
MePage.displayName = 'MePage' MePage.displayName = 'MePage'
export default MePage export default MePage
function MePageTitlebar() { function MePageTitlebar({ onRefresh }: { onRefresh: () => void }) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div className="flex h-full items-center pl-3"> <div className="flex h-full w-full items-center justify-between gap-2 pl-3 pr-1">
<div className="text-lg font-semibold">{t('YouTabName')}</div> <div className="text-lg font-semibold">{t('YouTabName')}</div>
<RefreshButton onClick={onRefresh} />
</div> </div>
) )
} }

21
src/pages/primary/NoteListPage/FollowingFeed.tsx

@ -1,16 +1,18 @@
import NormalFeed from '@/components/NormalFeed' import NormalFeed from '@/components/NormalFeed'
import type { TNoteListRef } from '@/components/NoteList'
import { useFeed } from '@/providers/FeedProvider' import { useFeed } from '@/providers/FeedProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TFeedSubRequest } from '@/types' import { TFeedSubRequest } from '@/types'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useEffect, useState } from 'react' import { forwardRef, useEffect, useState } from 'react'
export default function FollowingFeed({ const FollowingFeed = forwardRef<
setSubHeader TNoteListRef,
}: { {
setSubHeader?: (node: ReactNode) => void setSubHeader?: (node: ReactNode) => void
}) { }
>(function FollowingFeed({ setSubHeader }, ref) {
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { feedInfo } = useFeed() const { feedInfo } = useFeed()
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([]) const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
@ -29,5 +31,8 @@ export default function FollowingFeed({
init() init()
}, [feedInfo.feedType, pubkey]) }, [feedInfo.feedType, pubkey])
return <NormalFeed subRequests={subRequests} isMainFeed setSubHeader={setSubHeader} /> return <NormalFeed ref={ref} subRequests={subRequests} isMainFeed setSubHeader={setSubHeader} />
} })
FollowingFeed.displayName = 'FollowingFeed'
export default FollowingFeed

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

@ -1,19 +1,20 @@
import NormalFeed from '@/components/NormalFeed' import NormalFeed from '@/components/NormalFeed'
import type { TNoteListRef } from '@/components/NoteList'
import { checkAlgoRelay } from '@/lib/relay' import { checkAlgoRelay } from '@/lib/relay'
import { useFeed } from '@/providers/FeedProvider' import { useFeed } from '@/providers/FeedProvider'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilter } from '@/providers/KindFilterProvider'
import relayInfoService from '@/services/relay-info.service' import relayInfoService from '@/services/relay-info.service'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import React, { useEffect, useMemo, useState, useRef } from 'react' import React, { forwardRef, useEffect, useMemo, useState, useRef } from 'react'
export default function RelaysFeed({ const RelaysFeed = forwardRef<
setSubHeader, TNoteListRef,
kindsOverride {
}: { setSubHeader?: (node: React.ReactNode) => void
setSubHeader?: (node: React.ReactNode) => void /** When set, subscription kinds (fixed list); otherwise uses KindFilterProvider. */
/** When set, subscription kinds (fixed list); otherwise uses KindFilterProvider. */ kindsOverride?: number[]
kindsOverride?: number[] }
}) { >(function RelaysFeed({ setSubHeader, kindsOverride }, ref) {
const { feedInfo, relayUrls } = useFeed() const { feedInfo, relayUrls } = useFeed()
const { showKinds } = useKindFilter() const { showKinds } = useKindFilter()
const [areAlgoRelays, setAreAlgoRelays] = useState(false) const [areAlgoRelays, setAreAlgoRelays] = useState(false)
@ -92,10 +93,14 @@ export default function RelaysFeed({
return ( return (
<NormalFeed <NormalFeed
ref={ref}
subRequests={subRequests} subRequests={subRequests}
areAlgoRelays={areAlgoRelays} areAlgoRelays={areAlgoRelays}
isMainFeed isMainFeed
setSubHeader={setSubHeader} setSubHeader={setSubHeader}
/> />
) )
} })
RelaysFeed.displayName = 'RelaysFeed'
export default RelaysFeed

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

@ -1,5 +1,6 @@
import BookmarkList from '@/components/BookmarkList' import BookmarkList from '@/components/BookmarkList'
import RelayInfo from '@/components/RelayInfo' import RelayInfo from '@/components/RelayInfo'
import { RefreshButton } from '@/components/RefreshButton'
import VersionUpdateBanner from '@/components/VersionUpdateBanner' 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'
@ -7,6 +8,7 @@ import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFeed } from '@/providers/FeedProvider' import { useFeed } from '@/providers/FeedProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import type { TNoteListRef } from '@/components/NoteList'
import { TPageRef } from '@/types' import { TPageRef } from '@/types'
import { Compass, Info } from 'lucide-react' import { Compass, Info } from 'lucide-react'
import React, { import React, {
@ -25,15 +27,33 @@ import FollowingFeed from './FollowingFeed'
import RelaysFeed from './RelaysFeed' import RelaysFeed from './RelaysFeed'
import { usePrimaryNoteView, usePrimaryPage } from '@/PageManager' import { usePrimaryNoteView, usePrimaryPage } from '@/PageManager'
const NoteListPage = forwardRef((_, ref) => { const NoteListPage = forwardRef<TPageRef>((_, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { addRelayUrls, removeRelayUrls } = useCurrentRelays() const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
const layoutRef = useRef<TPageRef>(null) const layoutRef = useRef<TPageRef>(null)
const feedRef = useRef<TNoteListRef>(null)
const bookmarkRef = useRef<{ refresh: () => void }>(null)
const { pubkey, checkLogin } = useNostr() const { pubkey, checkLogin } = useNostr()
const { feedInfo, relayUrls, isReady } = useFeed() const { feedInfo, relayUrls, isReady } = useFeed()
const [showRelayDetails, setShowRelayDetails] = useState(false) const [showRelayDetails, setShowRelayDetails] = useState(false)
const [homeSubHeader, setHomeSubHeader] = useState<React.ReactNode>(null) const [homeSubHeader, setHomeSubHeader] = useState<React.ReactNode>(null)
useImperativeHandle(ref, () => layoutRef.current)
const runFeedRefresh = useCallback(() => {
if (feedInfo.feedType === 'bookmarks') {
void bookmarkRef.current?.refresh()
} else {
feedRef.current?.refresh()
}
}, [feedInfo.feedType])
useImperativeHandle(
ref,
() => ({
scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior),
refresh: runFeedRefresh
}),
[runFeedRefresh]
)
const setHomeSubHeaderStable = useCallback((node: React.ReactNode) => { const setHomeSubHeaderStable = useCallback((node: React.ReactNode) => {
setHomeSubHeader(node) setHomeSubHeader(node)
@ -82,17 +102,17 @@ const NoteListPage = forwardRef((_, ref) => {
</div> </div>
) )
} else { } else {
content = <BookmarkList /> content = <BookmarkList ref={bookmarkRef} />
} }
} else if (feedInfo.feedType === 'following') { } else if (feedInfo.feedType === 'following') {
content = <FollowingFeed setSubHeader={setHomeSubHeaderStable} /> content = <FollowingFeed ref={feedRef} setSubHeader={setHomeSubHeaderStable} />
} else { } else {
content = ( content = (
<> <>
{showRelayDetails && feedInfo.feedType === 'relay' && !!feedInfo.id && ( {showRelayDetails && feedInfo.feedType === 'relay' && !!feedInfo.id && (
<RelayInfo url={feedInfo.id!} className="mb-2 pt-3" /> <RelayInfo url={feedInfo.id!} className="mb-2 pt-3" />
)} )}
<RelaysFeed setSubHeader={setHomeSubHeaderStable} /> <RelaysFeed ref={feedRef} setSubHeader={setHomeSubHeaderStable} />
</> </>
) )
} }
@ -104,6 +124,7 @@ const NoteListPage = forwardRef((_, ref) => {
titlebar={ titlebar={
<NoteListPageTitlebar <NoteListPageTitlebar
layoutRef={layoutRef} layoutRef={layoutRef}
onFeedRefresh={runFeedRefresh}
showRelayDetails={showRelayDetails} showRelayDetails={showRelayDetails}
setShowRelayDetails={ setShowRelayDetails={
feedInfo.feedType === 'relay' && !!feedInfo.id ? setShowRelayDetails : undefined feedInfo.feedType === 'relay' && !!feedInfo.id ? setShowRelayDetails : undefined
@ -125,10 +146,12 @@ export default NoteListPage
function NoteListPageTitlebar({ function NoteListPageTitlebar({
layoutRef, layoutRef,
onFeedRefresh,
showRelayDetails, showRelayDetails,
setShowRelayDetails setShowRelayDetails
}: { }: {
layoutRef?: React.RefObject<TPageRef> layoutRef?: React.RefObject<TPageRef>
onFeedRefresh: () => void
showRelayDetails?: boolean showRelayDetails?: boolean
setShowRelayDetails?: Dispatch<SetStateAction<boolean>> setShowRelayDetails?: Dispatch<SetStateAction<boolean>>
}) { }) {
@ -176,6 +199,7 @@ function NoteListPageTitlebar({
</div> </div>
)} )}
<div className="shrink-0 flex gap-1 items-center"> <div className="shrink-0 flex gap-1 items-center">
<RefreshButton onClick={onFeedRefresh} />
{setShowRelayDetails && ( {setShowRelayDetails && (
<Button <Button
variant="ghost" variant="ghost"

54
src/pages/primary/ProfilePage/index.tsx

@ -1,24 +1,41 @@
import Profile from '@/components/Profile' import Profile from '@/components/Profile'
import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { usePrimaryPage } from '@/PageManager' import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { TPageRef } from '@/types'
import { Settings, UserRound } from 'lucide-react' import { Settings, UserRound } from 'lucide-react'
import { forwardRef } from 'react' import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const ProfilePage = forwardRef((_, ref) => { const ProfilePage = forwardRef<TPageRef>((_, ref) => {
const { pubkey } = useNostr() const { pubkey } = useNostr()
const layoutRef = useRef<TPageRef>(null)
const feedRef = useRef<{ refresh: () => void }>(null)
const runRefresh = useCallback(() => {
feedRef.current?.refresh()
}, [])
useImperativeHandle(
ref,
() => ({
scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior),
refresh: runRefresh
}),
[runRefresh]
)
return ( return (
<PrimaryPageLayout <PrimaryPageLayout
pageName="profile" pageName="profile"
titlebar={<ProfilePageTitlebar />} titlebar={<ProfilePageTitlebar onFeedRefresh={runRefresh} />}
displayScrollToTopButton displayScrollToTopButton
ref={ref} ref={layoutRef}
> >
<div className="min-w-0 pt-2"> <div className="min-w-0 pt-2">
<Profile id={pubkey ?? undefined} /> <Profile id={pubkey ?? undefined} feedRef={feedRef} />
</div> </div>
</PrimaryPageLayout> </PrimaryPageLayout>
) )
@ -26,7 +43,7 @@ const ProfilePage = forwardRef((_, ref) => {
ProfilePage.displayName = 'ProfilePage' ProfilePage.displayName = 'ProfilePage'
export default ProfilePage export default ProfilePage
function ProfilePageTitlebar() { function ProfilePageTitlebar({ onFeedRefresh }: { onFeedRefresh: () => void }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { navigate } = usePrimaryPage() const { navigate } = usePrimaryPage()
@ -37,17 +54,20 @@ function ProfilePageTitlebar() {
<UserRound className="size-5 shrink-0" /> <UserRound className="size-5 shrink-0" />
<div className="truncate text-lg font-semibold">{t('Profile')}</div> <div className="truncate text-lg font-semibold">{t('Profile')}</div>
</div> </div>
{pubkey ? ( <div className="flex shrink-0 items-center gap-1">
<Button <RefreshButton onClick={onFeedRefresh} />
type="button" {pubkey ? (
variant="ghost" <Button
size="titlebar-icon" type="button"
title={t('Settings')} variant="ghost"
onClick={() => navigate('settings')} size="titlebar-icon"
> title={t('Settings')}
<Settings className="size-5" /> onClick={() => navigate('settings')}
</Button> >
) : null} <Settings className="size-5" />
</Button>
) : null}
</div>
</div> </div>
) )
} }

39
src/pages/primary/RelayPage/index.tsx

@ -1,21 +1,39 @@
import type { TNoteListRef } from '@/components/NoteList'
import { RefreshButton } from '@/components/RefreshButton'
import Relay from '@/components/Relay' import Relay from '@/components/Relay'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { TPageRef } from '@/types'
import { normalizeUrl, simplifyUrl } from '@/lib/url' import { normalizeUrl, simplifyUrl } from '@/lib/url'
import { Server } from 'lucide-react' import { Server } from 'lucide-react'
import { forwardRef, useMemo } from 'react' import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react'
const RelayPage = forwardRef(({ url }: { url?: string }, ref) => { const RelayPage = forwardRef<TPageRef, { url?: string }>(({ url }, ref) => {
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url]) const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url])
const layoutRef = useRef<TPageRef>(null)
const feedRef = useRef<TNoteListRef>(null)
const runRefresh = useCallback(() => {
feedRef.current?.refresh()
}, [])
useImperativeHandle(
ref,
() => ({
scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior),
refresh: runRefresh
}),
[runRefresh]
)
return ( return (
<PrimaryPageLayout <PrimaryPageLayout
pageName="relay" pageName="relay"
titlebar={<RelayPageTitlebar url={normalizedUrl} />} titlebar={<RelayPageTitlebar url={normalizedUrl} onRefresh={runRefresh} />}
displayScrollToTopButton displayScrollToTopButton
ref={ref} ref={layoutRef}
> >
<div className="min-w-0 pt-2"> <div className="min-w-0 pt-2">
<Relay url={normalizedUrl} /> <Relay ref={feedRef} url={normalizedUrl} />
</div> </div>
</PrimaryPageLayout> </PrimaryPageLayout>
) )
@ -23,11 +41,14 @@ const RelayPage = forwardRef(({ url }: { url?: string }, ref) => {
RelayPage.displayName = 'RelayPage' RelayPage.displayName = 'RelayPage'
export default RelayPage export default RelayPage
function RelayPageTitlebar({ url }: { url?: string }) { function RelayPageTitlebar({ url, onRefresh }: { url?: string; onRefresh: () => void }) {
return ( return (
<div className="flex items-center gap-2 px-3 h-full"> <div className="flex w-full items-center justify-between gap-2 px-1 h-full">
<Server /> <div className="flex min-w-0 flex-1 items-center gap-2 px-2">
<div className="text-lg font-semibold truncate">{simplifyUrl(url ?? '')}</div> <Server />
<div className="text-lg font-semibold truncate">{simplifyUrl(url ?? '')}</div>
</div>
<RefreshButton onClick={onRefresh} />
</div> </div>
) )
} }

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

@ -1,21 +1,23 @@
import RssFeedList from '@/components/RssFeedList' import RssFeedList from '@/components/RssFeedList'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DEFAULT_RSS_FEEDS } from '@/constants' import { DEFAULT_RSS_FEEDS } from '@/constants'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import rssFeedService from '@/services/rss-feed.service' import rssFeedService from '@/services/rss-feed.service'
import { Rss, Search } from 'lucide-react' import { Rss, Search } from 'lucide-react'
import { forwardRef, useState } from 'react' import { TPageRef } from '@/types'
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const RssPage = forwardRef((_, ref) => { const RssPage = forwardRef<TPageRef>((_, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, rssFeedListEvent } = useNostr() const { pubkey, rssFeedListEvent } = useNostr()
const [rssRefreshKey, setRssRefreshKey] = useState(0) const [rssRefreshKey, setRssRefreshKey] = useState(0)
const layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const handleRefresh = () => { const handleRefresh = useCallback(() => {
let feedUrls: string[] = [] let feedUrls: string[] = []
if (pubkey && rssFeedListEvent) { if (pubkey && rssFeedListEvent) {
try { try {
@ -40,11 +42,20 @@ const RssPage = forwardRef((_, ref) => {
) )
} }
setRssRefreshKey((k) => k + 1) setRssRefreshKey((k) => k + 1)
} }, [pubkey, rssFeedListEvent])
useImperativeHandle(
ref,
() => ({
scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior),
refresh: handleRefresh
}),
[handleRefresh]
)
return ( return (
<PrimaryPageLayout <PrimaryPageLayout
ref={ref} ref={layoutRef}
pageName="rss" pageName="rss"
titlebar={ titlebar={
<div className="flex h-full w-full items-center justify-between gap-2 pr-1"> <div className="flex h-full w-full items-center justify-between gap-2 pr-1">

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

@ -1,27 +1,32 @@
import LatestFromFollowsSection from '@/components/LatestFromFollowsSection' import LatestFromFollowsSection from '@/components/LatestFromFollowsSection'
import { RefreshButton } from '@/components/RefreshButton'
import SearchBar, { TSearchBarRef } from '@/components/SearchBar' import SearchBar, { TSearchBarRef } from '@/components/SearchBar'
import SearchResult from '@/components/SearchResult' import SearchResult from '@/components/SearchResult'
import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout'
import { usePrimaryPage } from '@/PageManager' import { usePrimaryPage } from '@/PageManager'
import { TSearchParams } from '@/types' import { TPageRef, TSearchParams } from '@/types'
import { BookOpen } from 'lucide-react' import { BookOpen } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
const SearchPage = forwardRef((_, ref) => { const SearchPage = forwardRef<TPageRef>((_, ref) => {
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [searchParams, setSearchParams] = useState<TSearchParams | null>(null) const [searchParams, setSearchParams] = useState<TSearchParams | null>(null)
const [resultRefreshKey, setResultRefreshKey] = useState(0)
const isActive = useMemo(() => current === 'search' && display, [current, display]) const isActive = useMemo(() => current === 'search' && display, [current, display])
const searchBarRef = useRef<TSearchBarRef>(null) const searchBarRef = useRef<TSearchBarRef>(null)
const layoutRef = useRef<TPrimaryPageLayoutRef>(null) const layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const bumpResults = useCallback(() => setResultRefreshKey((k) => k + 1), [])
useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({
scrollToTop: (behavior: ScrollBehavior = 'smooth') => layoutRef.current?.scrollToTop(behavior) scrollToTop: (behavior: ScrollBehavior = 'smooth') => layoutRef.current?.scrollToTop(behavior),
refresh: bumpResults
}), }),
[] [bumpResults]
) )
useEffect(() => { useEffect(() => {
@ -46,7 +51,10 @@ const SearchPage = forwardRef((_, ref) => {
displayScrollToTopButton displayScrollToTopButton
> >
<div className="min-w-0 pt-4 px-4 pb-4"> <div className="min-w-0 pt-4 px-4 pb-4">
<div className="text-2xl font-bold mb-4">Search Nostr</div> <div className="mb-4 flex items-center justify-between gap-2">
<div className="text-2xl font-bold">Search Nostr</div>
<RefreshButton onClick={bumpResults} />
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mb-4 relative z-40"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mb-4 relative z-40">
<div className="flex-1 relative order-2 sm:order-1"> <div className="flex-1 relative order-2 sm:order-1">
<SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} /> <SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} />
@ -69,14 +77,16 @@ const SearchPage = forwardRef((_, ref) => {
</div> </div>
</div> </div>
<div className="h-4"></div> <div className="h-4"></div>
{searchParams ? ( <div key={resultRefreshKey} className="min-w-0">
<SearchResult searchParams={searchParams} /> {searchParams ? (
) : ( <SearchResult searchParams={searchParams} />
<div className="mb-4 min-w-0 space-y-2"> ) : (
<LatestFromFollowsSection /> <div className="mb-4 min-w-0 space-y-2">
<SearchResult searchParams={null} /> <LatestFromFollowsSection />
</div> <SearchResult searchParams={null} />
)} </div>
)}
</div>
</div> </div>
</PrimaryPageLayout> </PrimaryPageLayout>
) )

34
src/pages/primary/SettingsPrimaryPage/index.tsx

@ -1,25 +1,43 @@
import { RefreshButton } from '@/components/RefreshButton'
import SettingsMenuBody from '@/components/Settings/SettingsMenuBody' import SettingsMenuBody from '@/components/Settings/SettingsMenuBody'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout'
import { TPageRef } from '@/types'
import { Settings } from 'lucide-react' import { Settings } from 'lucide-react'
import { forwardRef } from 'react' import { forwardRef, useImperativeHandle, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const SettingsPrimaryPage = forwardRef((_, ref) => { const SettingsPrimaryPage = forwardRef<TPageRef>((_, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const [menuKey, setMenuKey] = useState(0)
const bumpMenu = () => setMenuKey((k) => k + 1)
useImperativeHandle(
ref,
() => ({
scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior),
refresh: bumpMenu
}),
[]
)
return ( return (
<PrimaryPageLayout <PrimaryPageLayout
ref={ref} ref={layoutRef}
pageName="settings" pageName="settings"
titlebar={ titlebar={
<div className="flex h-full items-center gap-2 pl-3"> <div className="flex h-full w-full items-center justify-between gap-2 pl-3 pr-1">
<Settings className="size-5 shrink-0" /> <div className="flex items-center gap-2">
<div className="text-lg font-semibold">{t('Settings')}</div> <Settings className="size-5 shrink-0" />
<div className="text-lg font-semibold">{t('Settings')}</div>
</div>
<RefreshButton onClick={bumpMenu} />
</div> </div>
} }
displayScrollToTopButton displayScrollToTopButton
> >
<div className="min-w-0 px-2 pt-2"> <div key={menuKey} className="min-w-0 px-2 pt-2">
<SettingsMenuBody /> <SettingsMenuBody />
</div> </div>
</PrimaryPageLayout> </PrimaryPageLayout>

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

@ -1,5 +1,6 @@
import HideUntrustedContentButton from '@/components/HideUntrustedContentButton' import HideUntrustedContentButton from '@/components/HideUntrustedContentButton'
import NoteList, { type TNoteListRef } from '@/components/NoteList' import NoteList, { type TNoteListRef } from '@/components/NoteList'
import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
@ -18,7 +19,7 @@ import { Separator } from '@/components/ui/separator'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'
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, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout'
import { usePrimaryPage } from '@/PageManager' 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'
@ -70,7 +71,6 @@ import {
MoreVertical, MoreVertical,
Pencil, Pencil,
Plus, Plus,
RefreshCw,
Star, Star,
Trash2, Trash2,
Users, Users,
@ -78,7 +78,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { verifyEvent } from 'nostr-tools' import { verifyEvent } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import CreateSpellDialog from './CreateSpellDialog' import CreateSpellDialog from './CreateSpellDialog'
import { import {
@ -275,7 +275,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
/** Bumps spell catalog relay re-sync when the user taps refresh in the titlebar. */ /** Bumps spell catalog relay re-sync when the user taps refresh in the titlebar. */
const [spellCatalogManualRefreshKey, setSpellCatalogManualRefreshKey] = useState(0) const [spellCatalogManualRefreshKey, setSpellCatalogManualRefreshKey] = useState(0)
const spellFeedListRef = useRef<TNoteListRef>(null) const spellFeedListRef = useRef<TNoteListRef>(null)
const [titlebarRefreshSpin, setTitlebarRefreshSpin] = useState(false) const layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const [spellPickerOpen, setSpellPickerOpen] = useState(false) const [spellPickerOpen, setSpellPickerOpen] = useState(false)
/** Monotonic token + wall time for spell-feed latency instrumentation (picker → first rows). */ /** Monotonic token + wall time for spell-feed latency instrumentation (picker → first rows). */
@ -332,13 +332,20 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
}, []) }, [])
const refreshSpellsFeedAndCatalog = useCallback(() => { const refreshSpellsFeedAndCatalog = useCallback(() => {
setTitlebarRefreshSpin(true)
window.setTimeout(() => setTitlebarRefreshSpin(false), 600)
void loadSpells() void loadSpells()
if (pubkey) setSpellCatalogManualRefreshKey((k) => k + 1) if (pubkey) setSpellCatalogManualRefreshKey((k) => k + 1)
spellFeedListRef.current?.refresh() spellFeedListRef.current?.refresh()
}, [loadSpells, pubkey]) }, [loadSpells, pubkey])
useImperativeHandle(
ref,
() => ({
scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior),
refresh: refreshSpellsFeedAndCatalog
}),
[refreshSpellsFeedAndCatalog]
)
/** /**
* Fingerprint by value `relayList` from NostrProvider often gets a new object ref each render. * Fingerprint by value `relayList` from NostrProvider often gets a new object ref each render.
* Using `[relayList]` in useMemo deps was invalidating every tick new subRequests browse-relay * Using `[relayList]` in useMemo deps was invalidating every tick new subRequests browse-relay
@ -1007,22 +1014,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return ( return (
<PrimaryPageLayout <PrimaryPageLayout
ref={ref} ref={layoutRef}
pageName="spells" pageName="spells"
titlebar={ titlebar={
<div className="flex h-full w-full items-center justify-between gap-2 pr-1"> <div className="flex h-full w-full items-center justify-between gap-2 pr-1">
<div className="pl-3 text-lg font-semibold">{t('Spells')}</div> <div className="pl-3 text-lg font-semibold">{t('Spells')}</div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button <RefreshButton onClick={refreshSpellsFeedAndCatalog} />
type="button"
variant="ghost"
size="titlebar-icon"
title={t('Refresh')}
aria-label={t('Refresh')}
onClick={refreshSpellsFeedAndCatalog}
>
<RefreshCw className={`size-5 ${titlebarRefreshSpin ? 'animate-spin' : ''}`} />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="titlebar-icon" size="titlebar-icon"

20
src/pages/secondary/CacheSettingsPage/index.tsx

@ -1,18 +1,34 @@
import CacheRelaysSetting from '@/components/CacheRelaysSetting' import CacheRelaysSetting from '@/components/CacheRelaysSetting'
import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef } from 'react' import { usePrimaryNoteView } from '@/PageManager'
import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const CacheSettingsPage = forwardRef( const CacheSettingsPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), [])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bump)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bump])
return ( return (
<SecondaryPageLayout <SecondaryPageLayout
ref={ref} ref={ref}
index={index} index={index}
title={hideTitlebar ? undefined : t('Cache & offline storage')} title={hideTitlebar ? undefined : t('Cache & offline storage')}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />}
> >
<div className="px-4 py-3"> <div key={contentKey} className="px-4 py-3">
<CacheRelaysSetting /> <CacheRelaysSetting />
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>

20
src/pages/secondary/FollowingListPage/index.tsx

@ -1,13 +1,28 @@
import ProfileList from '@/components/ProfileList' import ProfileList from '@/components/ProfileList'
import { RefreshButton } from '@/components/RefreshButton'
import { useFetchFollowings, useFetchProfile } from '@/hooks' import { useFetchFollowings, useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef } from 'react' import { usePrimaryNoteView } from '@/PageManager'
import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; index?: number; hideTitlebar?: boolean }, ref) => { const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [listRefreshNonce, setListRefreshNonce] = useState(0)
const { profile } = useFetchProfile(id) const { profile } = useFetchProfile(id)
const { followings } = useFetchFollowings(profile?.pubkey) const { followings } = useFetchFollowings(profile?.pubkey, listRefreshNonce)
const bumpList = useCallback(() => setListRefreshNonce((n) => n + 1), [])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bumpList)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpList])
return ( return (
<SecondaryPageLayout <SecondaryPageLayout
@ -21,6 +36,7 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
: t('Following') : t('Following')
} }
hideBackButton={hideTitlebar} hideBackButton={hideTitlebar}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bumpList} />}
displayScrollToTopButton displayScrollToTopButton
> >
<ProfileList pubkeys={followings} /> <ProfileList pubkeys={followings} />

25
src/pages/secondary/GeneralSettingsPage/index.tsx

@ -1,3 +1,4 @@
import { RefreshButton } from '@/components/RefreshButton'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
@ -9,6 +10,7 @@ import {
} from '@/constants' } from '@/constants'
import { LocalizedLanguageNames, TLanguage } from '@/i18n' import { LocalizedLanguageNames, TLanguage } from '@/i18n'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/PageManager'
import { cn, isSupportCheckConnectionType } from '@/lib/utils' import { cn, isSupportCheckConnectionType } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useFontSize } from '@/providers/FontSizeProvider' import { useFontSize } from '@/providers/FontSizeProvider'
@ -18,11 +20,14 @@ import { useUserTrust } from '@/providers/UserTrustProvider'
import { TMediaAutoLoadPolicy } from '@/types' import { TMediaAutoLoadPolicy } from '@/types'
import { SelectValue } from '@radix-ui/react-select' import { SelectValue } from '@radix-ui/react-select'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { forwardRef, HTMLProps, useState } from 'react' import { forwardRef, HTMLProps, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), [])
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage) const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
const { themeSetting, setThemeSetting } = useTheme() const { themeSetting, setThemeSetting } = useTheme()
const { fontSize, setFontSize } = useFontSize() const { fontSize, setFontSize } = useFontSize()
@ -49,9 +54,23 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
setLanguage(value) setLanguage(value)
} }
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bump)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bump])
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('General')}> <SecondaryPageLayout
<div className="space-y-4 mt-3"> ref={ref}
index={index}
title={hideTitlebar ? undefined : t('General')}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />}
>
<div key={contentKey} className="space-y-4 mt-3">
<SettingItem> <SettingItem>
<Label htmlFor="languages" className="text-base font-normal"> <Label htmlFor="languages" className="text-base font-normal">
{t('Languages')} {t('Languages')}

22
src/pages/secondary/MuteListPage/index.tsx

@ -1,28 +1,43 @@
import MuteButton from '@/components/MuteButton' import MuteButton from '@/components/MuteButton'
import Nip05 from '@/components/Nip05' import Nip05 from '@/components/Nip05'
import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username' import Username from '@/components/Username'
import { useFetchProfile } from '@/hooks' import { useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/PageManager'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Loader, Lock, Unlock } from 'lucide-react' import { Loader, Lock, Unlock } from 'lucide-react'
import { forwardRef, 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 NotFoundPage from '../NotFoundPage' import NotFoundPage from '../NotFoundPage'
const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { profile, pubkey } = useNostr() const { profile, pubkey } = useNostr()
const { getMutePubkeys } = useMuteList() const { getMutePubkeys } = useMuteList()
const mutePubkeys = useMemo(() => getMutePubkeys(), [pubkey]) const mutePubkeys = useMemo(() => getMutePubkeys(), [pubkey])
const [visibleMutePubkeys, setVisibleMutePubkeys] = useState<string[]>([]) const [visibleMutePubkeys, setVisibleMutePubkeys] = useState<string[]>([])
const [listRefreshKey, setListRefreshKey] = useState(0)
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const bumpList = useCallback(() => setListRefreshKey((k) => k + 1), [])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bumpList)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpList])
useEffect(() => { useEffect(() => {
setVisibleMutePubkeys(mutePubkeys.slice(0, 10)) setVisibleMutePubkeys(mutePubkeys.slice(0, 10))
}, [mutePubkeys]) }, [mutePubkeys, listRefreshKey])
useEffect(() => { useEffect(() => {
const options = { const options = {
@ -62,9 +77,10 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb
index={index} index={index}
title={hideTitlebar ? undefined : t("username's muted", { username: profile.username })} title={hideTitlebar ? undefined : t("username's muted", { username: profile.username })}
hideBackButton={hideTitlebar} hideBackButton={hideTitlebar}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bumpList} />}
displayScrollToTopButton displayScrollToTopButton
> >
<div className="space-y-2 px-4 pt-2"> <div key={listRefreshKey} className="space-y-2 px-4 pt-2">
{visibleMutePubkeys.map((pubkey, index) => ( {visibleMutePubkeys.map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} /> <UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))} ))}

11
src/pages/secondary/NotFoundPage/index.tsx

@ -1,11 +1,16 @@
import NotFound from '@/components/NotFound' import NotFound from '@/components/NotFound'
import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef } from 'react' import { forwardRef, useCallback, useState } from 'react'
const NotFoundPage = forwardRef(({ index }: { index?: number }, ref) => { const NotFoundPage = forwardRef(({ index }: { index?: number }, ref) => {
const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), [])
return ( return (
<SecondaryPageLayout ref={ref} index={index} hideBackButton> <SecondaryPageLayout ref={ref} index={index} hideBackButton controls={<RefreshButton onClick={bump} />}>
<NotFound /> <div key={contentKey}>
<NotFound />
</div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )
}) })

40
src/pages/secondary/NoteListPage/index.tsx

@ -1,18 +1,20 @@
import { Favicon } from '@/components/Favicon' import { Favicon } from '@/components/Favicon'
import type { TNoteListRef } from '@/components/NoteList'
import NormalFeed from '@/components/NormalFeed' import NormalFeed from '@/components/NormalFeed'
import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toProfileList } from '@/lib/link' import { toProfileList } from '@/lib/link'
import { fetchPubkeysFromDomain, getWellKnownNip05Url } from '@/lib/nip05' import { fetchPubkeysFromDomain, getWellKnownNip05Url } from '@/lib/nip05'
import { useSecondaryPage } from '@/PageManager' import { usePrimaryNoteView, useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useInterestList } from '@/providers/InterestListProvider' import { useInterestList } from '@/providers/InterestListProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TFeedSubRequest } from '@/types' import { TFeedSubRequest } from '@/types'
import { UserRound, Plus } from 'lucide-react' import { UserRound, Plus } from 'lucide-react'
import React, { forwardRef, useEffect, useState, useMemo, useCallback } from 'react' import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
interface NoteListPageProps { interface NoteListPageProps {
@ -22,6 +24,9 @@ interface NoteListPageProps {
const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hideTitlebar = false }, ref) => { const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hideTitlebar = false }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const feedRef = useRef<TNoteListRef>(null)
const bumpFeed = useCallback(() => feedRef.current?.refresh(), [])
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { relayList, pubkey } = useNostr() const { relayList, pubkey } = useNostr()
const { isSubscribed, subscribe } = useInterestList() const { isSubscribed, subscribe } = useInterestList()
@ -226,6 +231,17 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
} }
}, [data, pubkey, isHashtagSubscribed, handleSubscribeHashtag, t]) }, [data, pubkey, isHashtagSubscribed, handleSubscribeHashtag, t])
useEffect(() => {
const inlineHeader =
hideTitlebar && (data?.type === 'hashtag' || data?.type === 'dtag')
if (!hideTitlebar || inlineHeader) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bumpFeed)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, data?.type, registerPrimaryPanelRefresh, bumpFeed])
let content: React.ReactNode = null let content: React.ReactNode = null
if (data?.type === 'domain' && subRequests.length === 0) { if (data?.type === 'domain' && subRequests.length === 0) {
content = ( content = (
@ -236,23 +252,35 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
</div> </div>
) )
} else if (data) { } else if (data) {
content = <NormalFeed subRequests={subRequests} /> content = <NormalFeed ref={feedRef} subRequests={subRequests} />
} }
const titlebarExtras = controls
return ( return (
<SecondaryPageLayout <SecondaryPageLayout
ref={ref} ref={ref}
index={index} index={index}
title={hideTitlebar ? undefined : title} title={hideTitlebar ? undefined : title}
controls={hideTitlebar ? undefined : controls} controls={
hideTitlebar ? undefined : (
<div className="flex items-center gap-1">
<RefreshButton onClick={bumpFeed} />
{titlebarExtras}
</div>
)
}
displayScrollToTopButton displayScrollToTopButton
> >
{hideTitlebar && (data?.type === 'hashtag' || data?.type === 'dtag') ? ( {hideTitlebar && (data?.type === 'hashtag' || data?.type === 'dtag') ? (
<> <>
<div className="px-4 py-2 border-b"> <div className="px-4 py-2 border-b">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
<div className="text-lg font-semibold">{title}</div> <div className="text-lg font-semibold">{title}</div>
{controls} <div className="flex items-center gap-1">
<RefreshButton onClick={bumpFeed} />
{titlebarExtras}
</div>
</div> </div>
</div> </div>
<div className="pt-4">{content}</div> <div className="pt-4">{content}</div>

57
src/pages/secondary/NotePage/index.tsx

@ -1,4 +1,5 @@
import { useSecondaryPage, useSmartNoteNavigation } from '@/PageManager' import { RefreshButton } from '@/components/RefreshButton'
import { usePrimaryNoteView, useSecondaryPage, useSmartNoteNavigation } from '@/PageManager'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import ContentPreview from '@/components/ContentPreview' import ContentPreview from '@/components/ContentPreview'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -20,7 +21,7 @@ import { cn } from '@/lib/utils'
import { Ellipsis } from 'lucide-react' import { Ellipsis } from 'lucide-react'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { kinds, nip19 } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools'
import { forwardRef, useEffect, useMemo, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { NOSTR_URI_NADDR_REGEX } from '@/lib/content-patterns' import { NOSTR_URI_NADDR_REGEX } from '@/lib/content-patterns'
import NotFound from './NotFound' import NotFound from './NotFound'
@ -93,7 +94,8 @@ function stripMarkdown(content: string): string {
const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: { id?: string; index?: number; hideTitlebar?: boolean; initialEvent?: Event }, ref) => { const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: { id?: string; index?: number; hideTitlebar?: boolean; initialEvent?: Event }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { event, isFetching } = useFetchEvent(id, initialEvent) const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { event, isFetching, refetch: refetchMain } = useFetchEvent(id, initialEvent)
const [externalEvent, setExternalEvent] = useState<Event | undefined>(undefined) const [externalEvent, setExternalEvent] = useState<Event | undefined>(undefined)
const finalEvent = event || externalEvent const finalEvent = event || externalEvent
@ -103,8 +105,10 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
() => (finalEvent?.kind === ExtendedKind.COMMENT ? finalEvent.tags.find(tagNameEquals('I')) : undefined), () => (finalEvent?.kind === ExtendedKind.COMMENT ? finalEvent.tags.find(tagNameEquals('I')) : undefined),
[finalEvent] [finalEvent]
) )
const { isFetching: isFetchingRootEvent, event: rootEvent } = useFetchEvent(rootEventId) const { isFetching: isFetchingRootEvent, event: rootEvent, refetch: refetchRoot } =
const { isFetching: isFetchingParentEvent, event: parentEvent } = useFetchEvent(parentEventId) useFetchEvent(rootEventId)
const { isFetching: isFetchingParentEvent, event: parentEvent, refetch: refetchParent } =
useFetchEvent(parentEventId)
// When viewing a kind-24 invite (e.g. from notifications), extract calendar event naddr from content and show full calendar card with RSVP // When viewing a kind-24 invite (e.g. from notifications), extract calendar event naddr from content and show full calendar card with RSVP
const calendarInviteNaddr = useMemo(() => { const calendarInviteNaddr = useMemo(() => {
@ -123,7 +127,25 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
} }
return undefined return undefined
}, [finalEvent?.kind, finalEvent?.content]) }, [finalEvent?.kind, finalEvent?.content])
const { event: calendarInviteEvent } = useFetchEvent(calendarInviteNaddr) const { event: calendarInviteEvent, refetch: refetchCalendarInvite } = useFetchEvent(calendarInviteNaddr)
const refreshNoteData = useCallback(() => {
refetchMain()
refetchRoot()
refetchParent()
refetchCalendarInvite()
}, [refetchMain, refetchRoot, refetchParent, refetchCalendarInvite])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(refreshNoteData)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, refreshNoteData])
const titlebarRefreshControls = hideTitlebar ? undefined : <RefreshButton onClick={refreshNoteData} />
// Fetch profile for author (for OpenGraph metadata) // Fetch profile for author (for OpenGraph metadata)
const { profile: authorProfile } = useFetchProfile(finalEvent?.pubkey) const { profile: authorProfile } = useFetchProfile(finalEvent?.pubkey)
@ -426,7 +448,12 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
if (!event && isFetching) { if (!event && isFetching) {
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Note')}> <SecondaryPageLayout
ref={ref}
index={index}
title={hideTitlebar ? undefined : t('Note')}
controls={titlebarRefreshControls}
>
<div className="px-4 pt-3"> <div className="px-4 pt-3">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Skeleton className="w-10 h-10 rounded-full" /> <Skeleton className="w-10 h-10 rounded-full" />
@ -453,14 +480,26 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
} }
if (!finalEvent) { if (!finalEvent) {
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Note')} displayScrollToTopButton> <SecondaryPageLayout
ref={ref}
index={index}
title={hideTitlebar ? undefined : t('Note')}
controls={titlebarRefreshControls}
displayScrollToTopButton
>
<NotFound bech32Id={id} onEventFound={setExternalEvent} /> <NotFound bech32Id={id} onEventFound={setExternalEvent} />
</SecondaryPageLayout> </SecondaryPageLayout>
) )
} }
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : getNoteTypeTitle(finalEvent.kind)} displayScrollToTopButton> <SecondaryPageLayout
ref={ref}
index={index}
title={hideTitlebar ? undefined : getNoteTypeTitle(finalEvent.kind)}
controls={titlebarRefreshControls}
displayScrollToTopButton
>
<div className="px-4 pt-3 w-full"> <div className="px-4 pt-3 w-full">
{rootITag && <ExternalRoot value={rootITag[1]} />} {rootITag && <ExternalRoot value={rootITag[1]} />}
{rootEventId && rootEventId !== parentEventId && ( {rootEventId && rootEventId !== parentEventId && (

20
src/pages/secondary/OthersRelaySettingsPage/index.tsx

@ -1,12 +1,27 @@
import OthersRelayList from '@/components/OthersRelayList' import OthersRelayList from '@/components/OthersRelayList'
import { RefreshButton } from '@/components/RefreshButton'
import { useFetchProfile } from '@/hooks' import { useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef } from 'react' import { usePrimaryNoteView } from '@/PageManager'
import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const RelaySettingsPage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; index?: number; hideTitlebar?: boolean }, ref) => { const RelaySettingsPage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { profile } = useFetchProfile(id) const { profile } = useFetchProfile(id)
const [listKey, setListKey] = useState(0)
const bumpList = useCallback(() => setListKey((k) => k + 1), [])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bumpList)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpList])
if (!id || !profile) { if (!id || !profile) {
return null return null
@ -18,8 +33,9 @@ const RelaySettingsPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
index={index} index={index}
title={hideTitlebar ? undefined : t("username's used relays", { username: profile.username })} title={hideTitlebar ? undefined : t("username's used relays", { username: profile.username })}
hideBackButton={hideTitlebar} hideBackButton={hideTitlebar}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bumpList} />}
> >
<div className="px-4 pt-3"> <div key={listKey} className="px-4 pt-3">
<OthersRelayList userId={id} /> <OthersRelayList userId={id} />
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>

25
src/pages/secondary/PostSettingsPage/index.tsx

@ -1,5 +1,7 @@
import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef } from 'react' import { usePrimaryNoteView } from '@/PageManager'
import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import MediaUploadServiceSetting from './MediaUploadServiceSetting' import MediaUploadServiceSetting from './MediaUploadServiceSetting'
import ExpirationSettings from './ExpirationSettings' import ExpirationSettings from './ExpirationSettings'
@ -7,10 +9,27 @@ import QuietSettings from './QuietSettings'
const PostSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const PostSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), [])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bump)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bump])
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Post settings')}> <SecondaryPageLayout
<div className="px-4 pt-3 space-y-6"> ref={ref}
index={index}
title={hideTitlebar ? undefined : t('Post settings')}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />}
>
<div key={contentKey} className="px-4 pt-3 space-y-6">
<MediaUploadServiceSetting /> <MediaUploadServiceSetting />
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-medium">{t('Expiration Tags')}</h3> <h3 className="text-lg font-medium">{t('Expiration Tags')}</h3>

17
src/pages/secondary/ProfileListPage/index.tsx

@ -1,13 +1,16 @@
import { Favicon } from '@/components/Favicon' import { Favicon } from '@/components/Favicon'
import ProfileList from '@/components/ProfileList' import ProfileList from '@/components/ProfileList'
import { ProfileListBySearch } from '@/components/ProfileListBySearch' import { ProfileListBySearch } from '@/components/ProfileListBySearch'
import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { fetchPubkeysFromDomain } from '@/lib/nip05' import { fetchPubkeysFromDomain } from '@/lib/nip05'
import { forwardRef, useEffect, useState } from 'react' import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => { const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const [listKey, setListKey] = useState(0)
const bumpList = useCallback(() => setListKey((k) => k + 1), [])
const [title, setTitle] = useState<React.ReactNode>() const [title, setTitle] = useState<React.ReactNode>()
const [data, setData] = useState<{ const [data, setData] = useState<{
type: 'search' | 'domain' type: 'search' | 'domain'
@ -44,8 +47,16 @@ const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => {
} }
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={title} displayScrollToTopButton> <SecondaryPageLayout
{content} ref={ref}
index={index}
title={title}
controls={<RefreshButton onClick={bumpList} />}
displayScrollToTopButton
>
<div key={listKey} className="min-w-0">
{content}
</div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )
}) })

27
src/pages/secondary/ProfilePage/index.tsx

@ -1,7 +1,9 @@
import Profile from '@/components/Profile' import Profile from '@/components/Profile'
import { RefreshButton } from '@/components/RefreshButton'
import { useFetchProfile } from '@/hooks' import { useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef, useEffect } from 'react' import { usePrimaryNoteView } from '@/PageManager'
import { forwardRef, useCallback, useEffect, useRef } from 'react'
// Helper function to update or create meta tags // Helper function to update or create meta tags
function updateMetaTag(property: string, content: string) { function updateMetaTag(property: string, content: string) {
@ -25,6 +27,19 @@ function updateMetaTag(property: string, content: string) {
} }
const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; index?: number; hideTitlebar?: boolean }, ref) => { const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; index?: number; hideTitlebar?: boolean }, ref) => {
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const feedRef = useRef<{ refresh: () => void }>(null)
const bumpFeed = useCallback(() => feedRef.current?.refresh(), [])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bumpFeed)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpFeed])
const { profile } = useFetchProfile(id) const { profile } = useFetchProfile(id)
// Update OpenGraph metadata to match fallback card format for profiles // Update OpenGraph metadata to match fallback card format for profiles
@ -117,8 +132,14 @@ const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: stri
}, [profile]) }, [profile])
return ( return (
<SecondaryPageLayout index={index} title={hideTitlebar ? undefined : profile?.username} displayScrollToTopButton ref={ref}> <SecondaryPageLayout
<Profile id={id} /> index={index}
title={hideTitlebar ? undefined : profile?.username}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bumpFeed} />}
displayScrollToTopButton
ref={ref}
>
<Profile id={id} feedRef={feedRef} />
</SecondaryPageLayout> </SecondaryPageLayout>
) )
}) })

28
src/pages/secondary/RelayPage/index.tsx

@ -1,20 +1,42 @@
import type { TNoteListRef } from '@/components/NoteList'
import Relay from '@/components/Relay' import Relay from '@/components/Relay'
import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/PageManager'
import { normalizeUrl, simplifyUrl } from '@/lib/url' import { normalizeUrl, simplifyUrl } from '@/lib/url'
import { forwardRef, useMemo } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'
import NotFoundPage from '../NotFoundPage' import NotFoundPage from '../NotFoundPage'
const RelayPage = forwardRef(({ url, index, hideTitlebar = false }: { url?: string; index?: number; hideTitlebar?: boolean }, ref) => { const RelayPage = forwardRef(({ url, index, hideTitlebar = false }: { url?: string; index?: number; hideTitlebar?: boolean }, ref) => {
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const feedRef = useRef<TNoteListRef>(null)
const bumpFeed = useCallback(() => feedRef.current?.refresh(), [])
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url]) const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url])
const title = useMemo(() => (url ? simplifyUrl(url) : undefined), [url]) const title = useMemo(() => (url ? simplifyUrl(url) : undefined), [url])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bumpFeed)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpFeed])
if (!normalizedUrl) { if (!normalizedUrl) {
return <NotFoundPage ref={ref} /> return <NotFoundPage ref={ref} />
} }
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : title} displayScrollToTopButton> <SecondaryPageLayout
<Relay url={normalizedUrl} /> ref={ref}
index={index}
title={hideTitlebar ? undefined : title}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bumpFeed} />}
displayScrollToTopButton
>
<Relay ref={feedRef} url={normalizedUrl} />
</SecondaryPageLayout> </SecondaryPageLayout>
) )
}) })

27
src/pages/secondary/RelayReviewsPage/index.tsx

@ -1,13 +1,29 @@
import type { TNoteListRef } from '@/components/NoteList'
import NoteList from '@/components/NoteList' import NoteList from '@/components/NoteList'
import { RefreshButton } from '@/components/RefreshButton'
import { FAST_READ_RELAY_URLS, ExtendedKind } from '@/constants' import { FAST_READ_RELAY_URLS, ExtendedKind } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/PageManager'
import { normalizeUrl, simplifyUrl } from '@/lib/url' import { normalizeUrl, simplifyUrl } from '@/lib/url'
import { forwardRef, useMemo } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import NotFoundPage from '../NotFoundPage' import NotFoundPage from '../NotFoundPage'
const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url?: string; index?: number; hideTitlebar?: boolean }, ref) => { const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url?: string; index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const feedRef = useRef<TNoteListRef>(null)
const bumpFeed = useCallback(() => feedRef.current?.refresh(), [])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bumpFeed)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpFeed])
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url]) const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url])
const title = useMemo( const title = useMemo(
() => (url ? t('Reviews for {{relay}}', { relay: simplifyUrl(url) }) : undefined), () => (url ? t('Reviews for {{relay}}', { relay: simplifyUrl(url) }) : undefined),
@ -19,8 +35,15 @@ const RelayReviewsPage = forwardRef(({ url, index, hideTitlebar = false }: { url
} }
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : title} displayScrollToTopButton> <SecondaryPageLayout
ref={ref}
index={index}
title={hideTitlebar ? undefined : title}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bumpFeed} />}
displayScrollToTopButton
>
<NoteList <NoteList
ref={feedRef}
showKinds={[ExtendedKind.RELAY_REVIEW]} showKinds={[ExtendedKind.RELAY_REVIEW]}
subRequests={[ subRequests={[
{ {

25
src/pages/secondary/RelaySettingsPage/index.tsx

@ -1,13 +1,18 @@
import MailboxSetting from '@/components/MailboxSetting' import MailboxSetting from '@/components/MailboxSetting'
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting' import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
import SessionRelaysTab from '@/components/SessionRelaysTab' import SessionRelaysTab from '@/components/SessionRelaysTab'
import { RefreshButton } from '@/components/RefreshButton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef, useEffect, useState } from 'react' import { usePrimaryNoteView } from '@/PageManager'
import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), [])
const [tabValue, setTabValue] = useState('favorite-relays') const [tabValue, setTabValue] = useState('favorite-relays')
useEffect(() => { useEffect(() => {
@ -24,9 +29,23 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
} }
}, []) }, [])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bump)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bump])
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Relays and Storage Settings')}> <SecondaryPageLayout
<Tabs value={tabValue} onValueChange={setTabValue} className="px-4 py-3 space-y-4"> ref={ref}
index={index}
title={hideTitlebar ? undefined : t('Relays and Storage Settings')}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />}
>
<Tabs key={contentKey} value={tabValue} onValueChange={setTabValue} className="px-4 py-3 space-y-4">
<TabsList className="flex-col sm:flex-row h-auto sm:h-9"> <TabsList className="flex-col sm:flex-row h-auto sm:h-9">
<TabsTrigger value="favorite-relays" className="w-full sm:w-auto">{t('Favorite Relays')}</TabsTrigger> <TabsTrigger value="favorite-relays" className="w-full sm:w-auto">{t('Favorite Relays')}</TabsTrigger>
<TabsTrigger value="mailbox" className="w-full sm:w-auto">{t('Read & Write Relays')}</TabsTrigger> <TabsTrigger value="mailbox" className="w-full sm:w-auto">{t('Read & Write Relays')}</TabsTrigger>

95
src/pages/secondary/RssArticlePage/index.tsx

@ -2,12 +2,14 @@ import NoteInteractions from '@/components/NoteInteractions'
import NoteStats from '@/components/NoteStats' import NoteStats from '@/components/NoteStats'
import RssFeedItem from '@/components/RssFeedItem' import RssFeedItem from '@/components/RssFeedItem'
import WebPreview from '@/components/WebPreview' import WebPreview from '@/components/WebPreview'
import { RefreshButton } from '@/components/RefreshButton'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import type { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' import type { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/PageManager'
import { decodeRssArticlePathSegment, createRssThreadRootEvent } from '@/lib/rss-article' import { decodeRssArticlePathSegment, createRssThreadRootEvent } from '@/lib/rss-article'
import { forwardRef, useEffect, useMemo, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -28,6 +30,8 @@ const RssArticlePage = forwardRef(
ref ref
) => { ) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0)
const [item, setItem] = useState<TRssFeedItem | null>(initialItem ?? null) const [item, setItem] = useState<TRssFeedItem | null>(initialItem ?? null)
const [loading, setLoading] = useState(!initialItem) const [loading, setLoading] = useState(!initialItem)
@ -76,18 +80,63 @@ const RssArticlePage = forwardRef(
} }
}, [hideTitlebar, t, item]) }, [hideTitlebar, t, item])
const refreshArticle = useCallback(async () => {
setContentKey((k) => k + 1)
if (!articleUrl) return
if (initialItem) {
setItem(initialItem)
setLoading(false)
return
}
setLoading(true)
try {
const items = await indexedDb.getRssFeedItems()
const found = items.find((i) => i.link === articleUrl) ?? null
setItem(found)
} finally {
setLoading(false)
}
}, [articleUrl, initialItem])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(() => {
void refreshArticle()
})
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, refreshArticle])
const refreshControls = hideTitlebar ? undefined : <RefreshButton onClick={() => void refreshArticle()} />
if (!articleUrl) { if (!articleUrl) {
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('RSS article')}> <SecondaryPageLayout
<div className="px-4 py-6 text-sm text-muted-foreground">{t('Invalid article link.')}</div> ref={ref}
index={index}
title={hideTitlebar ? undefined : t('RSS article')}
controls={refreshControls}
>
<div key={contentKey} className="px-4 py-6 text-sm text-muted-foreground">
{t('Invalid article link.')}
</div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )
} }
if (loading) { if (loading) {
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('RSS article')}> <SecondaryPageLayout
<div className="px-4 py-6 text-sm text-muted-foreground">{t('Loading…')}</div> ref={ref}
index={index}
title={hideTitlebar ? undefined : t('RSS article')}
controls={refreshControls}
>
<div key={contentKey} className="px-4 py-6 text-sm text-muted-foreground">
{t('Loading…')}
</div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )
} }
@ -98,9 +147,10 @@ const RssArticlePage = forwardRef(
ref={ref} ref={ref}
index={index} index={index}
title={hideTitlebar ? undefined : t('Web page')} title={hideTitlebar ? undefined : t('Web page')}
controls={refreshControls}
displayScrollToTopButton displayScrollToTopButton
> >
<div className="px-4 pt-3 pb-4 w-full space-y-4"> <div key={contentKey} className="px-4 pt-3 pb-4 w-full space-y-4">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('Opened by URL — not from your RSS list. Nostr thread is still tied to this link.')} {t('Opened by URL — not from your RSS list. Nostr thread is still tied to this link.')}
</p> </p>
@ -139,26 +189,29 @@ const RssArticlePage = forwardRef(
ref={ref} ref={ref}
index={index} index={index}
title={hideTitlebar ? undefined : t('RSS article')} title={hideTitlebar ? undefined : t('RSS article')}
controls={refreshControls}
displayScrollToTopButton displayScrollToTopButton
> >
<div className="px-4 pt-3 w-full"> <div key={contentKey} className="min-w-0">
<RssFeedItem item={item} layout="detail" /> <div className="px-4 pt-3 w-full">
</div> <RssFeedItem item={item} layout="detail" />
{syntheticRoot && (
<div className="px-4 w-full">
<NoteStats className="mt-3" event={syntheticRoot} fetchIfNotExisting={false} displayTopZapsAndLikes={false} />
</div> </div>
)}
<Separator className="mt-4" />
<div className="px-4 pb-4 w-full">
{syntheticRoot && ( {syntheticRoot && (
<NoteInteractions <div className="px-4 w-full">
key={`rss-interactions-${syntheticRoot.id}`} <NoteStats className="mt-3" event={syntheticRoot} fetchIfNotExisting={false} displayTopZapsAndLikes={false} />
pageIndex={index} </div>
event={syntheticRoot}
showQuotes={false}
/>
)} )}
<Separator className="mt-4" />
<div className="px-4 pb-4 w-full">
{syntheticRoot && (
<NoteInteractions
key={`rss-interactions-${syntheticRoot.id}`}
pageIndex={index}
event={syntheticRoot}
showQuotes={false}
/>
)}
</div>
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )

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

@ -1,5 +1,9 @@
import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef, useEffect, useState } from 'react' import { usePrimaryNoteView } from '@/PageManager'
import { ExtendedKind, FAST_WRITE_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants'
import { getLatestEvent } from '@/lib/event'
import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -11,7 +15,7 @@ import { createRssFeedListDraftEvent } from '@/lib/draft-event'
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback'
import { CloudUpload, Loader, Trash2, Plus, Download, Upload } from 'lucide-react' import { CloudUpload, Loader, Trash2, Plus, Download, Upload } from 'lucide-react'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { ExtendedKind } from '@/constants' import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import rssFeedService from '@/services/rss-feed.service' import rssFeedService from '@/services/rss-feed.service'
import { parseOpml, generateOpml, downloadFile } from '@/lib/opml' import { parseOpml, generateOpml, downloadFile } from '@/lib/opml'
@ -105,6 +109,48 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
setLoading(false) setLoading(false)
}, [pubkey, rssFeedListEvent]) }, [pubkey, rssFeedListEvent])
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const refreshFromRelays = useCallback(async () => {
if (!pubkey) return
if (hasChange) {
toast.message(t('Save or discard your changes before refreshing from relays'))
return
}
setLoading(true)
try {
const events = await queryService.fetchEvents(FAST_WRITE_RELAY_URLS.concat(PROFILE_RELAY_URLS), {
kinds: [ExtendedKind.RSS_FEED_LIST],
authors: [pubkey],
limit: 1
})
const latest = getLatestEvent(events)
if (latest) {
await indexedDb.putReplaceableEvent(latest)
await updateRssFeedListEvent(latest)
toast.success(t('RSS feed list refreshed'))
} else {
toast.message(t('No RSS feed list found on relays'))
}
} catch (e) {
logger.error('[RssFeedSettingsPage] Refresh from relays failed', { error: e })
toast.error(t('Failed to refresh'))
} finally {
setLoading(false)
}
}, [pubkey, hasChange, t, updateRssFeedListEvent])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(() => {
void refreshFromRelays()
})
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, refreshFromRelays])
const handleShowRssFeedChange = (checked: boolean) => { const handleShowRssFeedChange = (checked: boolean) => {
setShowRssFeed(checked) setShowRssFeed(checked)
storage.setShowRssFeed(checked) storage.setShowRssFeed(checked)
@ -467,14 +513,24 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
if (loading) { if (loading) {
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('RSS Feed Settings')}> <SecondaryPageLayout
ref={ref}
index={index}
title={hideTitlebar ? undefined : t('RSS Feed Settings')}
controls={hideTitlebar ? undefined : <RefreshButton onClick={() => void refreshFromRelays()} />}
>
<div className="text-center text-sm text-muted-foreground py-8">{t('loading...')}</div> <div className="text-center text-sm text-muted-foreground py-8">{t('loading...')}</div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )
} }
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('RSS Feed Settings')}> <SecondaryPageLayout
ref={ref}
index={index}
title={hideTitlebar ? undefined : t('RSS Feed Settings')}
controls={hideTitlebar ? undefined : <RefreshButton onClick={() => void refreshFromRelays()} />}
>
<div className="px-4 pt-3 space-y-6"> <div className="px-4 pt-3 space-y-6">
{/* Show RSS Feed Toggle */} {/* Show RSS Feed Toggle */}
<div className="space-y-2"> <div className="space-y-2">

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

@ -1,17 +1,30 @@
import LatestFromFollowsSection from '@/components/LatestFromFollowsSection' import LatestFromFollowsSection from '@/components/LatestFromFollowsSection'
import { RefreshButton } from '@/components/RefreshButton'
import SearchBar, { TSearchBarRef } from '@/components/SearchBar' import SearchBar, { TSearchBarRef } from '@/components/SearchBar'
import SearchResult from '@/components/SearchResult' import SearchResult from '@/components/SearchResult'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toSearch } from '@/lib/link' import { toSearch } from '@/lib/link'
import { parseAdvancedSearch } from '@/lib/search-parser' import { parseAdvancedSearch } from '@/lib/search-parser'
import { useSecondaryPage } from '@/PageManager' import { usePrimaryNoteView, useSecondaryPage } from '@/PageManager'
import { TSearchParams } from '@/types' import { TSearchParams } from '@/types'
import { BookOpen } from 'lucide-react' import { BookOpen } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const [resultRefreshKey, setResultRefreshKey] = useState(0)
const bumpResults = useCallback(() => setResultRefreshKey((k) => k + 1), [])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bumpResults)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpResults])
const [input, setInput] = useState('') const [input, setInput] = useState('')
const searchBarRef = useRef<TSearchBarRef>(null) const searchBarRef = useRef<TSearchBarRef>(null)
const searchParams = useMemo(() => { const searchParams = useMemo(() => {
@ -99,10 +112,13 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
index={index} index={index}
title={hideTitlebar ? undefined : "Search"} title={hideTitlebar ? undefined : "Search"}
hideBackButton={hideTitlebar} hideBackButton={hideTitlebar}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bumpResults} />}
displayScrollToTopButton displayScrollToTopButton
> >
<div className="px-4 pt-4"> <div className="px-4 pt-4">
<div className="text-2xl font-bold mb-4">Search Nostr</div> <div className="mb-4">
<div className="text-2xl font-bold">Search Nostr</div>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mb-4 relative z-40"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mb-4 relative z-40">
<div className="flex-1 relative order-2 sm:order-1"> <div className="flex-1 relative order-2 sm:order-1">
<SearchBar ref={searchBarRef} input={input} setInput={setInput} onSearch={onSearch} /> <SearchBar ref={searchBarRef} input={input} setInput={setInput} onSearch={onSearch} />
@ -125,14 +141,16 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
</div> </div>
</div> </div>
<div className="h-4"></div> <div className="h-4"></div>
{searchParams ? ( <div key={resultRefreshKey} className="min-w-0">
<SearchResult searchParams={searchParams} /> {searchParams ? (
) : ( <SearchResult searchParams={searchParams} />
<div className="mb-4 min-w-0 space-y-2"> ) : (
<LatestFromFollowsSection /> <div className="mb-4 min-w-0 space-y-2">
<SearchResult searchParams={null} /> <LatestFromFollowsSection />
</div> <SearchResult searchParams={null} />
)} </div>
)}
</div>
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )

27
src/pages/secondary/SettingsPage/index.tsx

@ -1,15 +1,36 @@
import SettingsMenuBody from '@/components/Settings/SettingsMenuBody' import SettingsMenuBody from '@/components/Settings/SettingsMenuBody'
import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef } from 'react' import { usePrimaryNoteView } from '@/PageManager'
import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const SettingsPage = forwardRef( const SettingsPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), [])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bump)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bump])
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Settings')}> <SecondaryPageLayout
<SettingsMenuBody /> ref={ref}
index={index}
title={hideTitlebar ? undefined : t('Settings')}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />}
>
<div key={contentKey} className="min-w-0">
<SettingsMenuBody />
</div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )
} }

25
src/pages/secondary/TranslationPage/index.tsx

@ -1,14 +1,33 @@
import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef } from 'react' import { usePrimaryNoteView } from '@/PageManager'
import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const TranslationPage = forwardRef( const TranslationPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), [])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bump)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bump])
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Translation')}> <SecondaryPageLayout
<div className="px-4 pt-3 space-y-4"> ref={ref}
index={index}
title={hideTitlebar ? undefined : t('Translation')}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />}
>
<div key={contentKey} className="px-4 pt-3 space-y-4">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t( {t(
'To translate notes and other content, use your browser’s built-in translation. For example: right-click the page and choose “Translate to…”, or use the translate icon in the address bar.' 'To translate notes and other content, use your browser’s built-in translation. For example: right-click the page and choose “Translate to…”, or use the translate icon in the address bar.'

25
src/pages/secondary/WalletPage/index.tsx

@ -9,11 +9,13 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger AlertDialogTrigger
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/PageManager'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import { disconnect, launchModal } from '@getalby/bitcoin-connect-react' import { disconnect, launchModal } from '@getalby/bitcoin-connect-react'
import { forwardRef } from 'react' import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import DefaultZapAmountInput from './DefaultZapAmountInput' import DefaultZapAmountInput from './DefaultZapAmountInput'
import DefaultZapCommentInput from './DefaultZapCommentInput' import DefaultZapCommentInput from './DefaultZapCommentInput'
@ -23,11 +25,28 @@ import ZapReplyThresholdInput from './ZapReplyThresholdInput'
const WalletPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const WalletPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), [])
const { isWalletConnected, walletInfo } = useZap() const { isWalletConnected, walletInfo } = useZap()
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bump)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bump])
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Wallet')}> <SecondaryPageLayout
<div className="px-4 pt-3 space-y-4"> ref={ref}
index={index}
title={hideTitlebar ? undefined : t('Wallet')}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />}
>
<div key={contentKey} className="px-4 pt-3 space-y-4">
{isWalletConnected ? ( {isWalletConnected ? (
<> <>
<div> <div>

6
src/types/index.d.ts vendored

@ -181,7 +181,11 @@ export type TNoteListMode = 'posts' | 'postsAndReplies' | 'you' | 'bookmarksAndH
export type TNotificationType = 'all' | 'mentions' | 'reactions' | 'zaps' export type TNotificationType = 'all' | 'mentions' | 'reactions' | 'zaps'
export type TPageRef = { scrollToTop: (behavior?: ScrollBehavior) => void } export type TPageRef = {
scrollToTop: (behavior?: ScrollBehavior) => void
/** Optional: reload the current page’s primary data (feed, profile, note, etc.). */
refresh?: () => void
}
export type TEmoji = { export type TEmoji = {
shortcode: string shortcode: string

Loading…
Cancel
Save