Merge branch 'feed-insertion-algorithm' into 'develop'
Feed insertion algorithms See merge request soapbox-pub/soapbox!1782
This commit is contained in:
commit
749a11dd77
10 changed files with 186 additions and 9 deletions
|
@ -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<IStatusList> = ({
|
|||
}) => {
|
||||
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<string, any>).toJS();
|
||||
|
||||
const node = useRef<VirtuosoHandle>(null);
|
||||
const seed = useRef<string>(uuidv4());
|
||||
|
||||
const getFeaturedStatusCount = () => {
|
||||
return featuredStatusIds?.size || 0;
|
||||
|
@ -132,9 +139,10 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const renderAd = (ad: AdEntity) => {
|
||||
const renderAd = (ad: AdEntity, index: number) => {
|
||||
return (
|
||||
<Ad
|
||||
key={`ad-${index}`}
|
||||
card={ad.card}
|
||||
impression={ad.impression}
|
||||
expires={ad.expires}
|
||||
|
@ -175,9 +183,13 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
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<IStatusList> = ({
|
|||
acc.push(renderStatus(statusId));
|
||||
}
|
||||
|
||||
if (showAds && ad && showAd) {
|
||||
acc.push(renderAd(ad));
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as React.ReactNode[]);
|
||||
} else {
|
||||
|
|
|
@ -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);
|
||||
});
|
|
@ -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');
|
||||
});
|
52
app/soapbox/features/timeline-insertion/abovefold.ts
Normal file
52
app/soapbox/features/timeline-insertion/abovefold.ts
Normal file
|
@ -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<any, unknown>;
|
||||
|
||||
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,
|
||||
};
|
11
app/soapbox/features/timeline-insertion/index.ts
Normal file
11
app/soapbox/features/timeline-insertion/index.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { abovefoldAlgorithm } from './abovefold';
|
||||
import { linearAlgorithm } from './linear';
|
||||
|
||||
import type { PickAlgorithm } from './types';
|
||||
|
||||
const ALGORITHMS: Record<any, PickAlgorithm | undefined> = {
|
||||
'linear': linearAlgorithm,
|
||||
'abovefold': abovefoldAlgorithm,
|
||||
};
|
||||
|
||||
export { ALGORITHMS };
|
28
app/soapbox/features/timeline-insertion/linear.ts
Normal file
28
app/soapbox/features/timeline-insertion/linear.ts
Normal file
|
@ -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<any, unknown>;
|
||||
|
||||
return {
|
||||
interval: typeof interval === 'number' ? interval : 20,
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
linearAlgorithm,
|
||||
};
|
15
app/soapbox/features/timeline-insertion/types.ts
Normal file
15
app/soapbox/features/timeline-insertion/types.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Returns an item to insert at the index, or `undefined` if an item shouldn't be inserted.
|
||||
*/
|
||||
type PickAlgorithm = <D = any>(
|
||||
/** Elligible candidates to pick. */
|
||||
items: readonly D[],
|
||||
/** Current iteration by which an item may be chosen. */
|
||||
iteration: number,
|
||||
/** Implementation-specific opts. */
|
||||
opts: Record<string, unknown>
|
||||
) => D | undefined;
|
||||
|
||||
export {
|
||||
PickAlgorithm,
|
||||
};
|
|
@ -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<string, any>) => {
|
||||
return SoapboxConfigRecord(
|
||||
ImmutableMap(fromJS(soapboxConfig)).withMutations(soapboxConfig => {
|
||||
|
@ -186,6 +199,7 @@ export const normalizeSoapboxConfig = (soapboxConfig: Record<string, any>) => {
|
|||
maybeAddMissingColors(soapboxConfig);
|
||||
normalizeCryptoAddresses(soapboxConfig);
|
||||
normalizeAds(soapboxConfig);
|
||||
normalizeAdsAlgorithm(soapboxConfig);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
10
yarn.lock
10
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"
|
||||
|
|
Loading…
Reference in a new issue