Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
d9e543e293
  1. 84
      src/PageManager.tsx
  2. 18
      src/components/NoteStats/SeenOnButton.tsx
  3. 15
      src/components/NoteStats/index.tsx
  4. 3
      src/components/ReplyNoteList/index.tsx
  5. 10
      src/services/note-stats.service.ts

84
src/PageManager.tsx

@ -1260,6 +1260,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1260,6 +1260,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const useDrawer = isSmallScreen || panelMode === 'single'
if (!useDrawer || drawerOpen || !drawerNoteId) return
// Drawer close runs replaceState to the primary URL but used to leave the secondary stack populated,
// which re-opens the single-pane sheet (URL is / while the note panel stays visible).
secondaryStackRef.current = []
setSecondaryStack([])
setSinglePaneSheetOpen(false)
const timer = window.setTimeout(() => {
const pending = pendingDrawerCloseUrlRef.current
pendingDrawerCloseUrlRef.current = null
@ -1600,10 +1606,30 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1600,10 +1606,30 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
}
const browserPathOnly = window.location.pathname.split('?')[0].split('#')[0]
if (
isPrimaryOnlyPathname(browserPathOnly) &&
(secondaryStackRef.current.length > 0 || drawerOpenRef.current)
) {
if (drawerOpenRef.current) {
setDrawerOpen(false)
setDrawerNoteId(null)
setDrawerInitialEvent(null)
}
setSinglePaneSheetOpen(false)
secondaryStackRef.current = []
setSecondaryStack([])
restorePrimaryTabAfterSecondaryClose()
return
}
let state = e.state as { index: number; url: string } | null
// Use state.url if available, otherwise fall back to current pathname
const urlToCheck = state?.url || window.location.pathname
// Prefer the live address bar when history.state.url is stale after replaceState.
const urlToCheck =
state?.url && !isPrimaryOnlyPathname(browserPathOnly)
? state.url
: window.location.pathname + window.location.search + window.location.hash
// Check if it's a note URL (we'll update drawer after stack is synced)
const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/) ||
@ -2100,12 +2126,33 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2100,12 +2126,33 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
restorePrimaryTabAfterSecondaryClose()
}
/** Pop one secondary frame in React state before history.back (popstate can no-op when indices match). */
const popOneSecondaryStackFrame = () => {
const pre = secondaryStackRef.current
if (pre.length <= 1) return pre
const next = pre.slice(0, -1)
secondaryStackRef.current = next
setSecondaryStack(next)
return next
}
const syncDrawerToSecondaryStackTop = (stack: TStackItem[]) => {
if (!(isSmallScreen || panelMode === 'single')) return
const top = stack[stack.length - 1]
if (!top) return
const noteId = noteHexIdFromSecondaryNoteUrl(top.url)
if (!noteId) return
openDrawer(noteId, navigationEventStore.peekEvent(noteId))
}
const popSecondaryPage = () => {
const stackLen = secondaryStackRef.current.length
// Mobile / single-pane: one code path — drawer + stack share the same close behavior
if (isSmallScreen || panelMode === 'single') {
if (stackLen > 1) {
const next = popOneSecondaryStackFrame()
syncDrawerToSecondaryStackTop(next)
window.history.back()
} else {
hardCloseSecondaryPanel()
@ -2136,9 +2183,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2136,9 +2183,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
}
} else if (stackLen > 1) {
popOneSecondaryStackFrame()
// Must use real history navigation: replaceState + slice desyncs URL from the session stack
// (e.g. note → highlight → Back: bar shows the article but the panel still shows the highlight).
// popstate applies {@link onPopState} so stack and URL stay aligned with pushState indices.
// Eager stack pop above keeps the panel in sync even when popstate returns early (index === currentIndex).
window.history.back()
} else {
// Stack empty but user hit back/close: align URL to primary without history.go(-1), which
@ -2454,7 +2502,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2454,7 +2502,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
<NoteDrawer
open={drawerOpen}
initialEvent={drawerInitialEvent}
onOpenChange={setDrawerOpen}
onOpenChange={(open) => {
if (open) {
setDrawerOpen(true)
return
}
hardCloseSecondaryPanel()
}}
noteId={drawerNoteId}
/>
)}
@ -2582,23 +2636,29 @@ function secondaryPanelUrlsMatch(stackUrl: string, locationUrl: string): boolean @@ -2582,23 +2636,29 @@ function secondaryPanelUrlsMatch(stackUrl: string, locationUrl: string): boolean
return Boolean(idA && idB && idA === idB)
}
/**
* When popstate has no history state (e.g. after pushState(null, ) on load), the URL still updates
* but we must realign the secondary stack; otherwise the panel shows a stale page.
*/
function syncSecondaryStackWhenPopStateStateIsNull(pre: TStackItem[], locUrl: string): TStackItem[] {
const pathOnly = locUrl.split('?')[0].split('#')[0]
/** `/`, `/feed`, `/explore`, etc. — not `/notes/…`, `/feed/notes/…`, `/relays/…`. */
function isPrimaryOnlyPathname(pathname: string): boolean {
const pathOnly = pathname.split('?')[0].split('#')[0]
const segments = pathOnly.split('/').filter(Boolean)
const firstSeg = segments[0] ?? ''
const primaryMap = getPrimaryPageMap()
const isPrimaryOnly =
return (
segments.length === 0 ||
(segments.length === 1 &&
(firstSeg === 'discussions' ||
firstSeg === 'home' ||
firstSeg === 'explore' ||
firstSeg in primaryMap))
if (isPrimaryOnly) {
)
}
/**
* When popstate has no history state (e.g. after pushState(null, ) on load), the URL still updates
* but we must realign the secondary stack; otherwise the panel shows a stale page.
*/
function syncSecondaryStackWhenPopStateStateIsNull(pre: TStackItem[], locUrl: string): TStackItem[] {
const pathOnly = locUrl.split('?')[0].split('#')[0]
if (isPrimaryOnlyPathname(pathOnly)) {
return []
}

18
src/components/NoteStats/SeenOnButton.tsx

@ -11,12 +11,12 @@ import { @@ -11,12 +11,12 @@ import {
} from '@/components/ui/dropdown-menu'
import { toRelay } from '@/lib/link'
import { filterRelaysToUserAllowlist } from '@/lib/relay-allowlist'
import { simplifyUrl } from '@/lib/url'
import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import client from '@/services/client.service'
import { Server } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
@ -33,6 +33,15 @@ export default function SeenOnButton({ @@ -33,6 +33,15 @@ export default function SeenOnButton({
const { push } = useSecondaryPage()
const [relays, setRelays] = useState<string[]>([])
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const allowedRelaysRef = useRef(allowedRelays)
allowedRelaysRef.current = allowedRelays
const allowedRelaysKey = allowedRelays?.length
? [...allowedRelays]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort()
.join('|')
: ''
useEffect(() => {
let cancelled = false
@ -40,8 +49,9 @@ export default function SeenOnButton({ @@ -40,8 +49,9 @@ export default function SeenOnButton({
const maxAttempts = 20
const apply = () => {
const seenOn = client.getSeenEventRelayUrls(event.id)
const allowlist = allowedRelaysRef.current
const visible =
allowedRelays?.length ? filterRelaysToUserAllowlist(seenOn, allowedRelays) : seenOn
allowlist?.length ? filterRelaysToUserAllowlist(seenOn, allowlist) : seenOn
if (!cancelled) setRelays(visible)
return visible.length > 0
}
@ -55,7 +65,7 @@ export default function SeenOnButton({ @@ -55,7 +65,7 @@ export default function SeenOnButton({
cancelled = true
clearInterval(id)
}
}, [event.id, allowedRelays])
}, [event.id, allowedRelaysKey])
const trigger = (
<button

15
src/components/NoteStats/index.tsx

@ -8,6 +8,7 @@ import noteStatsService from '@/services/note-stats.service' @@ -8,6 +8,7 @@ import noteStatsService from '@/services/note-stats.service'
import { ExtendedKind } from '@/constants'
import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot'
import { shouldHideInteractions } from '@/lib/event-filtering'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { Event } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import BookmarkButton from '../BookmarkButton'
@ -73,6 +74,15 @@ export default function NoteStats({ @@ -73,6 +74,15 @@ export default function NoteStats({
const statsRelayFetchTier = isRssArticleRoot ? relayMergeTier : hintRelays.length > 0 ? 1 : 0
const statsRelaysRef = useRef(statsRelays)
statsRelaysRef.current = statsRelays
const seenOnAllowlistRef = useRef(seenOnAllowlist)
seenOnAllowlistRef.current = seenOnAllowlist
const seenOnAllowlistKey = seenOnAllowlist?.length
? [...seenOnAllowlist]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort()
.join('|')
: ''
const shouldDeferStatsFetch =
deferFetchUntilNearViewport ?? (fetchIfNotExisting && !foregroundStats)
const containerRef = useRef<HTMLDivElement>(null)
@ -85,12 +95,13 @@ export default function NoteStats({ @@ -85,12 +95,13 @@ export default function NoteStats({
noteStatsService
.fetchNoteStats(event, pubkey, statsRelaysRef.current, {
foreground: foregroundStats,
relayAllowlist: seenOnAllowlist?.length ? seenOnAllowlist : null
relayAllowlist: seenOnAllowlistRef.current?.length ? seenOnAllowlistRef.current : null
})
.finally(() => setLoading(false))
// Intentionally omit `event` object: parent feeds often pass new references each render;
// id/sig/kind/created_at identify the note for refetch boundaries.
// `statsRelayFetchTier` (not full sorted relay key) avoids a REQ storm when favorites/current relays hydrate.
// `seenOnAllowlistKey` (not the array ref) avoids refetch loops when parents pass a new [] each render.
}, [
event.id,
event.kind,
@ -103,7 +114,7 @@ export default function NoteStats({ @@ -103,7 +114,7 @@ export default function NoteStats({
pubkey,
statsRelayFetchTier,
currentRelaysKey,
seenOnAllowlist
seenOnAllowlistKey
])
const interactionButtons = (

3
src/components/ReplyNoteList/index.tsx

@ -1482,8 +1482,7 @@ function ReplyNoteList({ @@ -1482,8 +1482,7 @@ function ReplyNoteList({
highlightReply(parentEventHexId)
}}
onClickReply={belongsToSameThread ? (replyEvent) => {
const replyNoteUrl = toNote(replyEvent)
window.history.pushState(null, '', replyNoteUrl)
// Highlight only — do not push history (null pushState desynced stack vs URL on Back).
const replyIndex = mergedFeed.findIndex((r) => r.id === replyEvent.id)
if (replyIndex >= 0 && replyIndex >= showCount) {
setShowCount(replyIndex + 1)

10
src/services/note-stats.service.ts

@ -120,14 +120,14 @@ class NoteStatsService { @@ -120,14 +120,14 @@ class NoteStatsService {
}
/** Merge extra relay URLs into the pending fetch context for this note (deduped). */
private mergeFavoriteRelaysIntoPending(eventId: string, extra: string[] | null | undefined) {
private mergeFavoriteRelaysIntoPending(eventId: string, extra: readonly string[] | null | undefined) {
if (!extra?.length) return
const cur = this.pendingFetchFavoriteRelays.get(eventId)
const merged = new Set<string>([...(cur ?? []), ...extra])
this.pendingFetchFavoriteRelays.set(eventId, [...merged])
}
private mergeFavoriteRelaysIntoDeferred(eventId: string, extra: string[] | null | undefined) {
private mergeFavoriteRelaysIntoDeferred(eventId: string, extra: readonly string[] | null | undefined) {
if (!extra?.length) return
const cur = this.inFlightDeferredFavoriteRelays.get(eventId)
const merged = new Set<string>([...(cur ?? []), ...extra])
@ -190,7 +190,7 @@ class NoteStatsService { @@ -190,7 +190,7 @@ class NoteStatsService {
async fetchNoteStats(
event: Event,
_pubkey?: string | null,
favoriteRelays?: string[] | null,
favoriteRelays?: readonly string[] | null,
opts?: { foreground?: boolean; relayAllowlist?: readonly string[] | null }
) {
const eventId = this.statsKey(event.id)
@ -237,7 +237,7 @@ class NoteStatsService { @@ -237,7 +237,7 @@ class NoteStatsService {
return
}
this.pendingFetchFavoriteRelays.set(eventId, favoriteRelays ?? null)
this.pendingFetchFavoriteRelays.set(eventId, favoriteRelays?.length ? [...favoriteRelays] : null)
if (opts?.relayAllowlist?.length) {
this.pendingFetchRelayAllowlist.set(eventId, opts.relayAllowlist)
} else {
@ -615,7 +615,7 @@ class NoteStatsService { @@ -615,7 +615,7 @@ class NoteStatsService {
/** {@link buildComprehensiveRelayList} for reactions/reposts/zaps on a note (thread hints, capped author NIP-65). */
private async buildNoteStatsRelayList(
event: Event,
favoriteRelays?: string[] | null,
favoriteRelays?: readonly string[] | null,
relayAllowlist?: readonly string[]
): Promise<string[]> {
const me = client.pubkey?.trim()

Loading…
Cancel
Save