Skip to content
3 changes: 3 additions & 0 deletions src/app/Components/ArtworkGrids/GenericGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface Props {
saleInfoTextStyle?: TextProps
fitToFrame?: boolean
onItemVisibilityChange?: (artworkID: string, index: number, visible: boolean) => void
useLiveVisibilityTracking?: boolean
}

type PropsForArtwork = Omit<ArtworkProps, "artwork">
Expand All @@ -45,6 +46,7 @@ export const GenericGrid: React.FC<Props & PropsForArtwork> = ({
trackingFlow,
fitToFrame = false,
onItemVisibilityChange,
useLiveVisibilityTracking,
}) => {
const space = useSpace()
const artworks = useFragment(genericGridFragment, artworksProp)
Expand All @@ -69,6 +71,7 @@ export const GenericGrid: React.FC<Props & PropsForArtwork> = ({
trackingFlow={trackingFlow}
fitToFrame={fitToFrame}
onItemVisibilityChange={onItemVisibilityChange}
useLiveVisibilityTracking={useLiveVisibilityTracking}
/>
</Flex>
{isLoading ? <Spinner style={{ marginTop: space(2) }} testID="spinner" /> : null}
Expand Down
31 changes: 29 additions & 2 deletions src/app/Components/ArtworkGrids/MasonryArtworkGridItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import ArtworkGridItem, {
ArtworkProps,
PriceOfferMessage,
} from "app/Components/ArtworkGrids/ArtworkGridItem"
import { useGridItemVisibility } from "app/Components/ArtworkGrids/useGridItemVisibility"
import { PartnerOffer } from "app/Scenes/Activity/components/PartnerOfferCreatedNotification"
import { Sentinel } from "app/utils/Sentinel"
import { NUM_COLUMNS_MASONRY } from "app/utils/masonryHelpers"
import { ViewProps } from "react-native"
import { useRef } from "react"
import { View, ViewProps } from "react-native"
import { FragmentRefs } from "relay-runtime"

interface Artwork {
Expand Down Expand Up @@ -46,6 +48,13 @@ interface MasonryArtworkGridItemProps extends Omit<ArtworkProps, "artwork"> {
* Use for impression tracking in nested, non-scroll grids.
*/
onItemVisibilityChange?: (artworkInternalID: string, index: number, visible: boolean) => void
/**
* Whether this grid needs repeatable visibility detection (i.e. NWFY) — items that stay on
* screen across a live refresh re-fire onItemVisibilityChange, via useGridItemVisibility instead
* of the shared Sentinel, which only ever fires its first "true" transition (fine for one-shot
* consumers elsewhere). Leave undefined to keep the default Sentinel-based behavior.
*/
useLiveVisibilityTracking?: boolean
}

export const MasonryArtworkGridItem: React.FC<MasonryArtworkGridItemProps> = ({
Expand All @@ -63,6 +72,7 @@ export const MasonryArtworkGridItem: React.FC<MasonryArtworkGridItemProps> = ({
partnerOffer,
priceOfferMessage,
onItemVisibilityChange,
useLiveVisibilityTracking,
...rest
}) => {
const space = useSpace()
Expand All @@ -73,9 +83,26 @@ export const MasonryArtworkGridItem: React.FC<MasonryArtworkGridItemProps> = ({
const imgWidth = numColumns === 1 ? width : width / numColumns - space(2) - space(1)
const imgHeight = imgWidth / imgAspectRatio

const contentRef = useRef<View>(null)
const usesGridItemVisibility = !!onItemVisibilityChange && !!useLiveVisibilityTracking

// Measures the existing content Flex directly (via `ref` below) rather than an extra wrapping
// View — an earlier version wrapped the content in its own View to get a real height to measure,
// but that confused FlashList's masonry column-height measurement and pushed later items off
// screen. Called unconditionally (rules of hooks); it's a no-op unless `usesGridItemVisibility`.
useGridItemVisibility({
ref: contentRef,
threshold: 0.5,
enabled: usesGridItemVisibility,
onVisibilityChange: (visible) => {
onItemVisibilityChange?.(item.internalID || item.id, index, visible)
},
})

return (
<>
<Flex
ref={contentRef}
left={
fullWidth
? // When displayed full width, we want artworks to be displayed full width
Expand All @@ -100,7 +127,7 @@ export const MasonryArtworkGridItem: React.FC<MasonryArtworkGridItemProps> = ({
priceOfferMessage={priceOfferMessage}
/>
</Flex>
{!!onItemVisibilityChange && (
{!!onItemVisibilityChange && !usesGridItemVisibility && (
<Sentinel
threshold={0.5}
onChange={(visible) => onItemVisibilityChange(item.internalID || item.id, index, visible)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ interface MasonryInfiniteScrollArtworkGridProps extends MasonryFlashListOmittedP
trackingFlow?: string
fitToFrame?: boolean
onItemVisibilityChange?: (artworkID: string, index: number, visible: boolean) => void
useLiveVisibilityTracking?: boolean
}

/**
Expand Down Expand Up @@ -91,6 +92,7 @@ export const MasonryInfiniteScrollArtworkGrid: React.FC<MasonryInfiniteScrollArt
saleInfoTextStyle,
fitToFrame,
onItemVisibilityChange,
useLiveVisibilityTracking,
...rest
}) => {
const space = useSpace()
Expand Down Expand Up @@ -141,6 +143,7 @@ export const MasonryInfiniteScrollArtworkGrid: React.FC<MasonryInfiniteScrollArt
trackTap={trackTap}
fitToFrame={fitToFrame}
onItemVisibilityChange={onItemVisibilityChange}
useLiveVisibilityTracking={useLiveVisibilityTracking}
fullWidth={adjustedNumColumns === 1}
/>
),
Expand Down Expand Up @@ -170,6 +173,7 @@ export const MasonryInfiniteScrollArtworkGrid: React.FC<MasonryInfiniteScrollArt
trackTap,
fitToFrame,
onItemVisibilityChange,
useLiveVisibilityTracking,
]
)

Expand Down
100 changes: 100 additions & 0 deletions src/app/Components/ArtworkGrids/useGridItemVisibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useIsFocused } from "@react-navigation/native"
import { BOTTOM_TABS_HEIGHT } from "app/Navigation/AuthenticatedRoutes/Tabs"
import { RefObject, useCallback, useEffect, useRef } from "react"
import { Dimensions, View } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"

const POLL_INTERVAL = 1000

interface UseGridItemVisibilityArgs {
ref: RefObject<View | null>
/** The value indicates the minimum percentage of the container that must be visible (vertically or horizontally). A value of 1 means 100%, 0.7 means 70%, and so forth. The default value is 1 (100%). */
threshold?: number
enabled: boolean
onVisibilityChange: (visible: boolean) => void
}

/**
* Purpose-built alternative to the shared `Sentinel` for grid items that need repeatable
* visibility detection. `Sentinel`'s own polling effect has an empty dependency array, so its
* internal comparison value freezes at mount and it can only ever report one "true" transition for
* its entire lifetime — harmless for its other, fire-once consumers elsewhere in the app, but not
* for a rail that live-refreshes and needs to re-track items that are already on screen.
*
* Reports current visibility on every poll tick, unconditionally, rather than only on a change —
* so it doesn't need to be told when a live refresh happens at all. The caller's own "already
* tracked" guard (reset on refresh) is what decides whether a given report actually turns into an
* event; a redundant "still visible" report while that guard is set is just a harmless no-op.
*
* Implemented as a hook (rather than a component wrapping the item in an extra View, as an earlier
* version did) specifically so it doesn't change the rendered tree shape: the caller attaches
* `ref` directly to its existing content container. Wrapping the content in an additional View
* confused FlashList's masonry column-height measurement, pushing later items far off-screen.
*/
export const useGridItemVisibility = ({
ref,
threshold = 1,
enabled,
onVisibilityChange,
}: UseGridItemVisibilityArgs) => {
const insets = useSafeAreaInsets()
const isFocused = useIsFocused()

// Read via a ref so the poll below always calls the latest version, regardless of when its
// closure was created.
const onVisibilityChangeRef = useRef(onVisibilityChange)
onVisibilityChangeRef.current = onVisibilityChange

const measure = useCallback(
(report: (visible: boolean) => void) => {
ref.current?.measure(
(_x: number, _y: number, width: number, height: number, pageX: number, pageY: number) => {
const window = Dimensions.get("window")
// The visible content area ends above the bottom tab bar, so an item sitting behind it
// shouldn't count toward the visible fraction — using the raw window height here let
// items near the bottom tab bar read as "50%+ visible" when most of that overlap was
// actually hidden behind the tab bar.
const viewportHeight = window.height - BOTTOM_TABS_HEIGHT - insets.bottom

if (height <= 0 || width <= 0) {
report(false)
return
}

// Overlap between the item's bounds and the viewport on each axis, as a fraction of the
// item's own size — symmetric at both edges, unlike a pair of one-sided inequalities
// (e.g. a hard `pageY >= 0` for the top combined with a thresholded check at the bottom
// only), which would under-report items that are mostly visible but clipped at the top.
const visibleHeight = Math.max(
0,
Math.min(pageY + height, viewportHeight) - Math.max(pageY, 0)
)
const visibleWidth = Math.max(
0,
Math.min(pageX + width, window.width) - Math.max(pageX, 0)
)

const isVisible = visibleHeight >= height * threshold && visibleWidth >= width * threshold

report(isVisible)
}
)
},
[threshold, ref, insets.bottom]
)

// Paused while Home isn't focused (e.g. a pushed artwork screen on top of it) — react-native-
// screens freezes the Home tree in that state without unmounting it, so this interval would
// otherwise keep firing a native measure() every second for as long as the user is elsewhere.
useEffect(() => {
if (!enabled || !isFocused) {
return
}

const interval = setInterval(() => {
measure((visible) => onVisibilityChangeRef.current(visible))
}, POLL_INTERVAL)

Comment thread
github-actions[bot] marked this conversation as resolved.
return () => clearInterval(interval)
}, [enabled, isFocused, measure])
}
66 changes: 64 additions & 2 deletions src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
import { ContextModule } from "@artsy/cohesion"
import { useIsFocused } from "@react-navigation/native"
import { HomeViewStore } from "app/Scenes/HomeView/HomeViewContext"
import { useHomeViewTracking } from "app/Scenes/HomeView/hooks/useHomeViewTracking"
import { Sentinel } from "app/utils/Sentinel"
import React, { useCallback } from "react"
import React, { useCallback, useEffect, useRef } from "react"
import { Dimensions, View } from "react-native"

const POLL_INTERVAL = 1000

interface HomeViewSectionSentinelProps {
contextModule: ContextModule
sectionType?: string
index: number
/**
* Whether this is one of the "live" rails (WTYL, NWFY) that refetch and re-fire tracking after
* an interaction. Only these need repeatable visibility tracking (see below) — every other
* section renders the shared `Sentinel` unchanged.
*/
isLiveRefreshRail?: boolean
}

export const HomeViewSectionSentinel: React.FC<HomeViewSectionSentinelProps> = ({
contextModule,
sectionType,
index,
isLiveRefreshRail = false,
}) => {
const { viewedSection } = useHomeViewTracking()
const addTrackedSection = HomeViewStore.useStoreActions((actions) => actions.addTrackedSection)
Expand All @@ -22,6 +33,9 @@ export const HomeViewSectionSentinel: React.FC<HomeViewSectionSentinelProps> = (
(actions) => actions.addTrackedSectionTypes
)

const markerRef = useRef<View>(null)
const isFocused = useIsFocused()

const handleVisibilityChange = useCallback(
(visible: boolean) => {
if (visible && !trackedSections.includes(contextModule)) {
Expand All @@ -32,8 +46,56 @@ export const HomeViewSectionSentinel: React.FC<HomeViewSectionSentinelProps> = (
}
}
},
[contextModule, viewedSection, addTrackedSection, trackedSections]
[
contextModule,
viewedSection,
addTrackedSection,
trackedSections,
index,
sectionType,
addTrackedSectionTypes,
]
)

// Read via a ref inside the interval below so it always calls the latest version (the poll
// effect itself only depends on `isLiveRefreshRail`, not on this callback's own dependencies).
const handleVisibilityChangeRef = useRef(handleVisibilityChange)
handleVisibilityChangeRef.current = handleVisibilityChange

// Live rails need to detect visibility repeatedly: on the original scroll-into-view, then again
// after every live refresh (once the parent resets the "tracked" guard). The shared `Sentinel`
// only ever fires its first "true" transition — a stale-closure bug in its own polling effect
// freezes its comparison value at mount, so it can never detect a later re-entry into the
// viewport. That's harmless for Sentinel's other, fire-once consumers elsewhere in the app, but
// not for these always-live sections, so we poll directly here instead, scoped to just this case.
//
// Reports current visibility on every tick, unconditionally, rather than only on a change — so a
// live refresh doesn't need to explicitly tell this component anything. `handleVisibilityChange`'s
// own "already tracked" guard (reset by the parent on refresh) is what decides whether a given
// report actually fires railViewed; a redundant "still visible" report is a harmless no-op.
// Paused while Home isn't focused (e.g. a pushed artwork screen on top of it) — react-native-
// screens freezes the Home tree in that state without unmounting it, so this interval would
// otherwise keep firing a native measure() every second for as long as the user is elsewhere.
useEffect(() => {
if (!isLiveRefreshRail || !isFocused) {
return
}

const interval = setInterval(() => {
markerRef.current?.measure(
(_x: number, _y: number, _width: number, height: number, _pageX: number, pageY: number) => {
const windowHeight = Dimensions.get("window").height
handleVisibilityChangeRef.current(pageY + height > 0 && pageY < windowHeight)
}
)
}, POLL_INTERVAL)

return () => clearInterval(interval)
}, [isLiveRefreshRail, isFocused])

if (isLiveRefreshRail) {
return <View ref={markerRef} collapsable={false} testID="home-view-section-sentinel-marker" />
}

return <Sentinel onChange={handleVisibilityChange} />
}
7 changes: 7 additions & 0 deletions src/app/Scenes/HomeView/HomeViewContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface HomeViewStoreModel {
viewableSections: string[]
liveRefetchKey: number
addTrackedSection: Action<this, string>
removeTrackedSection: Action<this, string>
addTrackedSectionTypes: Action<this, string>
addTrackedExperiment: Action<this, string>
setViewableSections: Action<this, string[]>
Expand All @@ -26,6 +27,12 @@ export const HomeViewStoreModel: HomeViewStoreModel = {
}
state.trackedSections.push(payload)
}),
removeTrackedSection: action((state, payload) => {
if (!state.trackedSections.includes(payload)) {
return
}
state.trackedSections = state.trackedSections.filter((section) => section !== payload)
}),
addTrackedSectionTypes: action((state, payload) => {
if (state.trackedSectionTypes.includes(payload)) {
return
Expand Down
Loading
Loading