Support TruthSocial v2 ads
This commit is contained in:
parent
a70950f013
commit
4296772093
|
@ -19,7 +19,7 @@ import useAds from 'soapbox/queries/ads';
|
||||||
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||||
import type { IScrollableList } from 'soapbox/components/scrollable_list';
|
import type { IScrollableList } from 'soapbox/components/scrollable_list';
|
||||||
import type { Ad as AdEntity } from 'soapbox/features/ads/providers';
|
import type { Ad as AdEntity } from 'soapbox/types/soapbox';
|
||||||
|
|
||||||
interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
||||||
/** Unique key to preserve the scroll position when navigating back. */
|
/** Unique key to preserve the scroll position when navigating back. */
|
||||||
|
@ -141,12 +141,7 @@ const StatusList: React.FC<IStatusList> = ({
|
||||||
|
|
||||||
const renderAd = (ad: AdEntity, index: number) => {
|
const renderAd = (ad: AdEntity, index: number) => {
|
||||||
return (
|
return (
|
||||||
<Ad
|
<Ad key={`ad-${index}`} ad={ad} />
|
||||||
key={`ad-${index}`}
|
|
||||||
card={ad.card}
|
|
||||||
impression={ad.impression}
|
|
||||||
expires={ad.expires}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -7,19 +7,14 @@ import IconButton from 'soapbox/components/ui/icon-button/icon-button';
|
||||||
import StatusCard from 'soapbox/features/status/components/card';
|
import StatusCard from 'soapbox/features/status/components/card';
|
||||||
import { useAppSelector } from 'soapbox/hooks';
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
import type { Card as CardEntity } from 'soapbox/types/entities';
|
import type { Ad as AdEntity } from 'soapbox/types/soapbox';
|
||||||
|
|
||||||
interface IAd {
|
interface IAd {
|
||||||
/** Embedded ad data in Card format (almost like OEmbed). */
|
ad: AdEntity,
|
||||||
card: CardEntity,
|
|
||||||
/** Impression URL to fetch upon display. */
|
|
||||||
impression?: string,
|
|
||||||
/** Time when the ad expires and should no longer be displayed. */
|
|
||||||
expires?: Date,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Displays an ad in sponsored post format. */
|
/** Displays an ad in sponsored post format. */
|
||||||
const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
|
const Ad: React.FC<IAd> = ({ ad }) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const instance = useAppSelector(state => state.instance);
|
const instance = useAppSelector(state => state.instance);
|
||||||
|
|
||||||
|
@ -29,9 +24,9 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
|
||||||
|
|
||||||
// Fetch the impression URL (if any) upon displaying the ad.
|
// Fetch the impression URL (if any) upon displaying the ad.
|
||||||
// Don't fetch it more than once.
|
// Don't fetch it more than once.
|
||||||
useQuery(['ads', 'impression', impression], () => {
|
useQuery(['ads', 'impression', ad.impression], () => {
|
||||||
if (impression) {
|
if (ad.impression) {
|
||||||
return fetch(impression);
|
return fetch(ad.impression);
|
||||||
}
|
}
|
||||||
}, { cacheTime: Infinity, staleTime: Infinity });
|
}, { cacheTime: Infinity, staleTime: Infinity });
|
||||||
|
|
||||||
|
@ -63,8 +58,8 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
|
||||||
|
|
||||||
// Wait until the ad expires, then invalidate cache.
|
// Wait until the ad expires, then invalidate cache.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (expires) {
|
if (ad.expires_at) {
|
||||||
const delta = expires.getTime() - (new Date()).getTime();
|
const delta = new Date(ad.expires_at).getTime() - (new Date()).getTime();
|
||||||
timer.current = setTimeout(bustCache, delta);
|
timer.current = setTimeout(bustCache, delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,7 +68,7 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
|
||||||
clearTimeout(timer.current);
|
clearTimeout(timer.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [expires]);
|
}, [ad.expires_at]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='relative'>
|
<div className='relative'>
|
||||||
|
@ -112,7 +107,7 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
|
||||||
</Stack>
|
</Stack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<StatusCard card={card} onOpenMedia={() => {}} horizontal />
|
<StatusCard card={ad.card} onOpenMedia={() => {}} horizontal />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
@ -125,11 +120,15 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text size='sm' theme='muted'>
|
<Text size='sm' theme='muted'>
|
||||||
<FormattedMessage
|
{ad.reason ? (
|
||||||
id='sponsored.info.message'
|
ad.reason
|
||||||
defaultMessage='{siteTitle} displays ads to help fund our service.'
|
) : (
|
||||||
values={{ siteTitle: instance.title }}
|
<FormattedMessage
|
||||||
/>
|
id='sponsored.info.message'
|
||||||
|
defaultMessage='{siteTitle} displays ads to help fund our service.'
|
||||||
|
values={{ siteTitle: instance.title }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
@ -7,6 +7,7 @@ import type { Card } from 'soapbox/types/entities';
|
||||||
const PROVIDERS: Record<string, () => Promise<AdProvider>> = {
|
const PROVIDERS: Record<string, () => Promise<AdProvider>> = {
|
||||||
soapbox: async() => (await import(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default,
|
soapbox: async() => (await import(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default,
|
||||||
rumble: async() => (await import(/* webpackChunkName: "features/ads/rumble" */'./rumble')).default,
|
rumble: async() => (await import(/* webpackChunkName: "features/ads/rumble" */'./rumble')).default,
|
||||||
|
truth: async() => (await import(/* webpackChunkName: "features/ads/truth" */'./truth')).default,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Ad server implementation. */
|
/** Ad server implementation. */
|
||||||
|
@ -21,7 +22,9 @@ interface Ad {
|
||||||
/** Impression URL to fetch when displaying the ad. */
|
/** Impression URL to fetch when displaying the ad. */
|
||||||
impression?: string,
|
impression?: string,
|
||||||
/** Time when the ad expires and should no longer be displayed. */
|
/** Time when the ad expires and should no longer be displayed. */
|
||||||
expires?: Date,
|
expires_at?: string,
|
||||||
|
/** Reason the ad is displayed. */
|
||||||
|
reason?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gets the current provider based on config. */
|
/** Gets the current provider based on config. */
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { getSettings } from 'soapbox/actions/settings';
|
import { getSettings } from 'soapbox/actions/settings';
|
||||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||||
import { normalizeCard } from 'soapbox/normalizers';
|
import { normalizeAd, normalizeCard } from 'soapbox/normalizers';
|
||||||
|
|
||||||
import type { AdProvider } from '.';
|
import type { AdProvider } from '.';
|
||||||
|
|
||||||
|
@ -36,14 +36,14 @@ const RumbleAdProvider: AdProvider = {
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json() as RumbleApiResponse;
|
const data = await response.json() as RumbleApiResponse;
|
||||||
return data.ads.map(item => ({
|
return data.ads.map(item => normalizeAd({
|
||||||
impression: item.impression,
|
impression: item.impression,
|
||||||
card: normalizeCard({
|
card: normalizeCard({
|
||||||
type: item.type === 1 ? 'link' : 'rich',
|
type: item.type === 1 ? 'link' : 'rich',
|
||||||
image: item.asset,
|
image: item.asset,
|
||||||
url: item.click,
|
url: item.click,
|
||||||
}),
|
}),
|
||||||
expires: new Date(item.expires * 1000),
|
expires_at: new Date(item.expires * 1000),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { getSettings } from 'soapbox/actions/settings';
|
||||||
|
import { normalizeCard } from 'soapbox/normalizers';
|
||||||
|
|
||||||
|
import type { AdProvider } from '.';
|
||||||
|
import type { Card } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
/** TruthSocial ad API entity. */
|
||||||
|
interface TruthAd {
|
||||||
|
impression: string,
|
||||||
|
card: Card,
|
||||||
|
expires_at: string,
|
||||||
|
reason: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provides ads from the TruthSocial API. */
|
||||||
|
const TruthAdProvider: AdProvider = {
|
||||||
|
getAds: async(getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const settings = getSettings(state);
|
||||||
|
|
||||||
|
const response = await fetch('/api/v2/truth/ads?device=desktop', {
|
||||||
|
headers: {
|
||||||
|
'Accept-Language': settings.get('locale', '*') as string,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as TruthAd[];
|
||||||
|
return data.map(item => ({
|
||||||
|
...item,
|
||||||
|
card: normalizeCard(item.card),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TruthAdProvider;
|
|
@ -6,15 +6,23 @@ import {
|
||||||
|
|
||||||
import { CardRecord, normalizeCard } from '../card';
|
import { CardRecord, normalizeCard } from '../card';
|
||||||
|
|
||||||
export const AdRecord = ImmutableRecord({
|
import type { Ad } from 'soapbox/features/ads/providers';
|
||||||
|
|
||||||
|
export const AdRecord = ImmutableRecord<Ad>({
|
||||||
card: CardRecord(),
|
card: CardRecord(),
|
||||||
impression: undefined as string | undefined,
|
impression: undefined as string | undefined,
|
||||||
expires: undefined as Date | undefined,
|
expires_at: undefined as string | undefined,
|
||||||
|
reason: undefined as string | undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Normalizes an ad from Soapbox Config. */
|
/** Normalizes an ad from Soapbox Config. */
|
||||||
export const normalizeAd = (ad: Record<string, any>) => {
|
export const normalizeAd = (ad: Record<string, any>) => {
|
||||||
const map = ImmutableMap<string, any>(fromJS(ad));
|
const map = ImmutableMap<string, any>(fromJS(ad));
|
||||||
const card = normalizeCard(map.get('card'));
|
const card = normalizeCard(map.get('card'));
|
||||||
return AdRecord(map.set('card', card));
|
const expiresAt = map.get('expires_at') || map.get('expires');
|
||||||
|
|
||||||
|
return AdRecord(map.merge({
|
||||||
|
card,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { Ad, getProvider } from 'soapbox/features/ads/providers';
|
import { Ad, getProvider } from 'soapbox/features/ads/providers';
|
||||||
import { useAppDispatch } from 'soapbox/hooks';
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
import { normalizeAd } from 'soapbox/normalizers';
|
||||||
import { isExpired } from 'soapbox/utils/ads';
|
import { isExpired } from 'soapbox/utils/ads';
|
||||||
|
|
||||||
export default function useAds() {
|
export default function useAds() {
|
||||||
|
@ -23,7 +24,7 @@ export default function useAds() {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter out expired ads.
|
// Filter out expired ads.
|
||||||
const data = result.data?.filter(ad => !isExpired(ad));
|
const data = result.data?.map(normalizeAd).filter(ad => !isExpired(ad));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { normalizeCard } from 'soapbox/normalizers';
|
import { normalizeAd } from 'soapbox/normalizers';
|
||||||
|
|
||||||
import { isExpired } from '../ads';
|
import { isExpired } from '../ads';
|
||||||
|
|
||||||
|
@ -10,13 +10,14 @@ const fiveMins = 5 * 60 * 1000;
|
||||||
|
|
||||||
test('isExpired()', () => {
|
test('isExpired()', () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const card = normalizeCard({});
|
const iso = now.toISOString();
|
||||||
|
const epoch = now.getTime();
|
||||||
|
|
||||||
// Sanity tests.
|
// Sanity tests.
|
||||||
expect(isExpired({ expires: now, card })).toBe(true);
|
expect(isExpired(normalizeAd({ expires_at: iso }))).toBe(true);
|
||||||
expect(isExpired({ expires: new Date(now.getTime() + 999999), card })).toBe(false);
|
expect(isExpired(normalizeAd({ expires_at: new Date(epoch + 999999).toISOString() }))).toBe(false);
|
||||||
|
|
||||||
// Testing the 5-minute mark.
|
// Testing the 5-minute mark.
|
||||||
expect(isExpired({ expires: new Date(now.getTime() + threeMins), card }, fiveMins)).toBe(true);
|
expect(isExpired(normalizeAd({ expires_at: new Date(epoch + threeMins).toISOString() }), fiveMins)).toBe(true);
|
||||||
expect(isExpired({ expires: new Date(now.getTime() + fiveMins + 1000), card }, fiveMins)).toBe(false);
|
expect(isExpired(normalizeAd({ expires_at: new Date(epoch + fiveMins + 1000).toISOString() }), fiveMins)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import type { Ad } from 'soapbox/features/ads/providers';
|
import type { Ad } from 'soapbox/types/soapbox';
|
||||||
|
|
||||||
/** Time (ms) window to not display an ad if it's about to expire. */
|
/** Time (ms) window to not display an ad if it's about to expire. */
|
||||||
const AD_EXPIRY_THRESHOLD = 5 * 60 * 1000;
|
const AD_EXPIRY_THRESHOLD = 5 * 60 * 1000;
|
||||||
|
|
||||||
/** Whether the ad is expired or about to expire. */
|
/** Whether the ad is expired or about to expire. */
|
||||||
const isExpired = (ad: Ad, threshold = AD_EXPIRY_THRESHOLD): boolean => {
|
const isExpired = (ad: Ad, threshold = AD_EXPIRY_THRESHOLD): boolean => {
|
||||||
if (ad.expires) {
|
if (ad.expires_at) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return now.getTime() > (ad.expires.getTime() - threshold);
|
return now.getTime() > (new Date(ad.expires_at).getTime() - threshold);
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue