diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index c45216e03..3f0b262b5 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -137,6 +137,7 @@ const StatusList: React.FC = ({ ); }; diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx index 7ab86c2c0..5c1ff6a41 100644 --- a/app/soapbox/features/ads/components/ad.tsx +++ b/app/soapbox/features/ads/components/ad.tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from '@tanstack/react-query'; import React, { useState, useEffect, useRef } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -13,15 +14,24 @@ interface IAd { 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. */ -const Ad: React.FC = ({ card, impression }) => { +const Ad: React.FC = ({ card, impression, expires }) => { + const queryClient = useQueryClient(); const instance = useAppSelector(state => state.instance); + const timer = useRef(undefined); const infobox = useRef(null); const [showInfo, setShowInfo] = useState(false); + /** Invalidate query cache for ads. */ + const bustCache = (): void => { + queryClient.invalidateQueries(['ads']); + }; + /** Toggle the info box on click. */ const handleInfoButtonClick: React.MouseEventHandler = () => { setShowInfo(!showInfo); @@ -51,6 +61,20 @@ const Ad: React.FC = ({ card, impression }) => { } }, [impression]); + // Wait until the ad expires, then invalidate cache. + useEffect(() => { + if (expires) { + const delta = expires.getTime() - (new Date()).getTime(); + timer.current = setTimeout(bustCache, delta); + } + + return () => { + if (timer.current) { + clearTimeout(timer.current); + } + }; + }, [expires]); + return (
diff --git a/app/soapbox/features/ads/providers/index.ts b/app/soapbox/features/ads/providers/index.ts index 65e593985..b9c504bff 100644 --- a/app/soapbox/features/ads/providers/index.ts +++ b/app/soapbox/features/ads/providers/index.ts @@ -20,6 +20,8 @@ interface Ad { card: Card, /** Impression URL to fetch when displaying the ad. */ impression?: string, + /** Time when the ad expires and should no longer be displayed. */ + expires?: Date, } /** 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 249fc8547..ace4021f0 100644 --- a/app/soapbox/features/ads/providers/rumble.ts +++ b/app/soapbox/features/ads/providers/rumble.ts @@ -43,6 +43,7 @@ const RumbleAdProvider: AdProvider = { image: item.asset, url: item.click, }), + expires: new Date(item.expires * 1000), })); } } diff --git a/app/soapbox/normalizers/soapbox/ad.ts b/app/soapbox/normalizers/soapbox/ad.ts index c29ee9a3e..115ad529c 100644 --- a/app/soapbox/normalizers/soapbox/ad.ts +++ b/app/soapbox/normalizers/soapbox/ad.ts @@ -9,6 +9,7 @@ import { CardRecord, normalizeCard } from '../card'; export const AdRecord = ImmutableRecord({ card: CardRecord(), impression: undefined as string | undefined, + expires: undefined as Date | undefined, }); /** Normalizes an ad from Soapbox Config. */ diff --git a/app/soapbox/queries/ads.ts b/app/soapbox/queries/ads.ts index 7ad594a94..91c7da2fb 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 { isExpired } from 'soapbox/utils/ads'; export default function useAds() { const dispatch = useAppDispatch(); @@ -17,7 +18,15 @@ export default function useAds() { }); }; - return useQuery(['ads'], getAds, { + const result = useQuery(['ads'], getAds, { placeholderData: [], }); + + // Filter out expired ads. + const data = result.data?.filter(isExpired); + + return { + ...result, + data, + }; } diff --git a/app/soapbox/utils/__tests__/ads.test.ts b/app/soapbox/utils/__tests__/ads.test.ts new file mode 100644 index 000000000..989048110 --- /dev/null +++ b/app/soapbox/utils/__tests__/ads.test.ts @@ -0,0 +1,22 @@ +import { normalizeCard } from 'soapbox/normalizers'; + +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 card = normalizeCard({}); + + // Sanity tests. + expect(isExpired({ expires: now, card })).toBe(true); + expect(isExpired({ expires: new Date(now.getTime() + 999999), card })).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); +}); diff --git a/app/soapbox/utils/ads.ts b/app/soapbox/utils/ads.ts new file mode 100644 index 000000000..2d5a01040 --- /dev/null +++ b/app/soapbox/utils/ads.ts @@ -0,0 +1,16 @@ +import type { Ad } from 'soapbox/features/ads/providers'; + +/** 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) { + const now = new Date(); + return now.getTime() > (ad.expires.getTime() - threshold); + } else { + return false; + } +}; + +export { isExpired };