Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
1b8a8b9bc3
  1. 93
      src/PageManager.tsx
  2. 4
      src/components/LiveActivitiesStrip.tsx
  3. 55
      src/components/NoteOptions/useMenuActions.tsx
  4. 17
      src/components/Profile/index.tsx
  5. 141
      src/components/ReplyNoteList/index.tsx
  6. 101
      src/components/ReplyNoteList/reply-list-utils.ts
  7. 4
      src/contexts/primary-page-context.tsx
  8. 2
      src/contexts/secondary-page-context.tsx
  9. 36
      src/hooks/useProfilePins.tsx
  10. 6
      src/hooks/useRemovePinListEntry.ts
  11. 6
      src/layouts/PrimaryPageLayout/index.tsx
  12. 22
      src/lib/pin-list-events.ts

93
src/PageManager.tsx

@ -7,7 +7,10 @@ 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'
import { captureMobilePrimaryFeedScrollFromWindow, peekMobilePrimaryFeedScroll } from '@/lib/mobile-primary-feed-scroll' import {
captureMobilePrimaryFeedScrollFromWindow,
peekMobilePrimaryFeedScroll
} from '@/lib/mobile-primary-feed-scroll'
import { useMobileSwipeBackOnElement } from '@/lib/mobile-swipe-back' import { useMobileSwipeBackOnElement } from '@/lib/mobile-swipe-back'
import { preventRadixSheetCloseForPortaledOverlay } from '@/lib/sheet-dismiss-guard' import { preventRadixSheetCloseForPortaledOverlay } from '@/lib/sheet-dismiss-guard'
import { ChevronLeft } from 'lucide-react' import { ChevronLeft } from 'lucide-react'
@ -68,7 +71,7 @@ import {
usePrimaryNoteViewOptional, usePrimaryNoteViewOptional,
type TPrimaryOverlayViewType type TPrimaryOverlayViewType
} from '@/contexts/primary-note-view-context' } from '@/contexts/primary-note-view-context'
import { SecondaryPageContext, useSecondaryPage, useSecondaryPageOptional } from '@/contexts/secondary-page-context' import { SecondaryPageContext, useSecondaryPage, useSecondaryPageOptional, type SecondaryPageContextValue } from '@/contexts/secondary-page-context'
/** Survives React StrictMode remount so initial URL → secondary stack is not built twice. */ /** Survives React StrictMode remount so initial URL → secondary stack is not built twice. */
let historyLocationSeedApplied = false let historyLocationSeedApplied = false
@ -2033,14 +2036,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
recentSecondaryPushRef.current = { url, at: now } recentSecondaryPushRef.current = { url, at: now }
noteStatsService.setBackgroundStatsPaused(true) noteStatsService.setBackgroundStatsPaused(true)
client.interruptBackgroundQueries() if (!isSmallScreen) {
client.interruptBackgroundQueries()
}
if (isSmallScreen && currentPrimaryPage) { if (isSmallScreen && currentPrimaryPage) {
captureMobilePrimaryFeedScrollFromWindow(currentPrimaryPage) captureMobilePrimaryFeedScrollFromWindow(currentPrimaryPage)
} }
// Small screens render either the primary overlay OR the secondary stack — not both. // Small screens overlay the frozen feed; clear full-screen primary overlays so the secondary page shows.
// Clear overlays (e.g. full-screen note) so pushes from Seen-on, settings deep links, etc. show the target page.
if (isSmallScreen && primaryNoteView) { if (isSmallScreen && primaryNoteView) {
setPrimaryNoteView(null) setPrimaryNoteView(null)
} }
@ -2119,6 +2123,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const top = peekMobilePrimaryFeedScroll(page) const top = peekMobilePrimaryFeedScroll(page)
requestAnimationFrame(() => { requestAnimationFrame(() => {
window.scrollTo({ top, behavior: 'instant' }) window.scrollTo({ top, behavior: 'instant' })
requestAnimationFrame(() => {
window.scrollTo({ top, behavior: 'instant' })
})
}) })
} }
} }
@ -2256,34 +2263,60 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const primaryFrozen = primaryObscured const primaryFrozen = primaryObscured
/** Mobile secondary pages overlay the feed instead of unmounting it (preserves scroll + timeline). */
const mobileSecondaryOverlaysFeed =
isSmallScreen && secondaryStack.length > 0 && primaryNoteView == null
useLayoutEffect(() => { useLayoutEffect(() => {
noteStatsService.setBackgroundStatsPaused(primaryFrozen) noteStatsService.setBackgroundStatsPaused(primaryFrozen)
if (primaryFrozen) { if (primaryFrozen) {
extendProfileNetworkDeferral(PROFILE_SECONDARY_PANEL_DEFER_MS) extendProfileNetworkDeferral(PROFILE_SECONDARY_PANEL_DEFER_MS)
// Double-pane: keep the left feed's in-flight REQ alive; interrupt only when primary is hidden. // Keep in-flight REQ on double-pane and mobile feed overlay; interrupt only when primary is unmounted.
if (isSmallScreen || panelMode === 'single') { const shouldInterrupt = isSmallScreen ? primaryNoteView != null : panelMode === 'single'
if (shouldInterrupt) {
client.interruptBackgroundQueries() client.interruptBackgroundQueries()
} }
} }
}, [primaryFrozen, isSmallScreen, panelMode]) }, [primaryFrozen, isSmallScreen, panelMode, primaryNoteView])
const primaryPageContextValue = useMemo( const primaryPageContextValue = useMemo(
(): PrimaryPageContextValue => ({ (): PrimaryPageContextValue => ({
navigate: navigatePrimaryPageStable, navigate: navigatePrimaryPageStable,
current: currentPrimaryPage, current: currentPrimaryPage,
currentPageProps, currentPageProps,
/** Double-pane keeps the feed visible (frozen); single-pane / mobile unmount primary while a panel is open. */ /** Double-pane and mobile secondary overlay keep the feed mounted (frozen); full-screen mobile overlays unmount it. */
display: panelMode === 'double' || !primaryObscured, display: panelMode === 'double' || !primaryObscured || mobileSecondaryOverlaysFeed,
frozen: primaryFrozen frozen: primaryFrozen
}), }),
[ [
navigatePrimaryPageStable, navigatePrimaryPageStable,
currentPrimaryPage, currentPrimaryPage,
currentPageProps, currentPageProps,
isSmallScreen,
panelMode, panelMode,
primaryObscured, primaryObscured,
primaryFrozen primaryFrozen,
mobileSecondaryOverlaysFeed
]
)
const isSidePanelOpen = secondaryStack.length > 0 || drawerOpen
const secondaryPageContextValue = useMemo(
(): SecondaryPageContextValue => ({
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length
? secondaryStack[secondaryStack.length - 1].index
: 0,
navigateToPrimaryPage: navigatePrimaryPageStable,
isSidePanelOpen
}),
[
pushSecondaryPage,
popSecondaryPage,
secondaryStack,
navigatePrimaryPageStable,
isSidePanelOpen
] ]
) )
@ -2291,16 +2324,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
<PrimaryPageContext.Provider value={primaryPageContextValue}> <PrimaryPageContext.Provider value={primaryPageContextValue}>
{isSmallScreen ? ( {isSmallScreen ? (
<KeyboardShortcutsHelpProvider> <KeyboardShortcutsHelpProvider>
<SecondaryPageContext.Provider <SecondaryPageContext.Provider value={secondaryPageContextValue}>
value={{
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length
? secondaryStack[secondaryStack.length - 1].index
: 0,
navigateToPrimaryPage: navigatePrimaryPageStable
}}
>
<CurrentRelaysProvider> <CurrentRelaysProvider>
<PrimaryNoteViewContext.Provider <PrimaryNoteViewContext.Provider
value={{ value={{
@ -2350,19 +2374,23 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</div> </div>
) : ( ) : (
<> <>
<div
className={cn(
'block h-full min-h-0 min-w-0',
secondaryStack.length > 0 && 'hidden'
)}
aria-hidden={secondaryStack.length > 0}
>
{renderActivePrimaryPageContent(primaryPages, currentPrimaryPage)}
</div>
{secondaryStack.length > 0 ? ( {secondaryStack.length > 0 ? (
<div <div
ref={setMobileSecondarySwipeRoot} ref={setMobileSecondarySwipeRoot}
className="flex min-h-0 min-w-0 flex-1 flex-col touch-pan-y" className="flex min-h-0 min-w-0 flex-1 flex-col touch-pan-y bg-background"
> >
<TopSecondaryStackPane item={secondaryStack[secondaryStack.length - 1]!} /> <TopSecondaryStackPane item={secondaryStack[secondaryStack.length - 1]!} />
</div> </div>
) : null} ) : null}
{secondaryStack.length === 0 ? (
<div className="block h-full min-h-0 min-w-0">
{renderActivePrimaryPageContent(primaryPages, currentPrimaryPage)}
</div>
) : null}
</> </>
)} )}
</div> </div>
@ -2382,14 +2410,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</KeyboardShortcutsHelpProvider> </KeyboardShortcutsHelpProvider>
) : ( ) : (
<KeyboardShortcutsHelpProvider> <KeyboardShortcutsHelpProvider>
<SecondaryPageContext.Provider <SecondaryPageContext.Provider value={secondaryPageContextValue}>
value={{
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0,
navigateToPrimaryPage: navigatePrimaryPageStable
}}
>
<CurrentRelaysProvider> <CurrentRelaysProvider>
<PrimaryNoteViewContext.Provider <PrimaryNoteViewContext.Provider
value={{ value={{

4
src/components/LiveActivitiesStrip.tsx

@ -3,6 +3,7 @@ import { LIVE_ACTIVITIES_SLIDE_INTERVAL_MS } from '@/lib/live-activities'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartNoteNavigation } from '@/PageManager' import { useSmartNoteNavigation } from '@/PageManager'
import { useSecondaryPageOptional } from '@/contexts/secondary-page-context'
import { useLiveActivitiesOptional } from '@/providers/useLiveActivities' import { useLiveActivitiesOptional } from '@/providers/useLiveActivities'
import { useUserPreferencesOptional } from '@/providers/UserPreferencesProvider' import { useUserPreferencesOptional } from '@/providers/UserPreferencesProvider'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
@ -18,6 +19,7 @@ const SWIPE_NOTE_OPEN_SUPPRESS_MS = 400
export default function LiveActivitiesStrip({ placement }: { placement: TPlacement }) { export default function LiveActivitiesStrip({ placement }: { placement: TPlacement }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const secondaryPage = useSecondaryPageOptional()
const userPrefs = useUserPreferencesOptional() const userPrefs = useUserPreferencesOptional()
const showLiveActivitiesBanner = const showLiveActivitiesBanner =
userPrefs?.showLiveActivitiesBanner ?? storage.getShowLiveActivitiesBanner() userPrefs?.showLiveActivitiesBanner ?? storage.getShowLiveActivitiesBanner()
@ -116,7 +118,7 @@ export default function LiveActivitiesStrip({ placement }: { placement: TPlaceme
navigateToNote(toNote(ev), ev) navigateToNote(toNote(ev), ev)
}, [navigateToNote, itemAtSlide]) }, [navigateToNote, itemAtSlide])
if (!showLiveActivitiesBanner || items.length === 0) { if (!showLiveActivitiesBanner || items.length === 0 || secondaryPage?.isSidePanelOpen) {
return null return null
} }

55
src/components/NoteOptions/useMenuActions.tsx

@ -18,11 +18,17 @@ import {
subscribeNoteTranslations subscribeNoteTranslations
} from '@/lib/note-translation-display' } from '@/lib/note-translation-display'
import { speakNoteReadAloud } from '@/lib/read-aloud' import { speakNoteReadAloud } from '@/lib/read-aloud'
import {
dispatchPinListUpdated,
PIN_LIST_UPDATED_EVENT,
type PinListUpdatedDetail
} from '@/lib/pin-list-events'
import { import {
buildPinListTagsAfterToggle, buildPinListTagsAfterToggle,
fetchNewestPinListForPubkey, fetchNewestPinListForPubkey,
isEventInPinList isEventInPinList
} from '@/lib/replaceable-list-latest' } from '@/lib/replaceable-list-latest'
import indexedDb from '@/services/indexed-db.service'
import { generateBech32IdFromATag } from '@/lib/tag' import { generateBech32IdFromATag } from '@/lib/tag'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -133,7 +139,7 @@ export function useMenuActions({
onOpenPublicMessage, onOpenPublicMessage,
onOpenCallInvite, onOpenCallInvite,
onOpenEditOrClone, onOpenEditOrClone,
pinned: pinnedInFeed = false, pinned: _pinnedInFeed = false,
onViewAttestation onViewAttestation
}: UseMenuActionsProps) { }: UseMenuActionsProps) {
const { t } = useTranslation() const { t } = useTranslation()
@ -205,8 +211,8 @@ export function useMenuActions({
} }
}, []) }, [])
// Check if event is pinned (feed hint avoids "Pin note" on rows already shown as pinned) // Whether this note is on the signed-in user's kind 10001 pin list (not profile-owner pins).
const [isPinned, setIsPinned] = useState(pinnedInFeed) const [isPinnedInMyList, setIsPinnedInMyList] = useState(false)
// Keep refs so the effect can read the latest relay lists without making them // Keep refs so the effect can read the latest relay lists without making them
// part of the dependency array. Including live array references as deps causes // part of the dependency array. Including live array references as deps causes
@ -220,7 +226,7 @@ export function useMenuActions({
useEffect(() => { useEffect(() => {
const checkIfPinned = async () => { const checkIfPinned = async () => {
if (!pubkey) { if (!pubkey) {
setIsPinned(false) setIsPinnedInMyList(false)
return return
} }
try { try {
@ -234,17 +240,31 @@ export function useMenuActions({
) )
const pinListEvent = await fetchNewestPinListForPubkey(pubkey, comprehensiveRelays) const pinListEvent = await fetchNewestPinListForPubkey(pubkey, comprehensiveRelays)
const inList = pinListEvent ? isEventInPinList(pinListEvent, event) : false const inList = pinListEvent ? isEventInPinList(pinListEvent, event) : false
setIsPinned(inList || pinnedInFeed) setIsPinnedInMyList(inList)
} catch (error) { } catch (error) {
logger.component('PinStatus', 'Error checking pin status', { error: (error as Error).message }) logger.component('PinStatus', 'Error checking pin status', { error: (error as Error).message })
setIsPinned(pinnedInFeed) setIsPinnedInMyList(false)
} }
} }
checkIfPinned() checkIfPinned()
// Only re-run when the user or the specific event changes, not on relay list // Only re-run when the user or the specific event changes, not on relay list
// reference churn (relay arrays are read via refs above). // reference churn (relay arrays are read via refs above).
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [pubkey, event.id, pinnedInFeed]) }, [pubkey, event.id])
useEffect(() => {
if (!pubkey) return
const ownerPk = pubkey.trim().toLowerCase()
const handler = (raw: globalThis.Event) => {
const detail = (raw as CustomEvent<PinListUpdatedDetail>).detail
if (!detail || detail.ownerPubkey !== ownerPk) return
if (detail.toggledEvent?.id === event.id) {
setIsPinnedInMyList(detail.pinned ?? false)
}
}
window.addEventListener(PIN_LIST_UPDATED_EVENT, handler)
return () => window.removeEventListener(PIN_LIST_UPDATED_EVENT, handler)
}, [pubkey, event.id])
const handlePinNote = async () => { const handlePinNote = async () => {
if (!pubkey) return if (!pubkey) return
@ -262,8 +282,8 @@ export function useMenuActions({
logger.component('PinNote', 'Current pin list event', { hasEvent: !!latestPinList }) logger.component('PinNote', 'Current pin list event', { hasEvent: !!latestPinList })
const newTags = buildPinListTagsAfterToggle(latestPinList ?? null, event, !isPinned) const newTags = buildPinListTagsAfterToggle(latestPinList ?? null, event, !isPinnedInMyList)
const successMessage = isPinned ? t('Note unpinned') : t('Note pinned') const successMessage = isPinnedInMyList ? t('Note unpinned') : t('Note pinned')
logger.component('PinNote', 'Pin list tag count after merge', { count: newTags.length }) logger.component('PinNote', 'Pin list tag count after merge', { count: newTags.length })
const publishRelays = Array.from( const publishRelays = Array.from(
@ -307,8 +327,15 @@ export function useMenuActions({
toast.success(successMessage) toast.success(successMessage)
} }
// Update local state - the publish will update the cache automatically try {
setIsPinned(!isPinned) await indexedDb.putReplaceableEvent(publishedEvent as import('nostr-tools').Event)
} catch {
/* ignore */
}
const nowPinned = !isPinnedInMyList
dispatchPinListUpdated({ ownerPubkey: pubkey, toggledEvent: event, pinned: nowPinned })
setIsPinnedInMyList(nowPinned)
closeDrawer() closeDrawer()
} catch (error) { } catch (error) {
logger.component('PinNote', 'Error pinning/unpinning note', { error: (error as Error).message }) logger.component('PinNote', 'Error pinning/unpinning note', { error: (error as Error).message })
@ -1220,11 +1247,11 @@ export function useMenuActions({
} }
} }
// Pin functionality available for any note (not just own notes) // Pin / unpin only against the signed-in user's list (not another profile's pinned section).
if (pubkey) { if (pubkey) {
actions.push({ actions.push({
icon: Pin, icon: Pin,
label: isPinned ? t('Unpin note') : t('Pin note'), label: isPinnedInMyList ? t('Unpin note') : t('Pin note'),
onClick: () => { onClick: () => {
handlePinNote() handlePinNote()
}, },
@ -1261,7 +1288,7 @@ export function useMenuActions({
mutePubkeyPublicly, mutePubkeyPublicly,
unmutePubkey, unmutePubkey,
attemptDelete, attemptDelete,
isPinned, isPinnedInMyList,
handlePinNote, handlePinNote,
isArticleType, isArticleType,
articleMetadata, articleMetadata,

17
src/components/Profile/index.tsx

@ -330,14 +330,15 @@ export default function Profile({
return ( return (
<> <>
<div> <div>
<div className="relative isolate mb-2 bg-cover bg-center"> <div className="relative isolate bg-cover bg-center">
<Skeleton className="relative z-0 w-full aspect-[3/1] rounded-none" /> <Skeleton className="relative z-0 w-full aspect-[3/1] rounded-none" />
<Skeleton className="absolute bottom-0 left-3 z-20 h-24 w-24 translate-y-1/2 rounded-full border-4 border-background md:h-48 md:w-48" /> <Skeleton className="absolute bottom-0 left-3 z-20 h-24 w-24 translate-y-1/2 rounded-full border-4 border-background md:h-48 md:w-48" />
</div> </div>
<div className="h-12 md:h-24" aria-hidden />
</div> </div>
<div className="px-4"> <div className="px-4">
<Skeleton className="h-5 w-28 mt-14 md:mt-28 mb-1 md:ml-56" /> <Skeleton className="h-5 w-28 mt-2 mb-1" />
<Skeleton className="h-5 w-56 mt-2 my-1 rounded-full md:ml-56" /> <Skeleton className="h-5 w-56 mt-2 my-1 rounded-full" />
</div> </div>
<div className="px-4 pt-4 flex items-center justify-center"> <div className="px-4 pt-4 flex items-center justify-center">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
@ -363,7 +364,7 @@ export default function Profile({
return ( return (
<> <>
<div> <div>
<div className="relative isolate mb-2 bg-cover bg-center"> <div className="relative isolate bg-cover bg-center">
{/* Banner first in paint order; avatar uses higher z-index so it always sits on top. fetchPriority still prefers the pic over the banner. */} {/* Banner first in paint order; avatar uses higher z-index so it always sits on top. fetchPriority still prefers the pic over the banner. */}
<ProfileBanner <ProfileBanner
banner={banner} banner={banner}
@ -372,7 +373,7 @@ export default function Profile({
imageFetchPriority="low" imageFetchPriority="low"
/> />
{isVideo(avatar ?? '') ? ( {isVideo(avatar ?? '') ? (
<div className="absolute bottom-0 left-3 z-20 h-24 w-24 translate-y-1/2 md:h-48 md:w-48"> <div className="absolute bottom-0 left-3 z-20 h-24 w-24 translate-y-1/2 md:left-4 md:h-48 md:w-48">
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<div className="h-full w-full overflow-hidden rounded-full border-4 border-background bg-muted"> <div className="h-full w-full overflow-hidden rounded-full border-4 border-background bg-muted">
<video <video
@ -391,7 +392,7 @@ export default function Profile({
</div> </div>
</div> </div>
) : ( ) : (
<div className="absolute bottom-0 left-3 z-20 h-24 w-24 translate-y-1/2 md:h-48 md:w-48"> <div className="absolute bottom-0 left-3 z-20 h-24 w-24 translate-y-1/2 md:left-4 md:h-48 md:w-48">
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<Avatar className="h-full w-full border-4 border-background"> <Avatar className="h-full w-full border-4 border-background">
<AvatarImage <AvatarImage
@ -411,6 +412,8 @@ export default function Profile({
</div> </div>
)} )}
</div> </div>
{/* Below banner only: room for avatar half that extends past the banner edge */}
<div className="h-12 md:h-24" aria-hidden />
<div className="px-4"> <div className="px-4">
<div className="flex flex-wrap justify-end gap-2 items-center min-w-0"> <div className="flex flex-wrap justify-end gap-2 items-center min-w-0">
<ProfileOptions <ProfileOptions
@ -535,7 +538,7 @@ export default function Profile({
</> </>
) : null} ) : null}
</div> </div>
<div className="pt-2 pb-4 md:pl-56"> <div className="pt-2 pb-4">
<div className="flex flex-wrap gap-2 items-center min-w-0"> <div className="flex flex-wrap gap-2 items-center min-w-0">
<div className="text-xl font-semibold truncate select-text max-w-full">{username}</div> <div className="text-xl font-semibold truncate select-text max-w-full">{username}</div>
</div> </div>

141
src/components/ReplyNoteList/index.tsx

@ -68,9 +68,11 @@ import {
buildVisibleBacklinkRows, buildVisibleBacklinkRows,
EA_THREAD_TAIL_REFERENCE_KINDS, EA_THREAD_TAIL_REFERENCE_KINDS,
fetchPaymentAttestationsForRecipient, fetchPaymentAttestationsForRecipient,
hydrateThreadRepliesFromStats,
isEaThreadTailBacklinkCandidate, isEaThreadTailBacklinkCandidate,
isPollVoteKind, isPollVoteKind,
isWebThreadTailKind, isWebThreadTailKind,
loadThreadRepliesFromLocalStores,
mergeFetchedKind7ReactionsIntoRootNoteStats, mergeFetchedKind7ReactionsIntoRootNoteStats,
moveReportsToEndPreserveOrder, moveReportsToEndPreserveOrder,
partitionAndSortBacklinkTail, partitionAndSortBacklinkTail,
@ -518,129 +520,56 @@ function ReplyNoteList({
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({}) const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
const bottomRef = useRef<HTMLDivElement | null>(null) const bottomRef = useRef<HTMLDivElement | null>(null)
/** When stats saw a URL-thread reply on relays we didn't REQ in the reply list, fetch by id so count matches list. */ /** When note-stats counted replies we did not REQ in the thread, fetch by id from archive/session. */
const rssStatsHydratedReplyIdsRef = useRef<Set<string>>(new Set()) const statsHydratedReplyIdsRef = useRef<Set<string>>(new Set())
useEffect(() => { useEffect(() => {
rssStatsHydratedReplyIdsRef.current.clear() statsHydratedReplyIdsRef.current.clear()
}, [event.id]) }, [event.id])
useEffect(() => { useEffect(() => {
if (event.kind !== ExtendedKind.RSS_THREAD_ROOT || rootInfo?.type !== 'I') return if (!rootInfo) return
const fromStats = noteStats?.replies
if (!fromStats?.length) return
const urlKey = canonicalizeRssArticleUrl(rootInfo.id)
const inBucket = new Set((repliesMap.get(urlKey)?.events ?? []).map((e) => e.id))
const candidates = fromStats.filter(
(r) => !inBucket.has(r.id) && !rssStatsHydratedReplyIdsRef.current.has(r.id)
)
if (candidates.length === 0) return
let cancelled = false
;(async () => {
const batch: NEvent[] = []
for (const { id } of candidates) {
rssStatsHydratedReplyIdsRef.current.add(id)
try {
const ev = await eventService.fetchEvent(id)
if (cancelled) return
if (ev && isRssArticleUrlThreadInteraction(ev, rootInfo.id)) {
batch.push(ev)
} else {
rssStatsHydratedReplyIdsRef.current.delete(id)
}
} catch {
rssStatsHydratedReplyIdsRef.current.delete(id)
}
}
if (!cancelled && batch.length > 0) {
const ok = batch.filter(
(e) =>
!shouldHideThreadResponseEvent(
e,
mutePubkeySet,
hideContentMentioningMutedUsers
)
)
if (ok.length > 0) addReplies(ok)
}
})()
return () => {
cancelled = true
}
}, [
event.kind,
event.id,
rootInfo,
noteStats?.replies,
noteStats?.updatedAt,
repliesMap,
addReplies,
mutePubkeySet,
hideContentMentioningMutedUsers
])
/** When note-stats counted discussion replies we did not REQ in the thread, fetch by id (same idea as RSS threads). */
const discussionStatsHydratedReplyIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
discussionStatsHydratedReplyIdsRef.current.clear()
}, [event.id])
useEffect(() => {
if (event.kind !== ExtendedKind.DISCUSSION || !rootInfo || rootInfo.type !== 'E') return
const fromStats = noteStats?.replies const fromStats = noteStats?.replies
if (!fromStats?.length) return if (!fromStats?.length) return
const threadRoot = rootInfo
const candidates = fromStats.filter( const candidates = fromStats.filter(
(r) => (r) =>
!replyIdPresentInRepliesMap(repliesMap, r.id) && !replyIdPresentInRepliesMap(repliesMap, r.id) &&
!discussionStatsHydratedReplyIdsRef.current.has(r.id) !statsHydratedReplyIdsRef.current.has(r.id)
) )
if (candidates.length === 0) return if (candidates.length === 0) return
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
const batch: NEvent[] = [] for (const { id } of candidates) statsHydratedReplyIdsRef.current.add(id)
const batch = await hydrateThreadRepliesFromStats(
candidates,
rootInfo,
event,
isDiscussionRoot
)
if (cancelled) return
for (const { id } of candidates) { for (const { id } of candidates) {
discussionStatsHydratedReplyIdsRef.current.add(id) if (!batch.some((e) => e.id === id)) statsHydratedReplyIdsRef.current.delete(id)
try {
const ev = await eventService.fetchEvent(id)
if (cancelled) return
if (ev && replyMatchesThreadForList(ev, event, threadRoot, true) && !isPollVoteKind(ev)) {
batch.push(ev)
} else {
discussionStatsHydratedReplyIdsRef.current.delete(id)
}
} catch {
discussionStatsHydratedReplyIdsRef.current.delete(id)
}
}
if (!cancelled && batch.length > 0) {
const ok = batch.filter(
(e) =>
!shouldHideThreadResponseEvent(
e,
mutePubkeySet,
hideContentMentioningMutedUsers
)
)
if (ok.length > 0) addReplies(ok)
} }
const ok = batch.filter(
(e) =>
!shouldHideThreadResponseEvent(
e,
mutePubkeySet,
hideContentMentioningMutedUsers
)
)
if (ok.length > 0) addReplies(ok)
})() })()
return () => { return () => {
cancelled = true cancelled = true
} }
}, [ }, [
event.kind,
event.id,
event, event,
rootInfo, rootInfo,
isDiscussionRoot,
noteStats?.replies, noteStats?.replies,
noteStats?.updatedAt, noteStats?.updatedAt,
repliesMap, repliesMap,
@ -726,6 +655,24 @@ function ReplyNoteList({
setLoading(true) setLoading(true)
} }
try {
const localRows = await loadThreadRepliesFromLocalStores(
rootInfo,
event,
isDiscussionRoot,
mutePubkeySet,
hideContentMentioningMutedUsers
)
if (fetchGeneration !== replyFetchGenRef.current) return
if (localRows.length > 0) {
addReplies(localRows)
discussionFeedCache.setCachedReplies(rootInfo, localRows)
setLoading(false)
}
} catch (e) {
logger.debug('[ReplyNoteList] Local thread load failed', e)
}
// Always refetch soon so relays fill gaps; no artificial delay (was 2s and caused empty threads) // Always refetch soon so relays fill gaps; no artificial delay (was 2s and caused empty threads)
void fetchFromRelays() void fetchFromRelays()

101
src/components/ReplyNoteList/reply-list-utils.ts

@ -1,4 +1,19 @@
import { ExtendedKind, NOTE_STATS_OP_REFERENCE_KINDS } from '@/constants'
import { isNip56ReportEvent, kind1QuotesThreadRoot } from '@/lib/event'
import { isSuperchatKind, replyFeedSuperchatsFirst } from '@/lib/superchat'
import { eventReferencesThreadTarget } from '@/lib/op-reference-tags'
import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match'
import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed'
import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter'
import { buildThreadInteractionFilters } from '@/lib/thread-interaction-req'
import noteStatsService from '@/services/note-stats.service'
import client, { eventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import type { TSubRequestFilter } from '@/types'
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
import type { TFunction } from 'i18next'
import type { TRootInfo } from './types' import type { TRootInfo } from './types'
import { THREAD_REPLY_LIMIT } from './types'
export type { TRootInfo } from './types' export type { TRootInfo } from './types'
export { export {
@ -9,17 +24,81 @@ export {
THREAD_PROFILE_CHUNK THREAD_PROFILE_CHUNK
} from './types' } from './types'
import { ExtendedKind, NOTE_STATS_OP_REFERENCE_KINDS } from '@/constants' /** Session LRU + publication store + archive: paint thread replies before relay round-trip. */
import { isNip56ReportEvent, kind1QuotesThreadRoot } from '@/lib/event' export async function loadThreadRepliesFromLocalStores(
import { isSuperchatKind, replyFeedSuperchatsFirst } from '@/lib/superchat' rootInfo: TRootInfo,
import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' opEvent: NEvent,
import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' isDiscussionRoot: boolean,
import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' mutePubkeySet: Set<string>,
import noteStatsService from '@/services/note-stats.service' hideContentMentioningMutedUsers: boolean | undefined
import client from '@/services/client.service' ): Promise<NEvent[]> {
import type { TSubRequestFilter } from '@/types' const filters = buildThreadInteractionFilters({
import { Filter, Event as NEvent, kinds } from 'nostr-tools' root: rootInfo,
import type { TFunction } from 'i18next' opEventKind: opEvent.kind,
limit: THREAD_REPLY_LIMIT
})
if (!filters.length) return []
let local: NEvent[] = []
try {
local = await client.getLocalFeedEvents(
filters.map((filter) => ({ urls: [], filter: filter as TSubRequestFilter })),
{ maxMatches: THREAD_REPLY_LIMIT, maxRowsScanned: 28_000 }
)
} catch {
return []
}
const threadWalk = new Map(local.map((e) => [e.id.toLowerCase(), e] as const))
return local.filter((evt) => {
if (isPollVoteKind(evt)) return false
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) return false
if (rootInfo.type === 'I') {
return isRssArticleUrlThreadInteraction(evt, rootInfo.id)
}
return replyMatchesThreadForList(evt, opEvent, rootInfo, isDiscussionRoot, threadWalk)
})
}
/** Resolve reply ids from note-stats via archive + session fetch, then thread-match filter. */
export async function hydrateThreadRepliesFromStats(
candidates: ReadonlyArray<{ id: string }>,
rootInfo: TRootInfo,
opEvent: NEvent,
isDiscussionRoot: boolean
): Promise<NEvent[]> {
if (!candidates.length) return []
const ids = candidates.map((c) => c.id)
const byId = new Map<string, NEvent>()
try {
const archived = await indexedDb.getArchivedEventsByIds(ids)
for (const e of archived) byId.set(e.id, e)
} catch {
/* optional */
}
for (const id of ids) {
if (byId.has(id)) continue
try {
const ev = await eventService.fetchEvent(id)
if (ev) byId.set(ev.id, ev)
} catch {
/* optional */
}
}
const batch: NEvent[] = []
for (const ev of byId.values()) {
if (isPollVoteKind(ev)) continue
if (rootInfo.type === 'I') {
if (!isRssArticleUrlThreadInteraction(ev, rootInfo.id)) continue
} else if (!replyMatchesThreadForList(ev, opEvent, rootInfo, isDiscussionRoot)) {
continue
}
batch.push(ev)
}
return batch
}
export async function fetchPaymentAttestationsForRecipient( export async function fetchPaymentAttestationsForRecipient(
recipientPubkey: string, recipientPubkey: string,

4
src/contexts/primary-page-context.tsx

@ -13,8 +13,8 @@ export type PrimaryPageContextValue = {
/** Props passed to the current primary page (e.g. `{ spell: 'discussions' }` for spells). */ /** Props passed to the current primary page (e.g. `{ spell: 'discussions' }` for spells). */
currentPageProps: object | undefined currentPageProps: object | undefined
/** /**
* False while a note drawer, secondary page, or mobile overlay covers the feed (primary unmounted). * False while a full-screen mobile overlay or note drawer covers the feed (primary unmounted).
* True on desktop double-pane so the left column stays visible (but {@link frozen} pauses it). * True on desktop double-pane and when mobile secondary pages overlay the frozen feed.
*/ */
display: boolean display: boolean
/** /**

2
src/contexts/secondary-page-context.tsx

@ -12,6 +12,8 @@ export type SecondaryPageContextValue = {
pop: () => void pop: () => void
currentIndex: number currentIndex: number
navigateToPrimaryPage: (page: TPrimaryPageName, props?: object) => void navigateToPrimaryPage: (page: TPrimaryPageName, props?: object) => void
/** True when a secondary panel (stack or note drawer) is showing content. */
isSidePanelOpen: boolean
} }
export const SecondaryPageContext = createContext<SecondaryPageContextValue | undefined>(undefined) export const SecondaryPageContext = createContext<SecondaryPageContextValue | undefined>(undefined)

36
src/hooks/useProfilePins.tsx

@ -9,6 +9,7 @@ import {
METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS
} from '@/constants' } from '@/constants'
import { PIN_LIST_UPDATED_EVENT, type PinListUpdatedDetail } from '@/lib/pin-list-events'
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -298,6 +299,41 @@ export function useProfilePins(pubkey: string | undefined) {
void loadPins(false) void loadPins(false)
}, [pubkey, loadPins]) }, [pubkey, loadPins])
useEffect(() => {
if (!pubkey) return
const handler = (raw: globalThis.Event) => {
const detail = (raw as CustomEvent<PinListUpdatedDetail>).detail
if (!detail) return
let profilePk: string
try {
profilePk = normalizeHexPubkey(pubkey).toLowerCase()
} catch {
return
}
if (detail.ownerPubkey !== profilePk) return
const cacheKey = `${pubkey}-pins-profile`
pinsCache.delete(cacheKey)
if (detail.toggledEvent && detail.pinned === false) {
const removedId = detail.toggledEvent.id.toLowerCase()
setPinEvents((prev) => {
const next = prev.filter((ev) => ev.id.toLowerCase() !== removedId)
if (next.length > 0) {
pinsCache.set(cacheKey, { events: next, lastUpdated: Date.now() })
}
return next
})
} else {
void loadPins(true)
}
}
window.addEventListener(PIN_LIST_UPDATED_EVENT, handler)
return () => window.removeEventListener(PIN_LIST_UPDATED_EVENT, handler)
}, [pubkey, loadPins])
const refreshPins = useCallback(() => { const refreshPins = useCallback(() => {
if (pubkey) { if (pubkey) {
pinsCache.delete(`${pubkey}-pins-profile`) pinsCache.delete(`${pubkey}-pins-profile`)

6
src/hooks/useRemovePinListEntry.ts

@ -5,6 +5,7 @@ import {
fetchNewestPinListForPubkey, fetchNewestPinListForPubkey,
isEventInPinList isEventInPinList
} from '@/lib/replaceable-list-latest' } from '@/lib/replaceable-list-latest'
import { dispatchPinListUpdated } from '@/lib/pin-list-events'
import { decodePersonalListBech32Ref } from '@/lib/personal-list-mutations' import { decodePersonalListBech32Ref } from '@/lib/personal-list-mutations'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -53,6 +54,11 @@ export function useRemovePinListEntry(onSuccess?: () => void) {
{ specifiedRelayUrls: comprehensiveRelays } { specifiedRelayUrls: comprehensiveRelays }
) )
await indexedDb.putReplaceableEvent(published as Event) await indexedDb.putReplaceableEvent(published as Event)
dispatchPinListUpdated({
ownerPubkey: pubkey,
toggledEvent: loadedEvent ?? undefined,
pinned: false
})
onSuccess?.() onSuccess?.()
return true return true
}, },

6
src/layouts/PrimaryPageLayout/index.tsx

@ -61,7 +61,7 @@ const PrimaryPageLayout = forwardRef(
) )
useEffect(() => { useEffect(() => {
if (!isSmallScreen || current !== pageName) return if (!isSmallScreen || current !== pageName || frozen) return
const handleScroll = () => { const handleScroll = () => {
saveMobilePrimaryFeedScroll(pageName, window.scrollY) saveMobilePrimaryFeedScroll(pageName, window.scrollY)
@ -71,7 +71,7 @@ const PrimaryPageLayout = forwardRef(
handleScroll() handleScroll()
window.removeEventListener('scroll', handleScroll) window.removeEventListener('scroll', handleScroll)
} }
}, [current, isSmallScreen, pageName]) }, [current, frozen, isSmallScreen, pageName])
useEffect(() => { useEffect(() => {
if (!isSmallScreen || current !== pageName || !display) return if (!isSmallScreen || current !== pageName || !display) return
@ -125,7 +125,7 @@ const PrimaryPageLayout = forwardRef(
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<DeepBrowsingProvider active={current === pageName && display}> <DeepBrowsingProvider active={current === pageName && display && !frozen}>
<div <div
ref={smallScreenScrollAreaRef} ref={smallScreenScrollAreaRef}
className="min-w-0 w-full overflow-x-hidden" className="min-w-0 w-full overflow-x-hidden"

22
src/lib/pin-list-events.ts

@ -0,0 +1,22 @@
import type { Event } from 'nostr-tools'
/** Dispatched after the signed-in user's kind 10001 pin list is published. */
export const PIN_LIST_UPDATED_EVENT = 'jumble:pinListUpdated'
export type PinListUpdatedDetail = {
ownerPubkey: string
toggledEvent?: Event
pinned?: boolean
}
export function dispatchPinListUpdated(detail: PinListUpdatedDetail): void {
if (typeof window === 'undefined') return
window.dispatchEvent(
new CustomEvent<PinListUpdatedDetail>(PIN_LIST_UPDATED_EVENT, {
detail: {
...detail,
ownerPubkey: detail.ownerPubkey.trim().toLowerCase()
}
})
)
}
Loading…
Cancel
Save