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/__tests__/abovefold.test.ts b/app/soapbox/features/timeline-insertion/__tests__/abovefold.test.ts new file mode 100644 index 000000000..81de8c1a4 --- /dev/null +++ b/app/soapbox/features/timeline-insertion/__tests__/abovefold.test.ts @@ -0,0 +1,18 @@ +import { abovefoldAlgorithm } from '../abovefold'; + +const DATA = Object.freeze(['a', 'b', 'c', 'd']); + +test('abovefoldAlgorithm', () => { + const result = Array(50).fill('').map((_, i) => { + return abovefoldAlgorithm(DATA, i, { seed: '!', range: [2, 6], pageSize: 20 }); + }); + + // console.log(result); + expect(result[0]).toBe(undefined); + expect(result[4]).toBe('a'); + expect(result[5]).toBe(undefined); + expect(result[24]).toBe('b'); + expect(result[30]).toBe(undefined); + expect(result[42]).toBe('c'); + expect(result[43]).toBe(undefined); +}); \ No newline at end of file diff --git a/app/soapbox/features/timeline-insertion/__tests__/linear.test.ts b/app/soapbox/features/timeline-insertion/__tests__/linear.test.ts new file mode 100644 index 000000000..09d484f12 --- /dev/null +++ b/app/soapbox/features/timeline-insertion/__tests__/linear.test.ts @@ -0,0 +1,19 @@ +import { linearAlgorithm } from '../linear'; + +const DATA = Object.freeze(['a', 'b', 'c', 'd']); + +test('linearAlgorithm', () => { + const result = Array(50).fill('').map((_, i) => { + return linearAlgorithm(DATA, i, { interval: 5 }); + }); + + // console.log(result); + expect(result[0]).toBe(undefined); + expect(result[4]).toBe('a'); + expect(result[8]).toBe(undefined); + expect(result[9]).toBe('b'); + expect(result[10]).toBe(undefined); + expect(result[14]).toBe('c'); + expect(result[15]).toBe(undefined); + expect(result[19]).toBe('d'); +}); \ No newline at end of file diff --git a/app/soapbox/features/timeline-insertion/abovefold.ts b/app/soapbox/features/timeline-insertion/abovefold.ts new file mode 100644 index 000000000..9ca2f5e28 --- /dev/null +++ b/app/soapbox/features/timeline-insertion/abovefold.ts @@ -0,0 +1,52 @@ +import seedrandom from 'seedrandom'; + +import type { PickAlgorithm } from './types'; + +type Opts = { + /** Randomization seed. */ + seed: string, + /** + * Start/end index of the slot by which one item will be randomly picked per page. + * + * Eg. `[2, 6]` will cause one item to be picked among the third through seventh indexes. + * + * `end` must be larger than `start`. + */ + range: [start: number, end: number], + /** Number of items in the page. */ + pageSize: number, +}; + +/** + * Algorithm to display items per-page. + * One item is randomly inserted into each page within the index range. + */ +const abovefoldAlgorithm: PickAlgorithm = (items, iteration, rawOpts) => { + const opts = normalizeOpts(rawOpts); + /** Current page of the index. */ + const page = Math.floor(iteration / opts.pageSize); + /** Current index within the page. */ + 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]; + + 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 new file mode 100644 index 000000000..f4e00ed29 --- /dev/null +++ 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 new file mode 100644 index 000000000..a542e1fce --- /dev/null +++ b/app/soapbox/features/timeline-insertion/linear.ts @@ -0,0 +1,28 @@ +import type { PickAlgorithm } from './types'; + +type Opts = { + /** Number of iterations until the next item is picked. */ + interval: number, +}; + +/** Picks the next item every iteration. */ +const linearAlgorithm: PickAlgorithm = (items, iteration, rawOpts) => { + const opts = normalizeOpts(rawOpts); + const itemIndex = items ? Math.floor(iteration / opts.interval) % items.length : 0; + const item = items ? items[itemIndex] : undefined; + const showItem = (iteration + 1) % opts.interval === 0; + + 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 new file mode 100644 index 000000000..b874754d0 --- /dev/null +++ b/app/soapbox/features/timeline-insertion/types.ts @@ -0,0 +1,15 @@ +/** + * Returns an item to insert at the index, or `undefined` if an item shouldn't be inserted. + */ +type PickAlgorithm = ( + /** Elligible candidates to pick. */ + items: readonly D[], + /** Current iteration by which an item may be chosen. */ + iteration: number, + /** Implementation-specific opts. */ + opts: Record +) => D | undefined; + +export { + PickAlgorithm, +}; \ No newline at end of file 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); }), ); }; diff --git a/package.json b/package.json index eef743978..1ba60028b 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "@types/react-swipeable-views": "^0.13.1", "@types/react-toggle": "^4.0.3", "@types/redux-mock-store": "^1.0.3", + "@types/seedrandom": "^3.0.2", "@types/semver": "^7.3.9", "@types/uuid": "^8.3.4", "array-includes": "^3.1.5", @@ -184,6 +185,7 @@ "resize-observer-polyfill": "^1.5.1", "sass": "^1.20.3", "sass-loader": "^13.0.0", + "seedrandom": "^3.0.5", "semver": "^7.3.2", "stringz": "^2.0.0", "substring-trie": "^1.0.2", diff --git a/yarn.lock b/yarn.lock index 1e2044863..a3cbcff2e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2841,6 +2841,11 @@ dependencies: schema-utils "*" +"@types/seedrandom@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-3.0.2.tgz#7f30db28221067a90b02e73ffd46b6685b18df1a" + integrity sha512-YPLqEOo0/X8JU3rdiq+RgUKtQhQtrppE766y7vMTu8dGML7TVtZNiiiaC/hhU9Zqw9UYopXxhuWWENclMVBwKQ== + "@types/semver@^7.3.9": version "7.3.9" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.9.tgz#152c6c20a7688c30b967ec1841d31ace569863fc" @@ -10461,6 +10466,11 @@ scroll-behavior@^0.9.1: dom-helpers "^3.4.0" invariant "^2.2.4" +seedrandom@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-3.0.5.tgz#54edc85c95222525b0c7a6f6b3543d8e0b3aa0a7" + integrity sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg== + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"