kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge branch 'rm-ads' into 'main'
Remove Truth Social Ads See merge request soapbox-pub/soapbox!2714environments/review-main-yi2y9f/deployments/3947
commit
d0b6bfc96f
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
|
@ -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 };
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
||||
});
|
|
@ -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');
|
||||
});
|
|
@ -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,
|
||||
};
|
|
@ -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 };
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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);
|
||||
}),
|
||||
|
|
|
@ -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 };
|
|
@ -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';
|
|
@ -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 };
|
|
@ -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);
|
||||
});
|
|
@ -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 };
|
|
@ -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"
|
||||
|
|
Ładowanie…
Reference in New Issue