diff --git a/src/App.tsx b/src/App.tsx index 97dd79d5..0c8869c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { Toaster } from '@/components/ui/sonner' import { BookmarksProvider } from '@/providers/BookmarksProvider' import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider' import { DeletedEventProvider } from '@/providers/DeletedEventProvider' +import { FavoriteRelaysActivityProvider } from '@/providers/FavoriteRelaysActivityProvider' import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider' import { FeedProvider } from '@/providers/FeedProvider' import { FontSizeProvider } from '@/providers/FontSizeProvider' @@ -39,26 +40,28 @@ export default function App(): JSX.Element { - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 9503225a..43d77d8b 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -76,6 +76,9 @@ const SidebarLazy = lazy(() => import('@/components/Sidebar')) const BottomNavigationBarLazy = lazy(() => import('@/components/BottomNavigationBar')) const TooManyRelaysAlertDialogLazy = lazy(() => import('@/components/TooManyRelaysAlertDialog')) const CreateWalletGuideToastLazy = lazy(() => import('@/components/CreateWalletGuideToast')) +const RelayPulseActiveNpubsSheetLazy = lazy( + () => import('@/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet').then((m) => ({ default: m.RelayPulseActiveNpubsSheet })) +) type TStackItem = { index: number @@ -314,16 +317,21 @@ export function useSmartNoteNavigation() { const { isSmallScreen } = useScreenSize() const { current: currentPrimaryPage } = usePrimaryPage() - const navigateToNote = (url: string, event?: Event) => { + const navigateToNote = (url: string, event?: Event, relatedEvents?: Event[]) => { // Extract noteId from URL (handles both /notes/{id} and /{context}/notes/{id}) const { noteId } = parseNoteUrl(url) // If event is provided, store it in navigation event store to avoid re-fetching if (event) { navigationEventStore.setEvent(event) - // Also add to cache for future use client.addEventToCache(event) } + // Pre-cache related events (parent, root, embedded) so NotePage avoids re-fetching + if (relatedEvents?.length) { + for (const ev of relatedEvents) { + if (ev && ev !== event) client.addEventToCache(ev) + } + } // Build contextual URL based on current page const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage) @@ -332,7 +340,7 @@ export function useSmartNoteNavigation() { // Mobile: always push to secondary stack AND update drawer // This ensures back button works when clicking embedded events pushSecondaryPage(contextualUrl) - openDrawer(noteId) + openDrawer(noteId, event) } else { // Desktop: check panel mode const currentPanelMode = storage.getPanelMode() @@ -342,11 +350,11 @@ export function useSmartNoteNavigation() { if (isDrawerOpen) { // Navigating from within drawer - push to stack for back button support pushSecondaryPage(contextualUrl) - openDrawer(noteId) + openDrawer(noteId, event) } else { // Opening drawer for first time window.history.pushState(null, '', contextualUrl) - openDrawer(noteId) + openDrawer(noteId, event) } } else { // Double-pane: use secondary panel @@ -751,8 +759,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } // Drawer handlers - const openDrawer = useCallback((noteId: string) => { + const [drawerInitialEvent, setDrawerInitialEvent] = useState(null) + const openDrawer = useCallback((noteId: string, initialEvent?: Event) => { setDrawerNoteId(noteId) + setDrawerInitialEvent(initialEvent ?? null) setDrawerOpen(true) }, []) @@ -1126,6 +1136,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { setDrawerOpen(false) setTimeout(() => { setDrawerNoteId(null) + setDrawerInitialEvent(null) // Restore URL to current primary page const pageUrl = buildPrimaryPageUrl( currentPrimaryPage, @@ -1184,6 +1195,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const historyUrl = state!.url setTimeout(() => { setDrawerNoteId(null) + setDrawerInitialEvent(null) // Ensure URL matches the primary page (preserve /spells?spell=) const pageUrl = restoredPrimaryBrowserUrl(pathname, historyUrl) window.history.replaceState(null, '', pageUrl) @@ -1460,7 +1472,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { if (drawerOpen && secondaryStack.length === 0) { // Close drawer and reveal the background page setDrawerOpen(false) - setTimeout(() => setDrawerNoteId(null), 350) + setTimeout(() => { + setDrawerNoteId(null) + setDrawerInitialEvent(null) + }, 350) return } @@ -1468,7 +1483,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { if ((isSmallScreen || panelMode === 'single') && secondaryStack.length === 1 && drawerOpen) { // Close drawer (this will restore the URL to the correct primary page) setDrawerOpen(false) - setTimeout(() => setDrawerNoteId(null), 350) + setTimeout(() => { + setDrawerNoteId(null) + setDrawerInitialEvent(null) + }, 350) // Clear stack setSecondaryStack([]) @@ -1558,7 +1576,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { triggerPrimaryPanelRefresh }} > - + {primaryNoteView ? ( // Show primary note view with back button on mobile
@@ -1629,8 +1647,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { )} {drawerNoteId && ( - { setDrawerOpen(open) // Only clear noteId when Sheet is fully closed (after animation completes) @@ -1642,10 +1661,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined ) window.history.replaceState(null, '', pageUrl) - setTimeout(() => setDrawerNoteId(null), 350) + setTimeout(() => { + setDrawerNoteId(null) + setDrawerInitialEvent(null) + }, 350) } - }} - noteId={drawerNoteId} + }} + noteId={drawerNoteId} /> )} @@ -1657,6 +1679,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { + + + @@ -1684,7 +1709,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { triggerPrimaryPanelRefresh }} > - +
{drawerNoteId && ( - { setDrawerOpen(open) // Only clear noteId when Sheet is fully closed (after animation completes) @@ -1769,10 +1795,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined ) window.history.replaceState(null, '', pageUrl) - setTimeout(() => setDrawerNoteId(null), 350) + setTimeout(() => { + setDrawerNoteId(null) + setDrawerInitialEvent(null) + }, 350) } - }} - noteId={drawerNoteId} + }} + noteId={drawerNoteId} /> )} {/* Generic drawer for secondary stack in single-pane mode (for relay pages, etc.) */} @@ -1807,6 +1836,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { + + + diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index f9a8b0e0..c7fce2a6 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -572,9 +572,8 @@ function EmbeddedBookstrEvent({ event, originalNoteId, className }: { event: Eve return } e.stopPropagation() - client.addEventToCache(event) const noteUrl = toNote(originalNoteId ?? event) - navigateToNote(noteUrl) + navigateToNote(noteUrl, event) }} > {/* Header */} diff --git a/src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx b/src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx new file mode 100644 index 00000000..82683d6e --- /dev/null +++ b/src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx @@ -0,0 +1,173 @@ +import UserAvatar from '@/components/UserAvatar' +import { Button } from '@/components/ui/button' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet' +import { getProfileFromEvent } from '@/lib/event-metadata' +import { toProfile } from '@/lib/link' +import { + collectAggregatedNip05sFromKind0, + truncateAbout +} from '@/lib/relay-pulse-nip05' +import { useMuteList } from '@/contexts/mute-list-context' +import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context' +import { SecondaryPageLink } from '@/PageManager' +import type { Event } from 'nostr-tools' +import { Users } from 'lucide-react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +const ABOUT_PREVIEW_LEN = 250 + +function CompactProfileCard({ event }: { event: Event }) { + const profile = getProfileFromEvent(event) + const nip05s = collectAggregatedNip05sFromKind0(event) + const about = truncateAbout(profile.about, ABOUT_PREVIEW_LEN) + + return ( +
+
+ +
+ + {profile.username} + + {about ? ( +

+ {about} +

+ ) : null} + {nip05s.length > 0 ? ( +
    + {nip05s.map((id) => ( +
  • + {id} +
  • + ))} +
+ ) : null} +
+
+
+ ) +} + +export function RelayPulseActiveNpubsOpenButton({ + className, + size = 'sm', + variant = 'outline' +}: { + className?: string + size?: 'sm' | 'icon' + variant?: 'outline' | 'ghost' +}) { + const { t } = useTranslation() + const { setActiveNpubsDrawerOpen, totalCount } = useFavoriteRelaysActivity() + + if (totalCount === 0) return null + + return ( + + ) +} + +/** Mounted once inside {@link FavoriteRelaysActivityProvider}. */ +export function RelayPulseActiveNpubsSheet() { + const { t } = useTranslation() + const { mutePubkeySet } = useMuteList() + const { + activeNpubsDrawerOpen, + setActiveNpubsDrawerOpen, + followPubkeys, + otherPubkeys, + profileKind0ByPubkey, + profilesLoading + } = useFavoriteRelaysActivity() + + const followWithProfile = useMemo( + () => + followPubkeys.filter( + (pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) + ), + [followPubkeys, profileKind0ByPubkey, mutePubkeySet] + ) + const othersWithProfile = useMemo( + () => + otherPubkeys.filter( + (pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) + ), + [otherPubkeys, profileKind0ByPubkey, mutePubkeySet] + ) + + return ( + + + + {t('Relay pulse active npubs')} + {t('Relay pulse active npubs hint')} + +
+ {profilesLoading ? ( +

{t('Loading...')}

+ ) : null} +
+ {followWithProfile.length > 0 ? ( +
+

+ {t('Relay pulse drawer following')} +

+
+ {followWithProfile.map((pk) => { + const ev = profileKind0ByPubkey[pk] + return ev ? : null + })} +
+
+ ) : null} + {othersWithProfile.length > 0 ? ( +
+

+ {t('Relay pulse drawer others')} +

+
+ {othersWithProfile.map((pk) => { + const ev = profileKind0ByPubkey[pk] + return ev ? : null + })} +
+
+ ) : null} + {!profilesLoading && + followWithProfile.length === 0 && + othersWithProfile.length === 0 ? ( +

{t('Relay pulse drawer no profiles')}

+ ) : null} +
+
+
+
+ ) +} diff --git a/src/components/FavoriteRelaysActiveStrip/index.tsx b/src/components/FavoriteRelaysActiveStrip/index.tsx new file mode 100644 index 00000000..9e799fd8 --- /dev/null +++ b/src/components/FavoriteRelaysActiveStrip/index.tsx @@ -0,0 +1,378 @@ +import UserAvatar from '@/components/UserAvatar' +import { SimpleUsername } from '@/components/Username' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' +import { cn } from '@/lib/utils' +import { useMuteList } from '@/contexts/mute-list-context' +import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context' +import { RelayPulseActiveNpubsOpenButton } from './RelayPulseActiveNpubsSheet' +import type { TFunction } from 'i18next' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const MOBILE_MAX_FOLLOW = 8 +const MOBILE_MAX_OTHER = 8 +const SIDEBAR_MAX_FOLLOW = 5 +const SIDEBAR_MAX_OTHER = 5 + +/** Slight overlap so faces stay recognizable */ +const AVATAR_OVERLAP = '-ml-1' + +function relativePastPhrase(timestampMs: number, t: TFunction): string { + const sec = Math.floor((Date.now() - timestampMs) / 1000) + if (sec < 45) return t('just now') + const min = Math.floor(sec / 60) + if (min < 60) return t('n minutes ago', { n: min }) + const h = Math.floor(min / 60) + if (h < 48) return t('n hours ago', { n: h }) + const d = Math.floor(h / 24) + return t('n days ago', { n: d }) +} + +function useRelativePastPhrase(timestampMs: number | null, t: TFunction): string { + const [tick, setTick] = useState(0) + useEffect(() => { + if (timestampMs == null) return + const id = window.setInterval(() => setTick((x) => x + 1), 30_000) + return () => clearInterval(id) + }, [timestampMs]) + return useMemo(() => { + if (timestampMs == null) return '' + return relativePastPhrase(timestampMs, t) + }, [timestampMs, t, tick]) +} + +function OverlappingAvatars({ + pubkeys, + max, + avatarSize, + rowClassName, + scrollableRow = false +}: { + pubkeys: string[] + max: number + avatarSize: 'small' | 'xSmall' | 'tiny' + rowClassName?: string + /** Narrow screens: horizontal scroll inside the viewport instead of overflowing the page */ + scrollableRow?: boolean +}) { + const slice = pubkeys.slice(0, max) + const extra = pubkeys.length - slice.length + + const row = ( +
+ {slice.map((pk, i) => ( + + +
0 && AVATAR_OVERLAP + )} + style={{ zIndex: i + 1 }} + > + +
+
+ + + +
+ ))} + {extra > 0 ? ( +
0 && AVATAR_OVERLAP + )} + title={String(extra)} + > + +{extra > 99 ? '99+' : extra} +
+ ) : null} +
+ ) + + if (scrollableRow) { + return ( +
+ {row} +
+ ) + } + + return ( +
+ {row} +
+ ) +} + +function ActiveAvatarGroups({ + followPubkeysForAvatars, + otherPubkeysForAvatars, + followCount, + otherCount, + maxFollow, + maxOther, + avatarSize, + labelClassName, + stackClassName, + variant = 'default' +}: { + /** Subset with kind 0 only (shown as circles); counts use full totals */ + followPubkeysForAvatars: string[] + otherPubkeysForAvatars: string[] + followCount: number + otherCount: number + maxFollow: number + maxOther: number + avatarSize: 'small' | 'xSmall' | 'tiny' + labelClassName: string + stackClassName?: string + /** Mobile home: label above avatars + scrollable rows; sidebar/default keeps compact rows on wider mini breakpoints */ + variant?: 'default' | 'mobileBar' +}) { + const { t } = useTranslation() + const mobileBar = variant === 'mobileBar' + const groupRowClass = mobileBar + ? 'flex w-full min-w-0 flex-col gap-1.5' + : 'flex min-w-0 flex-col gap-1 min-[380px]:flex-row min-[380px]:items-center min-[380px]:gap-2' + + return ( +
+ {followCount > 0 ? ( +
+ + {t('Relay pulse follows', { count: followCount })} + + +
+ ) : null} + {otherCount > 0 ? ( +
+ + {t('Relay pulse others', { count: otherCount })} + + +
+ ) : null} +
+ ) +} + +/** Home feed / mobile: full label above the page title */ +export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: string }) { + const { t } = useTranslation() + const { mutePubkeySet } = useMuteList() + const { + followPubkeys, + otherPubkeys, + followCount, + otherCount, + totalCount, + loading, + relayActivityReady, + lastFetchedAtMs, + profileKind0ByPubkey + } = useFavoriteRelaysActivity() + + const followPubkeysForAvatars = useMemo( + () => + followPubkeys.filter( + (pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) + ), + [followPubkeys, profileKind0ByPubkey, mutePubkeySet] + ) + const otherPubkeysForAvatars = useMemo( + () => + otherPubkeys.filter( + (pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) + ), + [otherPubkeys, profileKind0ByPubkey, mutePubkeySet] + ) + + const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t) + + if (!relayActivityReady && !loading) { + return null + } + + if (relayActivityReady && !loading && totalCount === 0) { + return ( +
+

{t('Relay pulse')}

+ {lastFetchedAtMs != null && relativeLabel ? ( +

+ {t('Relay pulse updated', { relative: relativeLabel })} +

+ ) : null} +

+ {t('Relay pulse empty')} +

+
+ ) + } + + return ( +
+
+
+
+

{t('Relay pulse')}

+ +
+ {lastFetchedAtMs != null && relativeLabel ? ( +

+ {t('Relay pulse updated', { relative: relativeLabel })} +

+ ) : null} +
+ +
+
+ ) +} + +/** Desktop sidebar: compact row under nav */ +export function FavoriteRelaysActiveStripSidebar({ className }: { className?: string }) { + const { t } = useTranslation() + const { mutePubkeySet } = useMuteList() + const { + followPubkeys, + otherPubkeys, + followCount, + otherCount, + totalCount, + loading, + relayActivityReady, + lastFetchedAtMs, + profileKind0ByPubkey + } = useFavoriteRelaysActivity() + + const followPubkeysForAvatars = useMemo( + () => + followPubkeys.filter( + (pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) + ), + [followPubkeys, profileKind0ByPubkey, mutePubkeySet] + ) + const otherPubkeysForAvatars = useMemo( + () => + otherPubkeys.filter( + (pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) + ), + [otherPubkeys, profileKind0ByPubkey, mutePubkeySet] + ) + + const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t) + + if (!relayActivityReady && !loading) { + return null + } + + if (relayActivityReady && !loading && totalCount === 0) { + return ( +
+
+

{t('Relay pulse')}

+ +
+ {lastFetchedAtMs != null && relativeLabel ? ( +

+ {t('Relay pulse updated', { relative: relativeLabel })} +

+ ) : null} +

+ {t('Relay pulse empty')} +

+
+ ) + } + + return ( +
+
+

+ {t('Relay pulse')} +

+ +
+ {lastFetchedAtMs != null && relativeLabel ? ( +

+ {t('Relay pulse updated', { relative: relativeLabel })} +

+ ) : null} +
+ +
+
+ +
+
+ ) +} diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx index 82cde8bf..bb1862b5 100644 --- a/src/components/Note/Zap.tsx +++ b/src/components/Note/Zap.tsx @@ -75,7 +75,7 @@ export default function Zap({ event, className }: { event: Event; className?: st if (isEventZap) { // Event zap - navigate to the zapped event if (targetEvent) { - navigateToNote(toNote(targetEvent.id)) + navigateToNote(toNote(targetEvent.id), targetEvent) } else if (zapInfo.eventId) { navigateToNote(toNote(zapInfo.eventId)) } diff --git a/src/components/NoteDrawer/index.tsx b/src/components/NoteDrawer/index.tsx index d1c20c6f..336d55d5 100644 --- a/src/components/NoteDrawer/index.tsx +++ b/src/components/NoteDrawer/index.tsx @@ -1,14 +1,16 @@ import { useState, useEffect, useRef } from 'react' import { Sheet, SheetContent } from '@/components/ui/sheet' import NotePage from '@/pages/secondary/NotePage' +import type { Event } from 'nostr-tools' interface NoteDrawerProps { open: boolean onOpenChange: (open: boolean) => void noteId: string | null + initialEvent?: Event | null } -export default function NoteDrawer({ open, onOpenChange, noteId }: NoteDrawerProps) { +export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }: NoteDrawerProps) { const [displayNoteId, setDisplayNoteId] = useState(noteId) const timeoutRef = useRef(null) @@ -43,7 +45,7 @@ export default function NoteDrawer({ open, onOpenChange, noteId }: NoteDrawerPro
- +
diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index 7fbd420d..eaabee50 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -66,8 +66,7 @@ export default function ReplyNote({ if (onClickReply) { onClickReply(event) } else { - client.addEventToCache(event) - navigateToNote(toNote(event)) + navigateToNote(toNote(event), event) } }} > diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index e7449d44..135cdfbc 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -10,6 +10,7 @@ import PostButton from './PostButton' import RssButton from './RssButton' import SearchButton from './SearchButton' import SpellsButton from './SpellsButton' +import { FavoriteRelaysActiveStripSidebar } from '@/components/FavoriteRelaysActiveStrip' import PaneModeToggle from './PaneModeToggle' import DownloadDesktopSidebarButton from './DownloadDesktopSidebarButton' @@ -36,6 +37,7 @@ export default function PrimaryPageSidebar() { +
diff --git a/src/contexts/note-drawer-context.tsx b/src/contexts/note-drawer-context.tsx index b648320d..51ca906b 100644 --- a/src/contexts/note-drawer-context.tsx +++ b/src/contexts/note-drawer-context.tsx @@ -1,10 +1,12 @@ import { createContext, useContext } from 'react' +import type { Event } from 'nostr-tools' export type NoteDrawerContextValue = { - openDrawer: (noteId: string) => void + openDrawer: (noteId: string, initialEvent?: Event) => void closeDrawer: () => void isDrawerOpen: boolean drawerNoteId: string | null + drawerInitialEvent: Event | null } /** diff --git a/src/hooks/useFetchEvent.tsx b/src/hooks/useFetchEvent.tsx index a715f83e..af42cfb7 100644 --- a/src/hooks/useFetchEvent.tsx +++ b/src/hooks/useFetchEvent.tsx @@ -1,3 +1,4 @@ +import { getNoteBech32Id } from '@/lib/event' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useReply } from '@/providers/ReplyProvider' import { eventService } from '@/services/client.service' @@ -27,11 +28,18 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) { const skipShortcuts = refetchToken > 0 // If we have an initial event that matches the eventId, use it and skip fetching - if ( - !skipShortcuts && + const initialMatches = initialEvent && - (initialEvent.id === eventId || eventId.includes(initialEvent.id)) - ) { + (initialEvent.id === eventId || + eventId.includes(initialEvent.id) || + (() => { + try { + return getNoteBech32Id(initialEvent) === eventId + } catch { + return false + } + })()) + if (!skipShortcuts && initialMatches && initialEvent) { if (!isEventDeleted(initialEvent)) { setEvent(initialEvent) addReplies([initialEvent]) diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 13d9584f..fdac6d13 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -9,6 +9,17 @@ export default { Home: 'Startseite', Feed: 'Feed', 'Favorite Relays': 'Lieblings-Relays', + 'Relay pulse': 'Relay-Puls', + 'Relay pulse empty': 'In der letzten Stunde war es ruhig auf deinen Relays.', + 'Relay pulse follows': 'Folge ich ({{count}})', + 'Relay pulse others': 'Andere ({{count}})', + 'Relay pulse updated': 'Aktualisiert {{relative}}', + 'Relay pulse active npubs': 'Aktive npubs', + 'Relay pulse active npubs hint': + 'Kind-0-Profile für npubs, die in der letzten Stunde auf deinen Lieblingsrelais auftauchten (gleiche Stichprobe wie Relay-Puls).', + 'Relay pulse drawer following': 'Folge ich', + 'Relay pulse drawer others': 'Andere', + 'Relay pulse drawer no profiles': 'Für diese Stichprobe wurden noch keine Kind-0-Profile geladen.', 'All favorite relays': 'Alle Lieblingsrelais', 'Pinned note': 'Angehefteter Beitrag', 'Relay settings': 'Relay-Einstellungen', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 3dfaaae5..23481ca6 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -7,6 +7,17 @@ export default { Home: 'Home', Feed: 'Feed', 'Favorite Relays': 'Favorite Relays', + 'Relay pulse': 'Relay pulse', + 'Relay pulse empty': 'Quiet on your relays in the last hour.', + 'Relay pulse follows': 'Following ({{count}})', + 'Relay pulse others': 'Others ({{count}})', + 'Relay pulse updated': 'Updated {{relative}}', + 'Relay pulse active npubs': 'Active npubs', + 'Relay pulse active npubs hint': + 'Kind 0 profiles for pubkeys seen on your favorite relays in the last hour (same sample as Relay pulse).', + 'Relay pulse drawer following': 'Following', + 'Relay pulse drawer others': 'Others', + 'Relay pulse drawer no profiles': 'No kind 0 profiles loaded for this sample yet.', 'All favorite relays': 'All favorite relays', 'Pinned note': 'Pinned note', 'Relay settings': 'Relays and Storage Settings', diff --git a/src/lib/relay-pulse-nip05.ts b/src/lib/relay-pulse-nip05.ts new file mode 100644 index 00000000..5fc5cc21 --- /dev/null +++ b/src/lib/relay-pulse-nip05.ts @@ -0,0 +1,36 @@ +import type { Event } from 'nostr-tools' + +function addNip05(set: Set, raw: unknown) { + if (typeof raw !== 'string') return + const t = raw.trim() + if (t) set.add(t) +} + +/** + * All NIP-05 identifiers from kind 0: every `nip05` tag plus JSON `nip05` (string or string array). + * Deduplicated, order not preserved. + */ +export function collectAggregatedNip05sFromKind0(event: Event): string[] { + const set = new Set() + for (const tag of event.tags) { + if (tag[0] === 'nip05' && tag[1]) addNip05(set, tag[1]) + } + try { + const obj = JSON.parse(event.content || '{}') as Record + const j = obj.nip05 + if (typeof j === 'string') addNip05(set, j) + else if (Array.isArray(j)) { + for (const x of j) addNip05(set, x) + } + } catch { + // ignore invalid JSON + } + return [...set] +} + +export function truncateAbout(about: string | undefined, maxLen: number): string { + if (!about) return '' + const t = about.trim() + if (t.length <= maxLen) return t + return `${t.slice(0, maxLen)}…` +} diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index ea030e27..4f00d9e7 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -24,13 +24,13 @@ import React, { useState } from 'react' import { useTranslation } from 'react-i18next' +import { FavoriteRelaysActiveStripMobileBar } from '@/components/FavoriteRelaysActiveStrip' import FavoriteRelaysFeedPicker from '@/components/FavoriteRelaysFeedPicker' import HelpAndAccountMenu from '@/components/HelpAndAccountMenu' import FollowingFeed from './FollowingFeed' import RelaysFeed from './RelaysFeed' import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' - const NoteListPage = forwardRef((_, ref) => { const { t } = useTranslation() const { addRelayUrls, removeRelayUrls } = useCurrentRelays() @@ -39,6 +39,7 @@ const NoteListPage = forwardRef((_, ref) => { const bookmarkRef = useRef<{ refresh: () => void }>(null) const { pubkey, checkLogin } = useNostr() const { feedInfo, relayUrls, isReady } = useFeed() + const { isSmallScreen } = useScreenSize() const [showRelayDetails, setShowRelayDetails] = useState(false) const [homeSubHeader, setHomeSubHeader] = useState(null) @@ -168,6 +169,7 @@ const NoteListPage = forwardRef((_, ref) => { const subHeader = ( <> + {isSmallScreen ? : null}

{feedPageTitle}

diff --git a/src/providers/FavoriteRelaysActivityProvider.tsx b/src/providers/FavoriteRelaysActivityProvider.tsx new file mode 100644 index 00000000..f5c8c24e --- /dev/null +++ b/src/providers/FavoriteRelaysActivityProvider.tsx @@ -0,0 +1,260 @@ +import logger from '@/lib/logger' +import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' +import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useFollowListOptional } from '@/providers/FollowListProvider' +import { useNostr } from '@/providers/NostrProvider' +import { queryService, replaceableEventService } from '@/services/client.service' +import type { Event } from 'nostr-tools' +import { kinds } from 'nostr-tools' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + FavoriteRelaysActivityContext, + type TFavoriteRelaysActivityContext +} from './favorite-relays-activity-context' + +const ACTIVE_WINDOW_SEC = 3600 +/** Wall-clock cadence while the tab is visible */ +const POLL_INTERVAL_MS = 60 * 60 * 1000 +/** Enough events to surface many distinct authors without overloading relays */ +const REQ_LIMIT = 400 + +function aggregatePubkeysByRecency(events: { pubkey: string; created_at: number }[]): string[] { + const lastByPk = new Map() + for (const e of events) { + const prev = lastByPk.get(e.pubkey) ?? 0 + if (e.created_at > prev) lastByPk.set(e.pubkey, e.created_at) + } + return [...lastByPk.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([pk]) => pk) +} + +function partitionByFollows(orderedPubkeys: string[], followings: string[]) { + if (followings.length === 0) { + return { + followPubkeys: [] as string[], + otherPubkeys: orderedPubkeys, + followCount: 0, + otherCount: orderedPubkeys.length + } + } + const followSet = new Set( + followings.map((p) => normalizeHexPubkey(p)).filter((p) => p.length === 64) + ) + const followPubkeys: string[] = [] + const otherPubkeys: string[] = [] + for (const pk of orderedPubkeys) { + const normalized = normalizeHexPubkey(pk) + if (normalized.length === 64 && followSet.has(normalized)) followPubkeys.push(pk) + else otherPubkeys.push(pk) + } + return { + followPubkeys, + otherPubkeys, + followCount: followPubkeys.length, + otherCount: otherPubkeys.length + } +} + +export function FavoriteRelaysActivityProvider({ children }: { children: React.ReactNode }) { + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const followList = useFollowListOptional() + const followings = followList?.followings ?? [] + const { pubkey: viewerPubkey } = useNostr() + const [orderedPubkeys, setOrderedPubkeys] = useState([]) + const [loading, setLoading] = useState(false) + const [relayActivityReady, setRelayActivityReady] = useState(false) + const [lastFetchedAtMs, setLastFetchedAtMs] = useState(null) + const [profileKind0ByPubkey, setProfileKind0ByPubkey] = useState>({}) + const [profilesLoading, setProfilesLoading] = useState(false) + const [activeNpubsDrawerOpen, setActiveNpubsDrawerOpen] = useState(false) + const lastCompletedFetchAtRef = useRef(Date.now()) + const relayKey = useMemo( + () => getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays).join('\n'), + [favoriteRelays, blockedRelays] + ) + + const fetchActive = useCallback(async () => { + const urls = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) + if (urls.length === 0) { + setOrderedPubkeys([]) + setProfileKind0ByPubkey({}) + setLoading(false) + setRelayActivityReady(true) + const now = Date.now() + lastCompletedFetchAtRef.current = now + setLastFetchedAtMs(now) + return + } + setLoading(true) + const since = Math.floor(Date.now() / 1000) - ACTIVE_WINDOW_SEC + try { + const events = await queryService.fetchEvents( + urls, + { since, limit: REQ_LIMIT }, + { + firstRelayResultGraceMs: false, + eoseTimeout: 1800, + globalTimeout: 14_000 + } + ) + setOrderedPubkeys(aggregatePubkeysByRecency(events)) + } catch (error) { + logger.debug('[FavoriteRelaysActivity] fetch failed', { error }) + setOrderedPubkeys([]) + setProfileKind0ByPubkey({}) + } finally { + setLoading(false) + setRelayActivityReady(true) + const now = Date.now() + lastCompletedFetchAtRef.current = now + setLastFetchedAtMs(now) + } + }, [favoriteRelays, blockedRelays]) + + const fetchRef = useRef(fetchActive) + fetchRef.current = fetchActive + + /** Favorite relay set changed after initial hydration — refresh snapshot (not the hourly cadence). */ + const prevRelayKeyRef = useRef(undefined) + useEffect(() => { + if (prevRelayKeyRef.current === undefined) { + prevRelayKeyRef.current = relayKey + return + } + if (prevRelayKeyRef.current === relayKey) return + prevRelayKeyRef.current = relayKey + void fetchRef.current() + }, [relayKey]) + + /** Logged-in user changed — refetch for the new account. Follow list changes update partition via useMemo. */ + const prevViewerRef = useRef(undefined) + useEffect(() => { + if (prevViewerRef.current !== undefined && prevViewerRef.current !== viewerPubkey) { + void fetchRef.current() + } + prevViewerRef.current = viewerPubkey ?? undefined + }, [viewerPubkey]) + + /** While the document is visible: poll once per hour; when returning after a long background, catch up if due. */ + useEffect(() => { + let intervalId: ReturnType | undefined + + const runTick = () => { + void fetchRef.current() + } + + const syncPolling = () => { + if (document.visibilityState !== 'visible') { + if (intervalId !== undefined) { + clearInterval(intervalId) + intervalId = undefined + } + return + } + if (intervalId === undefined) { + intervalId = setInterval(runTick, POLL_INTERVAL_MS) + } + if (Date.now() - lastCompletedFetchAtRef.current >= POLL_INTERVAL_MS) { + runTick() + } + } + + syncPolling() + document.addEventListener('visibilitychange', syncPolling) + return () => { + document.removeEventListener('visibilitychange', syncPolling) + if (intervalId !== undefined) clearInterval(intervalId) + } + }, []) + + const profileFetchKeys = useMemo(() => { + if (!viewerPubkey) return orderedPubkeys + return orderedPubkeys.filter((pk) => !hexPubkeysEqual(pk, viewerPubkey)) + }, [orderedPubkeys, viewerPubkey]) + + useEffect(() => { + if (profileFetchKeys.length === 0) { + setProfileKind0ByPubkey({}) + setProfilesLoading(false) + return + } + let cancelled = false + setProfilesLoading(true) + ;(async () => { + try { + const events = await replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( + profileFetchKeys, + kinds.Metadata + ) + if (cancelled) return + const next: Record = {} + profileFetchKeys.forEach((pk, i) => { + const e = events[i] + if (e) next[pk] = e + }) + setProfileKind0ByPubkey(next) + } catch (err) { + logger.debug('[FavoriteRelaysActivity] profile batch failed', { err }) + if (!cancelled) setProfileKind0ByPubkey({}) + } finally { + if (!cancelled) setProfilesLoading(false) + } + })() + return () => { + cancelled = true + } + }, [profileFetchKeys]) + + const displayPubkeys = useMemo(() => { + if (!viewerPubkey) return orderedPubkeys + return orderedPubkeys.filter((pk) => !hexPubkeysEqual(pk, viewerPubkey)) + }, [orderedPubkeys, viewerPubkey]) + + const { followPubkeys, otherPubkeys, followCount, otherCount } = useMemo( + () => partitionByFollows(displayPubkeys, followings), + [displayPubkeys, followings] + ) + + const pubkeys = useMemo( + () => [...followPubkeys, ...otherPubkeys], + [followPubkeys, otherPubkeys] + ) + + const value: TFavoriteRelaysActivityContext = useMemo( + () => ({ + followPubkeys, + otherPubkeys, + followCount, + otherCount, + pubkeys, + totalCount: displayPubkeys.length, + loading, + relayActivityReady, + lastFetchedAtMs, + profileKind0ByPubkey, + profilesLoading, + activeNpubsDrawerOpen, + setActiveNpubsDrawerOpen, + refetch: fetchActive + }), + [ + followPubkeys, + otherPubkeys, + followCount, + otherCount, + pubkeys, + displayPubkeys.length, + loading, + relayActivityReady, + lastFetchedAtMs, + profileKind0ByPubkey, + profilesLoading, + activeNpubsDrawerOpen, + fetchActive + ] + ) + + return {children} +} diff --git a/src/providers/FollowListProvider.tsx b/src/providers/FollowListProvider.tsx index c7d7cdae..b7d8a966 100644 --- a/src/providers/FollowListProvider.tsx +++ b/src/providers/FollowListProvider.tsx @@ -29,6 +29,11 @@ export const useFollowList = () => { return context } +/** Same as {@link useFollowList} but returns undefined outside the provider (avoids HMR / refresh-boundary crashes). */ +export function useFollowListOptional(): TFollowListContext | undefined { + return useContext(FollowListContext) +} + export function FollowListProvider({ children }: { children: React.ReactNode }) { const { t } = useTranslation() const { pubkey: accountPubkey, followListEvent, publish, updateFollowListEvent } = useNostr() diff --git a/src/providers/favorite-relays-activity-context.tsx b/src/providers/favorite-relays-activity-context.tsx new file mode 100644 index 00000000..3c06370b --- /dev/null +++ b/src/providers/favorite-relays-activity-context.tsx @@ -0,0 +1,37 @@ +import type { Event } from 'nostr-tools' +import { createContext, useContext } from 'react' + +export type TFavoriteRelaysActivityContext = { + /** Active pubkeys you follow, most recent global activity first within this group */ + followPubkeys: string[] + /** Active pubkeys you do not follow */ + otherPubkeys: string[] + followCount: number + otherCount: number + /** `followPubkeys` then `otherPubkeys` */ + pubkeys: string[] + totalCount: number + loading: boolean + /** True after at least one fetch has finished (so empty state is meaningful) */ + relayActivityReady: boolean + /** Wall-clock ms when the last sample completed; null before first fetch */ + lastFetchedAtMs: number | null + /** Kind 0 events loaded for active pubkeys (viewer excluded); used for avatars + drawer */ + profileKind0ByPubkey: Record + profilesLoading: boolean + activeNpubsDrawerOpen: boolean + setActiveNpubsDrawerOpen: (open: boolean) => void + refetch: () => void +} + +export const FavoriteRelaysActivityContext = createContext< + TFavoriteRelaysActivityContext | undefined +>(undefined) + +export function useFavoriteRelaysActivity(): TFavoriteRelaysActivityContext { + const ctx = useContext(FavoriteRelaysActivityContext) + if (!ctx) { + throw new Error('useFavoriteRelaysActivity must be used within FavoriteRelaysActivityProvider') + } + return ctx +} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 382140dc..0caf7854 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -672,9 +672,13 @@ class ClientService extends EventTarget { private recordSessionRelayFailure(url: string) { const n = normalizeUrl(url) || url if (!n) return - const count = (this.publishStrikeCount.get(n) ?? 0) + 1 + const prev = this.publishStrikeCount.get(n) ?? 0 + if (prev >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) { + return + } + const count = prev + 1 this.publishStrikeCount.set(n, count) - if (count >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) { + if (count === ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD) { logger.info('[Relay] Session strike threshold — relay skipped for reads/publishes until reload', { url: n, strikes: count @@ -1932,6 +1936,7 @@ class ClientService extends EventTarget { const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) relays = relays.filter((url) => !kind1BlockedSet.has(normalizeUrl(url) || url)) } + relays = this.filterSessionStrikedRelays(relays) const events = await this.queryService.query(relays, filter, onevent, { eoseTimeout, globalTimeout,