diff --git a/packages/shared/src/hooks/useFeed.ts b/packages/shared/src/hooks/useFeed.ts index 80864c79b6..aae125e634 100644 --- a/packages/shared/src/hooks/useFeed.ts +++ b/packages/shared/src/hooks/useFeed.ts @@ -29,6 +29,7 @@ import { usePlusSubscription } from './usePlusSubscription'; import { LogEvent } from '../lib/log'; import { useLogContext } from '../contexts/LogContext'; import type { FeedAdTemplate } from '../lib/feed'; +import { getAdSlotIndex } from '../lib/feed'; import { featureFeedAdTemplate } from '../lib/featureManagement'; import { cloudinaryPostImageCoverPlaceholder } from '../lib/image'; import { AD_PLACEHOLDER_SOURCE_ID } from '../lib/constants'; @@ -268,6 +269,13 @@ export default function useFeed( ); const { fetchAd } = useFetchAd(); + // Per-mount random seed for ad jitter. Stable across re-renders/pagination + // (so ads don't visibly jump as new pages load) but varies across mounts and + // sessions, so the same user doesn't see ads in the same spots every visit. + const adJitterSeedRef = useRef(); + if (!adJitterSeedRef.current) { + adJitterSeedRef.current = Math.random().toString(36).slice(2); + } const adsQuery = useInfiniteQuery< Ad, ClientError, @@ -318,21 +326,20 @@ export default function useFeed( adTemplate?.adStart ?? featureFeedAdTemplate.defaultValue.default.adStart; const adRepeat = adTemplate?.adRepeat ?? pageSize + 1; + const adJitter = adTemplate?.adJitter ?? 0; + + const adPage = getAdSlotIndex({ + index, + adStart, + adRepeat, + adJitter, + seed: adJitterSeedRef.current ?? '', + }); - const adIndex = index - adStart; // 0-based index from adStart - - // if adIndex is negative, it means we are not supposed to show ads yet based on adStart - if (adIndex < 0) { - return undefined; - } - const adMatch = adIndex % adRepeat === 0; // should ad be shown at this index based on adRepeat - - if (!adMatch) { + if (adPage === undefined) { return undefined; } - const adPage = adIndex / adRepeat; // page number for ad - if (isLoading) { return createPlaceholderItem(adPage); } @@ -365,6 +372,7 @@ export default function useFeed( isLoading, adTemplate?.adStart, adTemplate?.adRepeat, + adTemplate?.adJitter, adsUpdatedAt, pageSize, ], diff --git a/packages/shared/src/lib/feed.spec.ts b/packages/shared/src/lib/feed.spec.ts new file mode 100644 index 0000000000..284bfd8ac2 --- /dev/null +++ b/packages/shared/src/lib/feed.spec.ts @@ -0,0 +1,134 @@ +import { getAdSlotIndex } from './feed'; + +describe('getAdSlotIndex', () => { + const seed = '["feed","my-feed"]'; + + it('matches modulo math when adJitter is 0', () => { + const adStart = 2; + const adRepeat = 5; + const positions: number[] = []; + for (let index = 0; index < 40; index += 1) { + const n = getAdSlotIndex({ index, adStart, adRepeat, seed }); + if (n !== undefined) { + positions.push(index); + } + } + expect(positions).toEqual([2, 7, 12, 17, 22, 27, 32, 37]); + }); + + it('returns undefined for indices before adStart (no jitter)', () => { + const adStart = 2; + const adRepeat = 5; + expect( + getAdSlotIndex({ index: 0, adStart, adRepeat, seed }), + ).toBeUndefined(); + expect( + getAdSlotIndex({ index: 1, adStart, adRepeat, seed }), + ).toBeUndefined(); + }); + + it('returns undefined for non-positive adRepeat', () => { + expect( + getAdSlotIndex({ index: 2, adStart: 2, adRepeat: 0, seed }), + ).toBeUndefined(); + }); + + it('never places the first ad before adStart, even with jitter', () => { + const adStart = 2; + const adRepeat = 5; + const adJitter = 3; + const seeds = Array.from({ length: 50 }, (_, i) => `["feed","user-${i}"]`); + seeds.forEach((s) => { + let firstHit: number | undefined; + for (let index = 0; index < adStart + adRepeat; index += 1) { + if ( + getAdSlotIndex({ index, adStart, adRepeat, adJitter, seed: s }) !== + undefined + ) { + firstHit = index; + break; + } + } + expect(firstHit).toBeDefined(); + expect(firstHit).toBeGreaterThanOrEqual(adStart); + expect(firstHit).toBeLessThanOrEqual(adStart + adJitter); + }); + }); + + it('keeps consecutive ad gaps within [adRepeat - J, adRepeat + J]', () => { + const adStart = 2; + const adRepeat = 5; + const adJitter = 3; + const hits: number[] = []; + for (let index = 0; index < 200; index += 1) { + if ( + getAdSlotIndex({ index, adStart, adRepeat, adJitter, seed }) !== + undefined + ) { + hits.push(index); + } + } + expect(hits.length).toBeGreaterThan(5); + for (let i = 1; i < hits.length; i += 1) { + const gap = hits[i] - hits[i - 1]; + expect(gap).toBeGreaterThanOrEqual(adRepeat - adJitter); + expect(gap).toBeLessThanOrEqual(adRepeat + adJitter); + } + }); + + it('is deterministic for the same seed and slot index', () => { + const args = { + adStart: 2, + adRepeat: 5, + adJitter: 2, + seed, + }; + const runA: Array = []; + const runB: Array = []; + for (let index = 0; index < 40; index += 1) { + runA.push(getAdSlotIndex({ index, ...args })); + runB.push(getAdSlotIndex({ index, ...args })); + } + expect(runA).toEqual(runB); + }); + + it('produces different positions for different seeds', () => { + const args = { adStart: 2, adRepeat: 5, adJitter: 2 }; + const collect = (s: string): number[] => { + const hits: number[] = []; + for (let index = 0; index < 60; index += 1) { + if (getAdSlotIndex({ index, ...args, seed: s }) !== undefined) { + hits.push(index); + } + } + return hits; + }; + const seedA = '["feed","user-a"]'; + const seedB = '["feed","user-b"]'; + expect(collect(seedA)).not.toEqual(collect(seedB)); + }); + + it('always leaves at least one post between consecutive ads, regardless of jitter size', () => { + // adRepeat is small and jitter is intentionally huge — clamp must still + // guarantee `gap >= 2` so we never render `ad, ad` back-to-back. + const adStart = 2; + const adRepeat = 3; + const adJitter = 100; + const seeds = Array.from({ length: 25 }, (_, i) => `["feed","user-${i}"]`); + seeds.forEach((s) => { + const hits: number[] = []; + for (let index = 0; index < 200; index += 1) { + if ( + getAdSlotIndex({ index, adStart, adRepeat, adJitter, seed: s }) !== + undefined + ) { + hits.push(index); + } + } + expect(hits.length).toBeGreaterThan(5); + for (let i = 1; i < hits.length; i += 1) { + expect(hits[i] - hits[i - 1]).toBeGreaterThanOrEqual(2); + } + }); + }); +}); diff --git a/packages/shared/src/lib/feed.ts b/packages/shared/src/lib/feed.ts index b4a97ae582..3246e5c011 100644 --- a/packages/shared/src/lib/feed.ts +++ b/packages/shared/src/lib/feed.ts @@ -274,6 +274,62 @@ export const getFeedName = ( export type FeedAdTemplate = { adStart: number; adRepeat?: number; + adJitter?: number; +}; + +/* eslint-disable no-bitwise -- intentional bitwise ops for FNV-1a hash */ +const hashSeed = (key: string, n: number): number => { + let h = 2166136261 >>> 0; + const s = `${key}:${n}`; + for (let i = 0; i < s.length; i += 1) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + return h; +}; +/* eslint-enable no-bitwise */ + +// Minimum index distance between two consecutive ads. A gap of 2 guarantees +// at least one post between any two ads, so we never render `ad, ad` back-to-back. +const MIN_AD_GAP = 2; + +export const getAdSlotIndex = ({ + index, + adStart, + adRepeat, + adJitter = 0, + seed, +}: { + index: number; + adStart: number; + adRepeat: number; + adJitter?: number; + seed: string; +}): number | undefined => { + if (adRepeat <= 0) { + return undefined; + } + if (index < adStart) { + return undefined; + } + // Clamp jitter so each gap stays >= MIN_AD_GAP. With per-gap symmetric jitter + // in [-J, +J], the minimum gap is `adRepeat - J`, so J must be <= adRepeat - MIN_AD_GAP. + const safeJitter = Math.max(0, Math.min(adJitter, adRepeat - MIN_AD_GAP)); + // Walk slots forward from 0, applying one independent jitter per gap, until + // we either hit `index` or pass it. The first slot uses one-sided jitter + // (0..+J) so the first ad never lands before `adStart`. + let pos = + adStart + (safeJitter === 0 ? 0 : hashSeed(seed, 0) % (safeJitter + 1)); + let n = 0; + while (pos < index) { + n += 1; + const offset = + safeJitter === 0 + ? 0 + : (hashSeed(seed, n) % (safeJitter * 2 + 1)) - safeJitter; + pos += adRepeat + offset; + } + return pos === index ? n : undefined; }; export function usePostLogEvent() {