diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 3bee7a03a..09fbe3275 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -19,7 +19,7 @@ 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/features/ads/providers'; +import type { Ad as AdEntity } from 'soapbox/types/soapbox'; interface IStatusList extends Omit { /** Unique key to preserve the scroll position when navigating back. */ @@ -141,12 +141,7 @@ const StatusList: React.FC = ({ const renderAd = (ad: AdEntity, index: number) => { return ( - + ); }; diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx index 43b2dc799..a0db9f78d 100644 --- a/app/soapbox/features/ads/components/ad.tsx +++ b/app/soapbox/features/ads/components/ad.tsx @@ -7,19 +7,14 @@ import IconButton from 'soapbox/components/ui/icon-button/icon-button'; import StatusCard from 'soapbox/features/status/components/card'; 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 { - /** Embedded ad data in Card format (almost like OEmbed). */ - card: CardEntity, - /** Impression URL to fetch upon display. */ - impression?: string, - /** Time when the ad expires and should no longer be displayed. */ - expires?: Date, + ad: AdEntity, } /** Displays an ad in sponsored post format. */ -const Ad: React.FC = ({ card, impression, expires }) => { +const Ad: React.FC = ({ ad }) => { const queryClient = useQueryClient(); const instance = useAppSelector(state => state.instance); @@ -29,9 +24,9 @@ const Ad: React.FC = ({ card, impression, expires }) => { // Fetch the impression URL (if any) upon displaying the ad. // Don't fetch it more than once. - useQuery(['ads', 'impression', impression], () => { - if (impression) { - return fetch(impression); + useQuery(['ads', 'impression', ad.impression], () => { + if (ad.impression) { + return fetch(ad.impression); } }, { cacheTime: Infinity, staleTime: Infinity }); @@ -63,8 +58,8 @@ const Ad: React.FC = ({ card, impression, expires }) => { // Wait until the ad expires, then invalidate cache. useEffect(() => { - if (expires) { - const delta = expires.getTime() - (new Date()).getTime(); + if (ad.expires_at) { + const delta = new Date(ad.expires_at).getTime() - (new Date()).getTime(); timer.current = setTimeout(bustCache, delta); } @@ -73,7 +68,7 @@ const Ad: React.FC = ({ card, impression, expires }) => { clearTimeout(timer.current); } }; - }, [expires]); + }, [ad.expires_at]); return (
@@ -112,7 +107,7 @@ const Ad: React.FC = ({ card, impression, expires }) => { - {}} horizontal /> + {}} horizontal /> @@ -125,11 +120,15 @@ const Ad: React.FC = ({ card, impression, expires }) => { - + {ad.reason ? ( + ad.reason + ) : ( + + )} diff --git a/app/soapbox/features/ads/providers/index.ts b/app/soapbox/features/ads/providers/index.ts index b9c504bff..bd17fa91c 100644 --- a/app/soapbox/features/ads/providers/index.ts +++ b/app/soapbox/features/ads/providers/index.ts @@ -7,6 +7,7 @@ import type { Card } from 'soapbox/types/entities'; const PROVIDERS: Record Promise> = { soapbox: async() => (await import(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default, rumble: async() => (await import(/* webpackChunkName: "features/ads/rumble" */'./rumble')).default, + truth: async() => (await import(/* webpackChunkName: "features/ads/truth" */'./truth')).default, }; /** Ad server implementation. */ @@ -21,7 +22,9 @@ interface Ad { /** Impression URL to fetch when displaying the ad. */ impression?: string, /** 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. */ diff --git a/app/soapbox/features/ads/providers/rumble.ts b/app/soapbox/features/ads/providers/rumble.ts index ace4021f0..bc86e8686 100644 --- a/app/soapbox/features/ads/providers/rumble.ts +++ b/app/soapbox/features/ads/providers/rumble.ts @@ -1,6 +1,6 @@ import { getSettings } from 'soapbox/actions/settings'; import { getSoapboxConfig } from 'soapbox/actions/soapbox'; -import { normalizeCard } from 'soapbox/normalizers'; +import { normalizeAd, normalizeCard } from 'soapbox/normalizers'; import type { AdProvider } from '.'; @@ -36,14 +36,14 @@ const RumbleAdProvider: AdProvider = { if (response.ok) { const data = await response.json() as RumbleApiResponse; - return data.ads.map(item => ({ + return data.ads.map(item => normalizeAd({ impression: item.impression, card: normalizeCard({ type: item.type === 1 ? 'link' : 'rich', image: item.asset, url: item.click, }), - expires: new Date(item.expires * 1000), + expires_at: new Date(item.expires * 1000), })); } } diff --git a/app/soapbox/features/ads/providers/truth.ts b/app/soapbox/features/ads/providers/truth.ts new file mode 100644 index 000000000..92f2e99f8 --- /dev/null +++ b/app/soapbox/features/ads/providers/truth.ts @@ -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; diff --git a/app/soapbox/normalizers/soapbox/ad.ts b/app/soapbox/normalizers/soapbox/ad.ts index 115ad529c..85dbcc8c6 100644 --- a/app/soapbox/normalizers/soapbox/ad.ts +++ b/app/soapbox/normalizers/soapbox/ad.ts @@ -6,15 +6,23 @@ import { import { CardRecord, normalizeCard } from '../card'; -export const AdRecord = ImmutableRecord({ +import type { Ad } from 'soapbox/features/ads/providers'; + +export const AdRecord = ImmutableRecord({ card: CardRecord(), 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. */ export const normalizeAd = (ad: Record) => { const map = ImmutableMap(fromJS(ad)); 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, + })); }; diff --git a/app/soapbox/queries/ads.ts b/app/soapbox/queries/ads.ts index 86dc1e86f..94c1d8640 100644 --- a/app/soapbox/queries/ads.ts +++ b/app/soapbox/queries/ads.ts @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { Ad, getProvider } from 'soapbox/features/ads/providers'; import { useAppDispatch } from 'soapbox/hooks'; +import { normalizeAd } from 'soapbox/normalizers'; import { isExpired } from 'soapbox/utils/ads'; export default function useAds() { @@ -23,7 +24,7 @@ export default function useAds() { }); // Filter out expired ads. - const data = result.data?.filter(ad => !isExpired(ad)); + const data = result.data?.map(normalizeAd).filter(ad => !isExpired(ad)); return { ...result, diff --git a/app/soapbox/utils/__tests__/ads.test.ts b/app/soapbox/utils/__tests__/ads.test.ts index 989048110..f96f29936 100644 --- a/app/soapbox/utils/__tests__/ads.test.ts +++ b/app/soapbox/utils/__tests__/ads.test.ts @@ -1,4 +1,4 @@ -import { normalizeCard } from 'soapbox/normalizers'; +import { normalizeAd } from 'soapbox/normalizers'; import { isExpired } from '../ads'; @@ -10,13 +10,14 @@ const fiveMins = 5 * 60 * 1000; test('isExpired()', () => { const now = new Date(); - const card = normalizeCard({}); + const iso = now.toISOString(); + const epoch = now.getTime(); // Sanity tests. - expect(isExpired({ expires: now, card })).toBe(true); - expect(isExpired({ expires: new Date(now.getTime() + 999999), card })).toBe(false); + expect(isExpired(normalizeAd({ expires_at: iso }))).toBe(true); + expect(isExpired(normalizeAd({ expires_at: new Date(epoch + 999999).toISOString() }))).toBe(false); // Testing the 5-minute mark. - expect(isExpired({ expires: new Date(now.getTime() + threeMins), card }, fiveMins)).toBe(true); - expect(isExpired({ expires: new Date(now.getTime() + fiveMins + 1000), card }, fiveMins)).toBe(false); + expect(isExpired(normalizeAd({ expires_at: new Date(epoch + threeMins).toISOString() }), fiveMins)).toBe(true); + expect(isExpired(normalizeAd({ expires_at: new Date(epoch + fiveMins + 1000).toISOString() }), fiveMins)).toBe(false); }); diff --git a/app/soapbox/utils/ads.ts b/app/soapbox/utils/ads.ts index 2d5a01040..949315191 100644 --- a/app/soapbox/utils/ads.ts +++ b/app/soapbox/utils/ads.ts @@ -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. */ const AD_EXPIRY_THRESHOLD = 5 * 60 * 1000; /** Whether the ad is expired or about to expire. */ const isExpired = (ad: Ad, threshold = AD_EXPIRY_THRESHOLD): boolean => { - if (ad.expires) { + if (ad.expires_at) { const now = new Date(); - return now.getTime() > (ad.expires.getTime() - threshold); + return now.getTime() > (new Date(ad.expires_at).getTime() - threshold); } else { return false; }