From f1a4b87dd9be4b5e618e15e81a6b40eb68761064 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 10 Nov 2022 19:10:50 -0600 Subject: [PATCH] Support x-truth-ad-indexes header --- app/soapbox/actions/timelines.ts | 15 ++++++++++++-- app/soapbox/components/status_list.tsx | 20 +++++++++++++++---- app/soapbox/features/test_timeline/index.tsx | 2 +- .../features/timeline-insertion/index.ts | 2 +- app/soapbox/reducers/timelines.ts | 9 +++++++-- app/soapbox/utils/ads.ts | 13 +++++++++++- 6 files changed, 50 insertions(+), 11 deletions(-) diff --git a/app/soapbox/actions/timelines.ts b/app/soapbox/actions/timelines.ts index 5e4bd26d6..c14fe38c5 100644 --- a/app/soapbox/actions/timelines.ts +++ b/app/soapbox/actions/timelines.ts @@ -2,6 +2,7 @@ import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutabl import { getSettings } from 'soapbox/actions/settings'; import { normalizeStatus } from 'soapbox/normalizers'; +import { adIndexesFromHeader } from 'soapbox/utils/ads'; import { shouldFilter } from 'soapbox/utils/timelines'; import api, { getLinks } from '../api'; @@ -172,8 +173,9 @@ const expandTimeline = (timelineId: string, path: string, params: Record { const next = getLinks(response).refs.find(link => link.rel === 'next'); + const adIndexes = adIndexesFromHeader(response); dispatch(importFetchedStatuses(response.data)); - dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore)); + dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, adIndexes)); done(); }).catch(error => { dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); @@ -234,7 +236,15 @@ const expandTimelineRequest = (timeline: string, isLoadingMore: boolean) => ({ skipLoading: !isLoadingMore, }); -const expandTimelineSuccess = (timeline: string, statuses: APIEntity[], next: string | null, partial: boolean, isLoadingRecent: boolean, isLoadingMore: boolean) => ({ +const expandTimelineSuccess = ( + timeline: string, + statuses: APIEntity[], + next: string | null, + partial: boolean, + isLoadingRecent: boolean, + isLoadingMore: boolean, + adIndexes: number[], +) => ({ type: TIMELINE_EXPAND_SUCCESS, timeline, statuses, @@ -242,6 +252,7 @@ const expandTimelineSuccess = (timeline: string, statuses: APIEntity[], next: st partial, isLoadingRecent, skipLoading: !isLoadingMore, + adIndexes, }); const expandTimelineFail = (timeline: string, error: AxiosError, isLoadingMore: boolean) => ({ diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 09fbe3275..7fc89a1b6 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -13,7 +13,7 @@ import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions' import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status'; import { ALGORITHMS } from 'soapbox/features/timeline-insertion'; import PendingStatus from 'soapbox/features/ui/components/pending_status'; -import { useSoapboxConfig } from 'soapbox/hooks'; +import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks'; import useAds from 'soapbox/queries/ads'; import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; @@ -66,6 +66,7 @@ const StatusList: React.FC = ({ const adsAlgorithm = String(soapboxConfig.extensions.getIn(['ads', 'algorithm', 0])); const adsOpts = (soapboxConfig.extensions.getIn(['ads', 'algorithm', 1], ImmutableMap()) as ImmutableMap).toJS(); + const adIndexes = useAppSelector(state => state.timelines.getIn([timelineId, 'adIndexes'], [[]]) as readonly number[][]); const node = useRef(null); const seed = useRef(uuidv4()); @@ -177,12 +178,23 @@ const StatusList: React.FC = ({ const renderStatuses = (): React.ReactNode[] => { if (isLoading || statusIds.size > 0) { + let lastAdIndex = 0; return statusIds.toList().reduce((acc, statusId, index) => { if (showAds && ads) { - const ad = ALGORITHMS[adsAlgorithm]?.(ads, index, { ...adsOpts, seed: seed.current }); + if ((adIndexes[Math.floor(index / 20)] || []).includes(index)) { + const ad = ads[lastAdIndex % ads.length]; - if (ad) { - acc.push(renderAd(ad, index)); + if (ad) { + acc.push(renderAd(ad, index)); + } + + lastAdIndex++; + } else { + const ad = ALGORITHMS[adsAlgorithm]?.(ads, index, { ...adsOpts, seed: seed.current }); + + if (ad) { + acc.push(renderAd(ad, index)); + } } } diff --git a/app/soapbox/features/test_timeline/index.tsx b/app/soapbox/features/test_timeline/index.tsx index 9addcfa92..fbefe04ed 100644 --- a/app/soapbox/features/test_timeline/index.tsx +++ b/app/soapbox/features/test_timeline/index.tsx @@ -36,7 +36,7 @@ const TestTimeline: React.FC = () => { React.useEffect(() => { dispatch(importFetchedStatuses(MOCK_STATUSES)); - dispatch(expandTimelineSuccess(timelineId, MOCK_STATUSES, null, false, false, false)); + dispatch(expandTimelineSuccess(timelineId, MOCK_STATUSES, null, false, false, false, [])); }, []); return ( diff --git a/app/soapbox/features/timeline-insertion/index.ts b/app/soapbox/features/timeline-insertion/index.ts index f4e00ed29..19e9c1e01 100644 --- a/app/soapbox/features/timeline-insertion/index.ts +++ b/app/soapbox/features/timeline-insertion/index.ts @@ -3,7 +3,7 @@ import { linearAlgorithm } from './linear'; import type { PickAlgorithm } from './types'; -const ALGORITHMS: Record = { +const ALGORITHMS: Record = { 'linear': linearAlgorithm, 'abovefold': abovefoldAlgorithm, }; diff --git a/app/soapbox/reducers/timelines.ts b/app/soapbox/reducers/timelines.ts index 97975d658..8c1c9210f 100644 --- a/app/soapbox/reducers/timelines.ts +++ b/app/soapbox/reducers/timelines.ts @@ -53,6 +53,7 @@ const TimelineRecord = ImmutableRecord({ totalQueuedItemsCount: 0, //used for queuedItems overflow for MAX_QUEUED_ITEMS+ loadingFailed: false, isPartial: false, + adIndexes: [] as readonly number[][], }); const initialState = ImmutableMap(); @@ -88,7 +89,7 @@ const setFailed = (state: State, timelineId: string, failed: boolean) => { return state.update(timelineId, TimelineRecord(), timeline => timeline.set('loadingFailed', failed)); }; -const expandNormalizedTimeline = (state: State, timelineId: string, statuses: ImmutableList>, next: string | null, isPartial: boolean, isLoadingRecent: boolean) => { +const expandNormalizedTimeline = (state: State, timelineId: string, statuses: ImmutableList>, next: string | null, isPartial: boolean, isLoadingRecent: boolean, adIndexes: number[]) => { const newIds = getStatusIds(statuses); return state.update(timelineId, TimelineRecord(), timeline => timeline.withMutations(timeline => { @@ -96,6 +97,10 @@ const expandNormalizedTimeline = (state: State, timelineId: string, statuses: Im timeline.set('loadingFailed', false); timeline.set('isPartial', isPartial); + timeline.update('adIndexes', (oldAdIndexes: readonly number[][]) => { + return [...oldAdIndexes, [...adIndexes]]; + }); + if (!next && !isLoadingRecent) timeline.set('hasMore', false); // Pinned timelines can be replaced entirely @@ -321,7 +326,7 @@ export default function timelines(state: State = initialState, action: AnyAction case TIMELINE_EXPAND_FAIL: return handleExpandFail(state, action.timeline); case TIMELINE_EXPAND_SUCCESS: - return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses) as ImmutableList>, action.next, action.partial, action.isLoadingRecent); + return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses) as ImmutableList>, action.next, action.partial, action.isLoadingRecent, action.adIndexes); case TIMELINE_UPDATE: return updateTimeline(state, action.timeline, action.statusId); case TIMELINE_UPDATE_QUEUE: diff --git a/app/soapbox/utils/ads.ts b/app/soapbox/utils/ads.ts index 949315191..428cfa434 100644 --- a/app/soapbox/utils/ads.ts +++ b/app/soapbox/utils/ads.ts @@ -1,3 +1,4 @@ +import type { AxiosResponse } from 'axios'; import type { Ad } from 'soapbox/types/soapbox'; /** Time (ms) window to not display an ad if it's about to expire. */ @@ -13,4 +14,14 @@ const isExpired = (ad: Ad, threshold = AD_EXPIRY_THRESHOLD): boolean => { } }; -export { isExpired }; +/** Get ad indexes from X-Truth-Ad-Indexes header. */ +const adIndexesFromHeader = (response: AxiosResponse): number[] => { + const header: string = response.headers['x-truth-ad-indexes']; + const strIndexes: string[] = header.split(','); + return strIndexes.map(strIndex => Number(strIndex.trim())); +}; + +export { + isExpired, + adIndexesFromHeader, +};