diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 693a71ed..2a1d74d5 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -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 { 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 }) { recentSecondaryPushRef.current = { url, at: now } noteStatsService.setBackgroundStatsPaused(true) - client.interruptBackgroundQueries() + 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) } @@ -2119,6 +2123,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const top = peekMobilePrimaryFeedScroll(page) requestAnimationFrame(() => { 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 + /** 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 + ] + ) + + 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 }) { {isSmallScreen ? ( - + ) : ( <> +
0 && 'hidden' + )} + aria-hidden={secondaryStack.length > 0} + > + {renderActivePrimaryPageContent(primaryPages, currentPrimaryPage)} +
{secondaryStack.length > 0 ? (
) : null} - {secondaryStack.length === 0 ? ( -
- {renderActivePrimaryPageContent(primaryPages, currentPrimaryPage)} -
- ) : null} )} @@ -2382,14 +2410,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
) : ( - + { const checkIfPinned = async () => { if (!pubkey) { - setIsPinned(false) + setIsPinnedInMyList(false) return } try { @@ -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).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({ 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({ 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({ } } - // 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({ mutePubkeyPublicly, unmutePubkey, attemptDelete, - isPinned, + isPinnedInMyList, handlePinNote, isArticleType, articleMetadata, diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 016a4755..49a5f78a 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -330,14 +330,15 @@ export default function Profile({ return ( <>
-
+
+
- - + +
@@ -363,7 +364,7 @@ export default function Profile({ return ( <>
-
+
{/* Banner first in paint order; avatar uses higher z-index so it always sits on top. fetchPriority still prefers the pic over the banner. */} {isVideo(avatar ?? '') ? ( -
+
) : ( -
+
)}
+ {/* Below banner only: room for avatar half that extends past the banner edge */} +
) : null}
-
+
{username}
diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index abfac536..502ba9c1 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -68,9 +68,11 @@ import { buildVisibleBacklinkRows, EA_THREAD_TAIL_REFERENCE_KINDS, fetchPaymentAttestationsForRecipient, + hydrateThreadRepliesFromStats, isEaThreadTailBacklinkCandidate, isPollVoteKind, isWebThreadTailKind, + loadThreadRepliesFromLocalStores, mergeFetchedKind7ReactionsIntoRootNoteStats, moveReportsToEndPreserveOrder, partitionAndSortBacklinkTail, @@ -518,129 +520,56 @@ function ReplyNoteList({ const replyRefs = useRef>({}) const bottomRef = useRef(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>(new Set()) + /** When note-stats counted replies we did not REQ in the thread, fetch by id from archive/session. */ + const statsHydratedReplyIdsRef = useRef>(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>(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) statsHydratedReplyIdsRef.current.add(id) + const batch = await hydrateThreadRepliesFromStats( + candidates, + rootInfo, + event, + isDiscussionRoot + ) + if (cancelled) return for (const { id } of candidates) { - discussionStatsHydratedReplyIdsRef.current.add(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) + if (!batch.some((e) => e.id === id)) statsHydratedReplyIdsRef.current.delete(id) } + const ok = batch.filter( + (e) => + !shouldHideThreadResponseEvent( + e, + mutePubkeySet, + hideContentMentioningMutedUsers + ) + ) + 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({ 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() diff --git a/src/components/ReplyNoteList/reply-list-utils.ts b/src/components/ReplyNoteList/reply-list-utils.ts index eccaab24..331d2171 100644 --- a/src/components/ReplyNoteList/reply-list-utils.ts +++ b/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 { THREAD_REPLY_LIMIT } from './types' export type { TRootInfo } from './types' 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, + hideContentMentioningMutedUsers: boolean | undefined +): Promise { + 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 { + if (!candidates.length) return [] + + const ids = candidates.map((c) => c.id) + const byId = new Map() + 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, diff --git a/src/contexts/primary-page-context.tsx b/src/contexts/primary-page-context.tsx index 34eedfaa..f84d14a7 100644 --- a/src/contexts/primary-page-context.tsx +++ b/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). */ 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 /** diff --git a/src/contexts/secondary-page-context.tsx b/src/contexts/secondary-page-context.tsx index 8ad25e4a..dbb7b8ad 100644 --- a/src/contexts/secondary-page-context.tsx +++ b/src/contexts/secondary-page-context.tsx @@ -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(undefined) diff --git a/src/hooks/useProfilePins.tsx b/src/hooks/useProfilePins.tsx index 4d970e7e..d23718c7 100644 --- a/src/hooks/useProfilePins.tsx +++ b/src/hooks/useProfilePins.tsx @@ -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) { void loadPins(false) }, [pubkey, loadPins]) + useEffect(() => { + if (!pubkey) return + + const handler = (raw: globalThis.Event) => { + const detail = (raw as CustomEvent).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`) diff --git a/src/hooks/useRemovePinListEntry.ts b/src/hooks/useRemovePinListEntry.ts index 39b1a3c8..2c4c5ea7 100644 --- a/src/hooks/useRemovePinListEntry.ts +++ b/src/hooks/useRemovePinListEntry.ts @@ -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) { { specifiedRelayUrls: comprehensiveRelays } ) await indexedDb.putReplaceableEvent(published as Event) + dispatchPinListUpdated({ + ownerPubkey: pubkey, + toggledEvent: loadedEvent ?? undefined, + pinned: false + }) onSuccess?.() return true }, diff --git a/src/layouts/PrimaryPageLayout/index.tsx b/src/layouts/PrimaryPageLayout/index.tsx index df4dec1d..123c22bc 100644 --- a/src/layouts/PrimaryPageLayout/index.tsx +++ b/src/layouts/PrimaryPageLayout/index.tsx @@ -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( 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( if (isSmallScreen) { return ( - +
(PIN_LIST_UPDATED_EVENT, { + detail: { + ...detail, + ownerPubkey: detail.ownerPubkey.trim().toLowerCase() + } + }) + ) +}