Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
1b8a8b9bc3
  1. 89
      src/PageManager.tsx
  2. 4
      src/components/LiveActivitiesStrip.tsx
  3. 55
      src/components/NoteOptions/useMenuActions.tsx
  4. 17
      src/components/Profile/index.tsx
  5. 123
      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

89
src/PageManager.tsx

@ -7,7 +7,10 @@ import { RefreshButton } from '@/components/RefreshButton' @@ -7,7 +7,10 @@ import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
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 { preventRadixSheetCloseForPortaledOverlay } from '@/lib/sheet-dismiss-guard'
import { ChevronLeft } from 'lucide-react'
@ -68,7 +71,7 @@ import { @@ -68,7 +71,7 @@ import {
usePrimaryNoteViewOptional,
type TPrimaryOverlayViewType
} 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. */
let historyLocationSeedApplied = false
@ -2033,14 +2036,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2033,14 +2036,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
recentSecondaryPushRef.current = { url, at: now }
noteStatsService.setBackgroundStatsPaused(true)
if (!isSmallScreen) {
client.interruptBackgroundQueries()
}
if (isSmallScreen && currentPrimaryPage) {
captureMobilePrimaryFeedScrollFromWindow(currentPrimaryPage)
}
// Small screens render either the primary overlay OR the secondary stack — not both.
// Clear overlays (e.g. full-screen note) so pushes from Seen-on, settings deep links, etc. show the target page.
// Small screens overlay the frozen feed; clear full-screen primary overlays so the secondary page shows.
if (isSmallScreen && primaryNoteView) {
setPrimaryNoteView(null)
}
@ -2117,9 +2121,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2117,9 +2121,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
if (isSmallScreen) {
const top = peekMobilePrimaryFeedScroll(page)
requestAnimationFrame(() => {
window.scrollTo({ top, behavior: 'instant' })
requestAnimationFrame(() => {
window.scrollTo({ top, behavior: 'instant' })
})
})
}
}
@ -2256,51 +2263,68 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2256,51 +2263,68 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
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(() => {
noteStatsService.setBackgroundStatsPaused(primaryFrozen)
if (primaryFrozen) {
extendProfileNetworkDeferral(PROFILE_SECONDARY_PANEL_DEFER_MS)
// Double-pane: keep the left feed's in-flight REQ alive; interrupt only when primary is hidden.
if (isSmallScreen || panelMode === 'single') {
// Keep in-flight REQ on double-pane and mobile feed overlay; interrupt only when primary is unmounted.
const shouldInterrupt = isSmallScreen ? primaryNoteView != null : panelMode === 'single'
if (shouldInterrupt) {
client.interruptBackgroundQueries()
}
}
}, [primaryFrozen, isSmallScreen, panelMode])
}, [primaryFrozen, isSmallScreen, panelMode, primaryNoteView])
const primaryPageContextValue = useMemo(
(): PrimaryPageContextValue => ({
navigate: navigatePrimaryPageStable,
current: currentPrimaryPage,
currentPageProps,
/** Double-pane keeps the feed visible (frozen); single-pane / mobile unmount primary while a panel is open. */
display: panelMode === 'double' || !primaryObscured,
/** Double-pane and mobile secondary overlay keep the feed mounted (frozen); full-screen mobile overlays unmount it. */
display: panelMode === 'double' || !primaryObscured || mobileSecondaryOverlaysFeed,
frozen: primaryFrozen
}),
[
navigatePrimaryPageStable,
currentPrimaryPage,
currentPageProps,
isSmallScreen,
panelMode,
primaryObscured,
primaryFrozen
primaryFrozen,
mobileSecondaryOverlaysFeed
]
)
return (
<PrimaryPageContext.Provider value={primaryPageContextValue}>
{isSmallScreen ? (
<KeyboardShortcutsHelpProvider>
<SecondaryPageContext.Provider
value={{
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
}}
>
navigateToPrimaryPage: navigatePrimaryPageStable,
isSidePanelOpen
}),
[
pushSecondaryPage,
popSecondaryPage,
secondaryStack,
navigatePrimaryPageStable,
isSidePanelOpen
]
)
return (
<PrimaryPageContext.Provider value={primaryPageContextValue}>
{isSmallScreen ? (
<KeyboardShortcutsHelpProvider>
<SecondaryPageContext.Provider value={secondaryPageContextValue}>
<CurrentRelaysProvider>
<PrimaryNoteViewContext.Provider
value={{
@ -2350,19 +2374,23 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2350,19 +2374,23 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</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 ? (
<div
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]!} />
</div>
) : null}
{secondaryStack.length === 0 ? (
<div className="block h-full min-h-0 min-w-0">
{renderActivePrimaryPageContent(primaryPages, currentPrimaryPage)}
</div>
) : null}
</>
)}
</div>
@ -2382,14 +2410,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2382,14 +2410,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</KeyboardShortcutsHelpProvider>
) : (
<KeyboardShortcutsHelpProvider>
<SecondaryPageContext.Provider
value={{
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0,
navigateToPrimaryPage: navigatePrimaryPageStable
}}
>
<SecondaryPageContext.Provider value={secondaryPageContextValue}>
<CurrentRelaysProvider>
<PrimaryNoteViewContext.Provider
value={{

4
src/components/LiveActivitiesStrip.tsx

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

55
src/components/NoteOptions/useMenuActions.tsx

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

17
src/components/Profile/index.tsx

@ -330,14 +330,15 @@ export default function Profile({ @@ -330,14 +330,15 @@ export default function Profile({
return (
<>
<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="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 className="h-12 md:h-24" aria-hidden />
</div>
<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-56 mt-2 my-1 rounded-full 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" />
</div>
<div className="px-4 pt-4 flex items-center justify-center">
<div className="text-sm text-muted-foreground">
@ -363,7 +364,7 @@ export default function Profile({ @@ -363,7 +364,7 @@ export default function Profile({
return (
<>
<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. */}
<ProfileBanner
banner={banner}
@ -372,7 +373,7 @@ export default function Profile({ @@ -372,7 +373,7 @@ export default function Profile({
imageFetchPriority="low"
/>
{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="h-full w-full overflow-hidden rounded-full border-4 border-background bg-muted">
<video
@ -391,7 +392,7 @@ export default function Profile({ @@ -391,7 +392,7 @@ export default function Profile({
</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">
<Avatar className="h-full w-full border-4 border-background">
<AvatarImage
@ -411,6 +412,8 @@ export default function Profile({ @@ -411,6 +412,8 @@ export default function Profile({
</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="flex flex-wrap justify-end gap-2 items-center min-w-0">
<ProfileOptions
@ -535,7 +538,7 @@ export default function Profile({ @@ -535,7 +538,7 @@ export default function Profile({
</>
) : null}
</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="text-xl font-semibold truncate select-text max-w-full">{username}</div>
</div>

123
src/components/ReplyNoteList/index.tsx

@ -68,9 +68,11 @@ import { @@ -68,9 +68,11 @@ import {
buildVisibleBacklinkRows,
EA_THREAD_TAIL_REFERENCE_KINDS,
fetchPaymentAttestationsForRecipient,
hydrateThreadRepliesFromStats,
isEaThreadTailBacklinkCandidate,
isPollVoteKind,
isWebThreadTailKind,
loadThreadRepliesFromLocalStores,
mergeFetchedKind7ReactionsIntoRootNoteStats,
moveReportsToEndPreserveOrder,
partitionAndSortBacklinkTail,
@ -518,109 +520,38 @@ function ReplyNoteList({ @@ -518,109 +520,38 @@ function ReplyNoteList({
const replyRefs = useRef<Record<string, HTMLDivElement | 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. */
const rssStatsHydratedReplyIdsRef = useRef<Set<string>>(new Set())
/** When note-stats counted replies we did not REQ in the thread, fetch by id from archive/session. */
const statsHydratedReplyIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
rssStatsHydratedReplyIdsRef.current.clear()
statsHydratedReplyIdsRef.current.clear()
}, [event.id])
useEffect(() => {
if (event.kind !== ExtendedKind.RSS_THREAD_ROOT || rootInfo?.type !== 'I') 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
if (!rootInfo) return
const fromStats = noteStats?.replies
if (!fromStats?.length) return
const threadRoot = rootInfo
const candidates = fromStats.filter(
(r) =>
!replyIdPresentInRepliesMap(repliesMap, r.id) &&
!discussionStatsHydratedReplyIdsRef.current.has(r.id)
!statsHydratedReplyIdsRef.current.has(r.id)
)
if (candidates.length === 0) return
let cancelled = false
;(async () => {
const batch: NEvent[] = []
for (const { id } of candidates) {
discussionStatsHydratedReplyIdsRef.current.add(id)
try {
const ev = await eventService.fetchEvent(id)
for (const { id } of candidates) statsHydratedReplyIdsRef.current.add(id)
const batch = await hydrateThreadRepliesFromStats(
candidates,
rootInfo,
event,
isDiscussionRoot
)
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)
}
for (const { id } of candidates) {
if (!batch.some((e) => e.id === id)) statsHydratedReplyIdsRef.current.delete(id)
}
if (!cancelled && batch.length > 0) {
const ok = batch.filter(
(e) =>
!shouldHideThreadResponseEvent(
@ -630,17 +561,15 @@ function ReplyNoteList({ @@ -630,17 +561,15 @@ function ReplyNoteList({
)
)
if (ok.length > 0) addReplies(ok)
}
})()
return () => {
cancelled = true
}
}, [
event.kind,
event.id,
event,
rootInfo,
isDiscussionRoot,
noteStats?.replies,
noteStats?.updatedAt,
repliesMap,
@ -726,6 +655,24 @@ function ReplyNoteList({ @@ -726,6 +655,24 @@ function ReplyNoteList({
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)
void fetchFromRelays()

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

@ -1,4 +1,19 @@ @@ -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 { THREAD_REPLY_LIMIT } from './types'
export type { TRootInfo } from './types'
export {
@ -9,17 +24,81 @@ export { @@ -9,17 +24,81 @@ export {
THREAD_PROFILE_CHUNK
} from './types'
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 noteStatsService from '@/services/note-stats.service'
import client from '@/services/client.service'
import type { TSubRequestFilter } from '@/types'
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
import type { TFunction } from 'i18next'
/** Session LRU + publication store + archive: paint thread replies before relay round-trip. */
export async function loadThreadRepliesFromLocalStores(
rootInfo: TRootInfo,
opEvent: NEvent,
isDiscussionRoot: boolean,
mutePubkeySet: Set<string>,
hideContentMentioningMutedUsers: boolean | undefined
): Promise<NEvent[]> {
const filters = buildThreadInteractionFilters({
root: rootInfo,
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(
recipientPubkey: string,

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

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

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

@ -12,6 +12,8 @@ export type SecondaryPageContextValue = { @@ -12,6 +12,8 @@ export type SecondaryPageContextValue = {
pop: () => void
currentIndex: number
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)

36
src/hooks/useProfilePins.tsx

@ -9,6 +9,7 @@ import { @@ -9,6 +9,7 @@ import {
METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS
} from '@/constants'
import { PIN_LIST_UPDATED_EVENT, type PinListUpdatedDetail } from '@/lib/pin-list-events'
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -298,6 +299,41 @@ export function useProfilePins(pubkey: string | undefined) { @@ -298,6 +299,41 @@ export function useProfilePins(pubkey: string | undefined) {
void loadPins(false)
}, [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(() => {
if (pubkey) {
pinsCache.delete(`${pubkey}-pins-profile`)

6
src/hooks/useRemovePinListEntry.ts

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

6
src/layouts/PrimaryPageLayout/index.tsx

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

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

@ -0,0 +1,22 @@ @@ -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