diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 295538da2..3bee7a03a 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -1,7 +1,9 @@ import classNames from 'clsx'; +import { Map as ImmutableMap } from 'immutable'; import debounce from 'lodash/debounce'; import React, { useRef, useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; +import { v4 as uuidv4 } from 'uuid'; import LoadGap from 'soapbox/components/load_gap'; import ScrollableList from 'soapbox/components/scrollable_list'; @@ -9,6 +11,7 @@ import StatusContainer from 'soapbox/containers/status_container'; import Ad from 'soapbox/features/ads/components/ad'; 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 useAds from 'soapbox/queries/ads'; @@ -60,8 +63,12 @@ const StatusList: React.FC = ({ }) => { const { data: ads } = useAds(); const soapboxConfig = useSoapboxConfig(); - const adsInterval = Number(soapboxConfig.extensions.getIn(['ads', 'interval'], 40)) || 0; + + const adsAlgorithm = String(soapboxConfig.extensions.getIn(['ads', 'algorithm', 0])); + const adsOpts = (soapboxConfig.extensions.getIn(['ads', 'algorithm', 1], ImmutableMap()) as ImmutableMap).toJS(); + const node = useRef(null); + const seed = useRef(uuidv4()); const getFeaturedStatusCount = () => { return featuredStatusIds?.size || 0; @@ -132,9 +139,10 @@ const StatusList: React.FC = ({ ); }; - const renderAd = (ad: AdEntity) => { + const renderAd = (ad: AdEntity, index: number) => { return ( = ({ const renderStatuses = (): React.ReactNode[] => { if (isLoading || statusIds.size > 0) { return statusIds.toList().reduce((acc, statusId, index) => { - const adIndex = ads ? Math.floor((index + 1) / adsInterval) % ads.length : 0; - const ad = ads ? ads[adIndex] : undefined; - const showAd = (index + 1) % adsInterval === 0; + if (showAds && ads) { + const ad = ALGORITHMS[adsAlgorithm]?.(ads, index, { ...adsOpts, seed: seed.current }); + + if (ad) { + acc.push(renderAd(ad, index)); + } + } if (statusId === null) { acc.push(renderLoadGap(index)); @@ -189,10 +201,6 @@ const StatusList: React.FC = ({ acc.push(renderStatus(statusId)); } - if (showAds && ad && showAd) { - acc.push(renderAd(ad)); - } - return acc; }, [] as React.ReactNode[]); } else { diff --git a/app/soapbox/features/timeline-insertion/abovefold.ts b/app/soapbox/features/timeline-insertion/abovefold.ts index 71ab51f81..5ab3b4306 100644 --- a/app/soapbox/features/timeline-insertion/abovefold.ts +++ b/app/soapbox/features/timeline-insertion/abovefold.ts @@ -8,7 +8,7 @@ type Opts = { /** * Start/end index of the slot by which one item will be randomly picked per page. * - * Eg. `[3, 7]` will cause one item to be picked between the third and seventh indexes per page. + * Eg. `[2, 6]` will cause one item to be picked among the third through seventh indexes. * * `end` must be larger than `start`. */ @@ -21,21 +21,34 @@ type Opts = { * Algorithm to display items per-page. * One item is randomly inserted into each page within the index range. */ -const abovefoldAlgorithm: PickAlgorithm = (items, iteration, opts: Opts) => { +const abovefoldAlgorithm: PickAlgorithm = (items, iteration, rawOpts) => { + const opts = normalizeOpts(rawOpts); /** Current page of the index. */ - const page = Math.floor(((iteration + 1) / opts.pageSize) - 1); + const page = Math.floor(iteration / opts.pageSize); /** Current index within the page. */ - const pageIndex = ((iteration + 1) % opts.pageSize) - 1; + const pageIndex = (iteration % opts.pageSize); /** RNG for the page. */ const rng = seedrandom(`${opts.seed}-page-${page}`); /** Index to insert the item. */ - const insertIndex = Math.floor(rng() * opts.range[1] - opts.range[0]) + opts.range[0]; + const insertIndex = Math.floor(rng() * (opts.range[1] - opts.range[0])) + opts.range[0]; + + console.log({ page, iteration, pageIndex, insertIndex }); if (pageIndex === insertIndex) { return items[page % items.length]; } }; +const normalizeOpts = (opts: unknown): Opts => { + const { seed, range, pageSize } = (opts && typeof opts === 'object' ? opts : {}) as Record; + + return { + seed: typeof seed === 'string' ? seed : '', + range: Array.isArray(range) ? [Number(range[0]), Number(range[1])] : [2, 6], + pageSize: typeof pageSize === 'number' ? pageSize : 20, + }; +}; + export { abovefoldAlgorithm, }; \ No newline at end of file diff --git a/app/soapbox/features/timeline-insertion/index.ts b/app/soapbox/features/timeline-insertion/index.ts index e69de29bb..f4e00ed29 100644 --- a/app/soapbox/features/timeline-insertion/index.ts +++ b/app/soapbox/features/timeline-insertion/index.ts @@ -0,0 +1,11 @@ +import { abovefoldAlgorithm } from './abovefold'; +import { linearAlgorithm } from './linear'; + +import type { PickAlgorithm } from './types'; + +const ALGORITHMS: Record = { + 'linear': linearAlgorithm, + 'abovefold': abovefoldAlgorithm, +}; + +export { ALGORITHMS }; \ No newline at end of file diff --git a/app/soapbox/features/timeline-insertion/linear.ts b/app/soapbox/features/timeline-insertion/linear.ts index 3037c3837..a3cbce685 100644 --- a/app/soapbox/features/timeline-insertion/linear.ts +++ b/app/soapbox/features/timeline-insertion/linear.ts @@ -6,7 +6,8 @@ type Opts = { }; /** Picks the next item every iteration. */ -const linearAlgorithm: PickAlgorithm = (items, iteration, opts: Opts) => { +const linearAlgorithm: PickAlgorithm = (items, iteration, rawOpts) => { + const opts = normalizeOpts(rawOpts); const itemIndex = items ? Math.floor((iteration + 1) / opts.interval) % items.length : 0; const item = items ? items[itemIndex] : undefined; const showItem = (iteration + 1) % opts.interval === 0; @@ -14,6 +15,14 @@ const linearAlgorithm: PickAlgorithm = (items, iteration, opts: Opts) => { return showItem ? item : undefined; }; +const normalizeOpts = (opts: unknown): Opts => { + const { interval } = (opts && typeof opts === 'object' ? opts : {}) as Record; + + return { + interval: typeof interval === 'number' ? interval : 20, + }; +}; + export { linearAlgorithm, }; \ No newline at end of file diff --git a/app/soapbox/features/timeline-insertion/types.ts b/app/soapbox/features/timeline-insertion/types.ts index c1cc1ed1d..69b6280c4 100644 --- a/app/soapbox/features/timeline-insertion/types.ts +++ b/app/soapbox/features/timeline-insertion/types.ts @@ -7,7 +7,7 @@ type PickAlgorithm = ( /** Current iteration by which an item may be chosen. */ iteration: number, /** Implementation-specific opts. */ - opts: any + opts: Record ) => D | undefined; export { diff --git a/app/soapbox/normalizers/soapbox/soapbox_config.ts b/app/soapbox/normalizers/soapbox/soapbox_config.ts index a471401c5..0e6b5c280 100644 --- a/app/soapbox/normalizers/soapbox/soapbox_config.ts +++ b/app/soapbox/normalizers/soapbox/soapbox_config.ts @@ -175,6 +175,19 @@ const normalizeFooterLinks = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap return soapboxConfig.setIn(path, items); }; +/** Migrate legacy ads config. */ +const normalizeAdsAlgorithm = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => { + const interval = soapboxConfig.getIn(['extensions', 'ads', 'interval']); + const algorithm = soapboxConfig.getIn(['extensions', 'ads', 'algorithm']); + + if (typeof interval === 'number' && !algorithm) { + const result = fromJS(['linear', { interval }]); + return soapboxConfig.setIn(['extensions', 'ads', 'algorithm'], result); + } else { + return soapboxConfig; + } +}; + export const normalizeSoapboxConfig = (soapboxConfig: Record) => { return SoapboxConfigRecord( ImmutableMap(fromJS(soapboxConfig)).withMutations(soapboxConfig => { @@ -186,6 +199,7 @@ export const normalizeSoapboxConfig = (soapboxConfig: Record) => { maybeAddMissingColors(soapboxConfig); normalizeCryptoAddresses(soapboxConfig); normalizeAds(soapboxConfig); + normalizeAdsAlgorithm(soapboxConfig); }), ); };