Merge branch 'rm-ads' into 'main'

Remove Truth Social Ads

See merge request soapbox-pub/soapbox!2714
environments/review-main-yi2y9f/deployments/3947
Alex Gleason 2023-09-19 16:48:31 +00:00
commit d0b6bfc96f
19 zmienionych plików z 0 dodań i 537 usunięć

Wyświetl plik

@ -153,7 +153,6 @@
"reselect": "^4.0.0",
"resize-observer-polyfill": "^1.5.1",
"sass": "^1.66.1",
"seedrandom": "^3.0.5",
"semver": "^7.3.8",
"stringz": "^2.0.0",
"substring-trie": "^1.0.2",

Wyświetl plik

@ -1,25 +1,19 @@
import clsx 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';
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';
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
import type { VirtuosoHandle } from 'react-virtuoso';
import type { IScrollableList } from 'soapbox/components/scrollable-list';
import type { Ad as AdEntity } from 'soapbox/types/soapbox';
interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
/** Unique key to preserve the scroll position when navigating back. */
@ -64,14 +58,8 @@ const StatusList: React.FC<IStatusList> = ({
showGroup = true,
...other
}) => {
const { data: ads } = useAds();
const soapboxConfig = useSoapboxConfig();
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;
@ -144,12 +132,6 @@ const StatusList: React.FC<IStatusList> = ({
);
};
const renderAd = (ad: AdEntity, index: number) => {
return (
<Ad key={`ad-${index}`} ad={ad} />
);
};
const renderPendingStatus = (statusId: string) => {
const idempotencyKey = statusId.replace(/^末pending-/, '');
@ -192,14 +174,6 @@ const StatusList: React.FC<IStatusList> = ({
const renderStatuses = (): React.ReactNode[] => {
if (isLoading || statusIds.size > 0) {
return statusIds.toList().reduce((acc, statusId, index) => {
if (showAds && ads) {
const ad = ALGORITHMS[adsAlgorithm]?.(ads, index, { ...adsOpts, seed: seed.current });
if (ad) {
acc.push(renderAd(ad, index));
}
}
if (statusId === null) {
const gap = renderLoadGap(index);
// one does not simply push a null item to Virtuoso: https://github.com/petyosi/react-virtuoso/issues/206#issuecomment-747363793

Wyświetl plik

@ -1,142 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { useState, useEffect, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import { Avatar, Card, HStack, Icon, IconButton, Stack, Text } from 'soapbox/components/ui';
import StatusCard from 'soapbox/features/status/components/card';
import { useInstance } from 'soapbox/hooks';
import { AdKeys } from 'soapbox/queries/ads';
import type { Ad as AdEntity } from 'soapbox/types/soapbox';
interface IAd {
ad: AdEntity
}
/** Displays an ad in sponsored post format. */
const Ad: React.FC<IAd> = ({ ad }) => {
const queryClient = useQueryClient();
const instance = useInstance();
const timer = useRef<NodeJS.Timeout | undefined>(undefined);
const infobox = useRef<HTMLDivElement>(null);
const [showInfo, setShowInfo] = useState(false);
// Fetch the impression URL (if any) upon displaying the ad.
// Don't fetch it more than once.
useQuery(['ads', 'impression', ad.impression], async () => {
if (ad.impression) {
return await axios.get(ad.impression);
}
}, { cacheTime: Infinity, staleTime: Infinity });
/** Invalidate query cache for ads. */
const bustCache = (): void => {
queryClient.invalidateQueries(AdKeys.ads);
};
/** Toggle the info box on click. */
const handleInfoButtonClick: React.MouseEventHandler = () => {
setShowInfo(!showInfo);
};
/** Hide the info box when clicked outside. */
const handleClickOutside = (event: MouseEvent) => {
if (event.target && infobox.current && !infobox.current.contains(event.target as any)) {
setShowInfo(false);
}
};
// Hide the info box when clicked outside.
// https://stackoverflow.com/a/42234988
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [infobox]);
// Wait until the ad expires, then invalidate cache.
useEffect(() => {
if (ad.expires_at) {
const delta = new Date(ad.expires_at).getTime() - (new Date()).getTime();
timer.current = setTimeout(bustCache, delta);
}
return () => {
if (timer.current) {
clearTimeout(timer.current);
}
};
}, [ad.expires_at]);
return (
<div className='relative'>
<Card className='py-4' variant='rounded'>
<Stack space={4}>
<HStack alignItems='center' space={3}>
<Avatar src={instance.thumbnail} size={42} />
<Stack grow>
<HStack space={1}>
<Text size='sm' weight='semibold' truncate>
{instance.title}
</Text>
<Icon
className='h-4 w-4 stroke-accent-500'
src={require('@tabler/icons/timeline.svg')}
/>
</HStack>
<Stack>
<HStack alignItems='center' space={1}>
<Text theme='muted' size='sm' truncate>
<FormattedMessage id='sponsored.subtitle' defaultMessage='Sponsored post' />
</Text>
</HStack>
</Stack>
</Stack>
<Stack justifyContent='center'>
<IconButton
iconClassName='h-6 w-6 stroke-gray-600'
src={require('@tabler/icons/info-circle.svg')}
onClick={handleInfoButtonClick}
/>
</Stack>
</HStack>
<StatusCard card={ad.card} onOpenMedia={() => { }} horizontal />
</Stack>
</Card>
{showInfo && (
<div ref={infobox} className='absolute right-5 top-5 max-w-[234px]'>
<Card variant='rounded'>
<Stack space={2}>
<Text size='sm' weight='bold'>
<FormattedMessage id='sponsored.info.title' defaultMessage='Why am I seeing this ad?' />
</Text>
<Text size='sm' theme='muted'>
{ad.reason ? (
ad.reason
) : (
<FormattedMessage
id='sponsored.info.message'
defaultMessage='{siteTitle} displays ads to help fund our service.'
values={{ siteTitle: instance.title }}
/>
)}
</Text>
</Stack>
</Card>
</div>
)}
</div>
);
};
export default Ad;

Wyświetl plik

@ -1,42 +0,0 @@
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import type { RootState } from 'soapbox/store';
import type { Card } from 'soapbox/types/entities';
/** Map of available provider modules. */
const PROVIDERS: Record<string, () => Promise<AdProvider>> = {
soapbox: async() => (await import('./soapbox-config')).default,
truth: async() => (await import('./truth')).default,
};
/** Ad server implementation. */
interface AdProvider {
getAds(getState: () => RootState): Promise<Ad[]>
}
/** Entity representing an advertisement. */
interface Ad {
/** Ad data in Card (OEmbed-ish) format. */
card: Card
/** Impression URL to fetch when displaying the ad. */
impression?: string
/** Time when the ad expires and should no longer be displayed. */
expires_at?: string
/** Reason the ad is displayed. */
reason?: string
}
/** Gets the current provider based on config. */
const getProvider = async(getState: () => RootState): Promise<AdProvider | undefined> => {
const state = getState();
const soapboxConfig = getSoapboxConfig(state);
const isEnabled = soapboxConfig.extensions.getIn(['ads', 'enabled'], false) === true;
const providerName = soapboxConfig.extensions.getIn(['ads', 'provider'], 'soapbox') as string;
if (isEnabled && PROVIDERS[providerName]) {
return PROVIDERS[providerName]();
}
};
export { getProvider };
export type { Ad, AdProvider };

Wyświetl plik

@ -1,14 +0,0 @@
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import type { AdProvider } from '.';
/** Provides ads from Soapbox Config. */
const SoapboxConfigAdProvider: AdProvider = {
getAds: async(getState) => {
const state = getState();
const soapboxConfig = getSoapboxConfig(state);
return soapboxConfig.ads.toArray();
},
};
export default SoapboxConfigAdProvider;

Wyświetl plik

@ -1,40 +0,0 @@
import axios from 'axios';
import { z } from 'zod';
import { getSettings } from 'soapbox/actions/settings';
import { cardSchema } from 'soapbox/schemas/card';
import { filteredArray } from 'soapbox/schemas/utils';
import type { AdProvider } from '.';
/** TruthSocial ad API entity. */
const truthAdSchema = z.object({
impression: z.string(),
card: cardSchema,
expires_at: z.string(),
reason: z.string().catch(''),
});
/** Provides ads from the TruthSocial API. */
const TruthAdProvider: AdProvider = {
getAds: async(getState) => {
const state = getState();
const settings = getSettings(state);
try {
const { data } = await axios.get('/api/v2/truth/ads?device=desktop', {
headers: {
'Accept-Language': z.string().catch('*').parse(settings.get('locale')),
},
});
return filteredArray(truthAdSchema).parse(data);
} catch (e) {
// do nothing
}
return [];
},
};
export default TruthAdProvider;

Wyświetl plik

@ -1,18 +0,0 @@
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);
});

Wyświetl plik

@ -1,19 +0,0 @@
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');
});

Wyświetl plik

@ -1,52 +0,0 @@
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,
};

Wyświetl plik

@ -1,11 +0,0 @@
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 };

Wyświetl plik

@ -1,28 +0,0 @@
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,
};

Wyświetl plik

@ -1,15 +0,0 @@
/**
* 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,
};

Wyświetl plik

@ -6,8 +6,6 @@ import {
} from 'immutable';
import trimStart from 'lodash/trimStart';
import { adSchema } from 'soapbox/schemas';
import { filteredArray } from 'soapbox/schemas/utils';
import { normalizeUsername } from 'soapbox/utils/input';
import { toTailwind } from 'soapbox/utils/tailwind';
import { generateAccent } from 'soapbox/utils/theme';
@ -124,15 +122,6 @@ export const SoapboxConfigRecord = ImmutableRecord({
type SoapboxConfigMap = ImmutableMap<string, any>;
const normalizeAds = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => {
if (soapboxConfig.has('ads')) {
const ads = filteredArray(adSchema).parse(ImmutableList(soapboxConfig.get('ads')).toJS());
return soapboxConfig.set('ads', ads);
} else {
return soapboxConfig;
}
};
const normalizeCryptoAddress = (address: unknown): CryptoAddress => {
return CryptoAddressRecord(ImmutableMap(fromJS(address))).update('ticker', ticker => {
return trimStart(ticker, '$').toLowerCase();
@ -188,19 +177,6 @@ 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;
}
};
/** Single user mode is now managed by `redirectRootNoLogin`. */
const upgradeSingleUserMode = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => {
const singleUserMode = soapboxConfig.get('singleUserMode') as boolean | undefined;
@ -250,8 +226,6 @@ export const normalizeSoapboxConfig = (soapboxConfig: Record<string, any>) => {
normalizeFooterLinks(soapboxConfig);
maybeAddMissingColors(soapboxConfig);
normalizeCryptoAddresses(soapboxConfig);
normalizeAds(soapboxConfig);
normalizeAdsAlgorithm(soapboxConfig);
upgradeSingleUserMode(soapboxConfig);
normalizeRedirectRootNoLogin(soapboxConfig);
}),

Wyświetl plik

@ -1,42 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Ad, getProvider } from 'soapbox/features/ads/providers';
import { useAppDispatch } from 'soapbox/hooks';
import { adSchema } from 'soapbox/schemas';
import { filteredArray } from 'soapbox/schemas/utils';
import { isExpired } from 'soapbox/utils/ads';
const AdKeys = {
ads: ['ads'] as const,
};
function useAds() {
const dispatch = useAppDispatch();
const getAds = async () => {
return dispatch(async (_, getState) => {
const provider = await getProvider(getState);
if (provider) {
return provider.getAds(getState);
} else {
return [];
}
});
};
const result = useQuery<Ad[]>(AdKeys.ads, getAds, {
placeholderData: [],
});
// Filter out expired ads.
const data = filteredArray(adSchema)
.parse(result.data)
.filter(ad => !isExpired(ad));
return {
...result,
data,
};
}
export { useAds as default, AdKeys };

Wyświetl plik

@ -16,6 +16,3 @@ export { relationshipSchema, type Relationship } from './relationship';
export { statusSchema, type Status } from './status';
export { tagSchema, type Tag } from './tag';
export { tombstoneSchema, type Tombstone } from './tombstone';
// Soapbox
export { adSchema, type Ad } from './soapbox/ad';

Wyświetl plik

@ -1,14 +0,0 @@
import { z } from 'zod';
import { cardSchema } from '../card';
const adSchema = z.object({
card: cardSchema,
impression: z.string().optional().catch(undefined),
expires_at: z.string().datetime().optional().catch(undefined),
reason: z.string().optional().catch(undefined),
});
type Ad = z.infer<typeof adSchema>;
export { adSchema, type Ad };

Wyświetl plik

@ -1,23 +0,0 @@
import { buildAd } from 'soapbox/jest/factory';
import { isExpired } from '../ads';
/** 3 minutes in milliseconds. */
const threeMins = 3 * 60 * 1000;
/** 5 minutes in milliseconds. */
const fiveMins = 5 * 60 * 1000;
test('isExpired()', () => {
const now = new Date();
const iso = now.toISOString();
const epoch = now.getTime();
// Sanity tests.
expect(isExpired(buildAd({ expires_at: iso }))).toBe(true);
expect(isExpired(buildAd({ expires_at: new Date(epoch + 999999).toISOString() }))).toBe(false);
// Testing the 5-minute mark.
expect(isExpired(buildAd({ expires_at: new Date(epoch + threeMins).toISOString() }), fiveMins)).toBe(true);
expect(isExpired(buildAd({ expires_at: new Date(epoch + fiveMins + 1000).toISOString() }), fiveMins)).toBe(false);
});

Wyświetl plik

@ -1,16 +0,0 @@
import type { Ad } from 'soapbox/schemas';
/** Time (ms) window to not display an ad if it's about to expire. */
const AD_EXPIRY_THRESHOLD = 5 * 60 * 1000;
/** Whether the ad is expired or about to expire. */
const isExpired = (ad: Pick<Ad, 'expires_at'>, threshold = AD_EXPIRY_THRESHOLD): boolean => {
if (ad.expires_at) {
const now = new Date();
return now.getTime() > (new Date(ad.expires_at).getTime() - threshold);
} else {
return false;
}
};
export { isExpired };

Wyświetl plik

@ -7902,11 +7902,6 @@ 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==
semver-compare@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"