diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 408506997..7cf13fb86 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -6,13 +6,17 @@ import { FormattedMessage } from 'react-intl'; 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 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/features/ads/providers'; interface IStatusList extends Omit { /** Unique key to preserve the scroll position when navigating back. */ @@ -37,6 +41,8 @@ interface IStatusList extends Omit { timelineId?: string, /** Whether to display a gap or border between statuses in the list. */ divideType?: 'space' | 'border', + /** Whether to display ads. */ + showAds?: boolean, } /** Feed of statuses, built atop ScrollableList. */ @@ -49,8 +55,12 @@ const StatusList: React.FC = ({ timelineId, isLoading, isPartial, + showAds = false, ...other }) => { + const { data: ads } = useAds(); + const soapboxConfig = useSoapboxConfig(); + const adsInterval = Number(soapboxConfig.extensions.getIn(['ads', 'interval'], 40)) || 0; const node = useRef(null); const getFeaturedStatusCount = () => { @@ -123,6 +133,15 @@ const StatusList: React.FC = ({ ); }; + const renderAd = (ad: AdEntity) => { + return ( + + ); + }; + const renderPendingStatus = (statusId: string) => { const idempotencyKey = statusId.replace(/^末pending-/, ''); @@ -156,17 +175,27 @@ const StatusList: React.FC = ({ const renderStatuses = (): React.ReactNode[] => { if (isLoading || statusIds.size > 0) { - return statusIds.toArray().map((statusId, index) => { + return statusIds.toList().reduce((acc, statusId, index) => { + const adIndex = ads ? Math.floor((index + 1) / adsInterval) % ads.length : 0; + const ad = ads ? ads[adIndex] : undefined; + const showAd = (index + 1) % adsInterval === 0; + if (statusId === null) { - return renderLoadGap(index); + acc.push(renderLoadGap(index)); } else if (statusId.startsWith('末suggestions-')) { - return renderFeedSuggestions(); + acc.push(renderFeedSuggestions()); } else if (statusId.startsWith('末pending-')) { - return renderPendingStatus(statusId); + acc.push(renderPendingStatus(statusId)); } else { - return renderStatus(statusId); + acc.push(renderStatus(statusId)); } - }); + + if (showAds && ad && showAd) { + acc.push(renderAd(ad)); + } + + return acc; + }, [] as React.ReactNode[]); } else { return []; } diff --git a/app/soapbox/features/ads/providers/index.ts b/app/soapbox/features/ads/providers/index.ts index 3adf323c7..69b8800a4 100644 --- a/app/soapbox/features/ads/providers/index.ts +++ b/app/soapbox/features/ads/providers/index.ts @@ -1,6 +1,13 @@ +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 Promise> = { + soapbox: async() => (await import(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default, +}; + /** Ad server implementation. */ interface AdProvider { getAds(getState: () => RootState): Promise, @@ -14,4 +21,17 @@ interface Ad { impression?: string, } +/** Gets the current provider based on config. */ +const getProvider = async(getState: () => RootState): Promise => { + 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 }; diff --git a/app/soapbox/features/home_timeline/index.tsx b/app/soapbox/features/home_timeline/index.tsx index dd83f723b..8c5522c66 100644 --- a/app/soapbox/features/home_timeline/index.tsx +++ b/app/soapbox/features/home_timeline/index.tsx @@ -90,6 +90,7 @@ const HomeTimeline: React.FC = () => { onLoadMore={handleLoadMore} timelineId='home' divideType='space' + showAds emptyMessage={ diff --git a/app/soapbox/queries/ads.ts b/app/soapbox/queries/ads.ts new file mode 100644 index 000000000..7ad594a94 --- /dev/null +++ b/app/soapbox/queries/ads.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; + +import { Ad, getProvider } from 'soapbox/features/ads/providers'; +import { useAppDispatch } from 'soapbox/hooks'; + +export default 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 []; + } + }); + }; + + return useQuery(['ads'], getAds, { + placeholderData: [], + }); +}