Merge branch 'ads' into 'develop'
Support ads in feed See merge request soapbox-pub/soapbox-fe!1698
This commit is contained in:
commit
7b355dea6e
21 changed files with 402 additions and 48 deletions
BIN
.eslintrc.js
BIN
.eslintrc.js
Binary file not shown.
|
@ -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<IScrollableList, 'onLoadMore' | 'children'> {
|
||||
/** Unique key to preserve the scroll position when navigating back. */
|
||||
|
@ -37,6 +41,8 @@ interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
|||
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<IStatusList> = ({
|
|||
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<VirtuosoHandle>(null);
|
||||
|
||||
const getFeaturedStatusCount = () => {
|
||||
|
@ -123,6 +133,15 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const renderAd = (ad: AdEntity) => {
|
||||
return (
|
||||
<Ad
|
||||
card={ad.card}
|
||||
impression={ad.impression}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPendingStatus = (statusId: string) => {
|
||||
const idempotencyKey = statusId.replace(/^末pending-/, '');
|
||||
|
||||
|
@ -156,17 +175,27 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
|
||||
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 [];
|
||||
}
|
||||
|
|
|
@ -32,11 +32,13 @@ interface IStack extends React.HTMLAttributes<HTMLDivElement> {
|
|||
justifyContent?: 'center',
|
||||
/** Extra class names on the <div> element. */
|
||||
className?: string,
|
||||
/** Whether to let the flexbox grow. */
|
||||
grow?: boolean,
|
||||
}
|
||||
|
||||
/** Vertical stack of child elements. */
|
||||
const Stack: React.FC<IStack> = (props) => {
|
||||
const { space, alignItems, justifyContent, className, ...filteredProps } = props;
|
||||
const { space, alignItems, justifyContent, className, grow, ...filteredProps } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -48,6 +50,7 @@ const Stack: React.FC<IStack> = (props) => {
|
|||
[alignItemsOptions[alignItems]]: typeof alignItems !== 'undefined',
|
||||
// @ts-ignore
|
||||
[justifyContentOptions[justifyContent]]: typeof justifyContent !== 'undefined',
|
||||
'flex-grow': grow,
|
||||
}, className)}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
'use strict';
|
||||
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import classNames from 'classnames';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
@ -37,6 +38,7 @@ import {
|
|||
useLocale,
|
||||
} from 'soapbox/hooks';
|
||||
import MESSAGES from 'soapbox/locales/messages';
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
import { useCachedLocationHandler } from 'soapbox/utils/redirect';
|
||||
import { generateThemeCss } from 'soapbox/utils/theme';
|
||||
|
||||
|
@ -281,11 +283,13 @@ const SoapboxHead: React.FC<ISoapboxHead> = ({ children }) => {
|
|||
const Soapbox: React.FC = () => {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<SoapboxHead>
|
||||
<SoapboxLoad>
|
||||
<SoapboxMount />
|
||||
</SoapboxLoad>
|
||||
</SoapboxHead>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SoapboxHead>
|
||||
<SoapboxLoad>
|
||||
<SoapboxMount />
|
||||
</SoapboxLoad>
|
||||
</SoapboxHead>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
|
118
app/soapbox/features/ads/components/ad.tsx
Normal file
118
app/soapbox/features/ads/components/ad.tsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Stack, HStack, Card, Avatar, Text, Icon } from 'soapbox/components/ui';
|
||||
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';
|
||||
|
||||
interface IAd {
|
||||
/** Embedded ad data in Card format (almost like OEmbed). */
|
||||
card: CardEntity,
|
||||
/** Impression URL to fetch upon display. */
|
||||
impression?: string,
|
||||
}
|
||||
|
||||
/** Displays an ad in sponsored post format. */
|
||||
const Ad: React.FC<IAd> = ({ card, impression }) => {
|
||||
const instance = useAppSelector(state => state.instance);
|
||||
|
||||
const infobox = useRef<HTMLDivElement>(null);
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
/** 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]);
|
||||
|
||||
// Fetch the impression URL (if any) upon displaying the ad.
|
||||
// It's common for ad providers to provide this.
|
||||
useEffect(() => {
|
||||
if (impression) {
|
||||
fetch(impression);
|
||||
}
|
||||
}, [impression]);
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<Card className='p-5' 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='w-5 h-5 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='stroke-gray-600 w-6 h-6'
|
||||
src={require('@tabler/icons/info-circle.svg')}
|
||||
onClick={handleInfoButtonClick}
|
||||
/>
|
||||
</Stack>
|
||||
</HStack>
|
||||
|
||||
<StatusCard card={card} onOpenMedia={() => {}} horizontal />
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{showInfo && (
|
||||
<div ref={infobox} className='absolute top-5 right-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'>
|
||||
<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;
|
38
app/soapbox/features/ads/providers/index.ts
Normal file
38
app/soapbox/features/ads/providers/index.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
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(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default,
|
||||
rumble: async() => (await import(/* webpackChunkName: "features/ads/rumble" */'./rumble')).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,
|
||||
}
|
||||
|
||||
/** 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 };
|
45
app/soapbox/features/ads/providers/rumble.ts
Normal file
45
app/soapbox/features/ads/providers/rumble.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import { normalizeCard } from 'soapbox/normalizers';
|
||||
|
||||
import type { AdProvider } from '.';
|
||||
|
||||
/** Rumble ad API entity. */
|
||||
interface RumbleAd {
|
||||
type: number,
|
||||
impression: string,
|
||||
click: string,
|
||||
asset: string,
|
||||
expires: number,
|
||||
}
|
||||
|
||||
/** Response from Rumble ad server. */
|
||||
interface RumbleApiResponse {
|
||||
count: number,
|
||||
ads: RumbleAd[],
|
||||
}
|
||||
|
||||
/** Provides ads from Soapbox Config. */
|
||||
const RumbleAdProvider: AdProvider = {
|
||||
getAds: async(getState) => {
|
||||
const state = getState();
|
||||
const soapboxConfig = getSoapboxConfig(state);
|
||||
const endpoint = soapboxConfig.extensions.getIn(['ads', 'endpoint']) as string | undefined;
|
||||
|
||||
if (endpoint) {
|
||||
const response = await fetch(endpoint);
|
||||
const data = await response.json() as RumbleApiResponse;
|
||||
return data.ads.map(item => ({
|
||||
impression: item.impression,
|
||||
card: normalizeCard({
|
||||
type: item.type === 1 ? 'link' : 'rich',
|
||||
image: item.asset,
|
||||
url: item.click,
|
||||
}),
|
||||
}));
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default RumbleAdProvider;
|
14
app/soapbox/features/ads/providers/soapbox-config.ts
Normal file
14
app/soapbox/features/ads/providers/soapbox-config.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
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;
|
|
@ -137,9 +137,7 @@ describe('<FeedCarousel />', () => {
|
|||
expect(screen.queryAllByTestId('prev-page')).toHaveLength(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
user.click(screen.getByTestId('next-page'));
|
||||
});
|
||||
await user.click(screen.getByTestId('next-page'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('prev-page')).toBeInTheDocument();
|
||||
|
|
|
@ -90,6 +90,7 @@ const HomeTimeline: React.FC = () => {
|
|||
onLoadMore={handleLoadMore}
|
||||
timelineId='home'
|
||||
divideType='space'
|
||||
showAds
|
||||
emptyMessage={
|
||||
<Stack space={1}>
|
||||
<Text size='xl' weight='medium' align='center'>
|
||||
|
|
|
@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
|
|||
|
||||
import Blurhash from 'soapbox/components/blurhash';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||
|
||||
import type { Card as CardEntity, Attachment } from 'soapbox/types/entities';
|
||||
|
@ -51,6 +51,7 @@ interface ICard {
|
|||
compact?: boolean,
|
||||
defaultWidth?: number,
|
||||
cacheWidth?: (width: number) => void,
|
||||
horizontal?: boolean,
|
||||
}
|
||||
|
||||
const Card: React.FC<ICard> = ({
|
||||
|
@ -61,6 +62,7 @@ const Card: React.FC<ICard> = ({
|
|||
compact = false,
|
||||
cacheWidth,
|
||||
onOpenMedia,
|
||||
horizontal,
|
||||
}): JSX.Element => {
|
||||
const [width, setWidth] = useState(defaultWidth);
|
||||
const [embedded, setEmbedded] = useState(false);
|
||||
|
@ -132,7 +134,7 @@ const Card: React.FC<ICard> = ({
|
|||
};
|
||||
|
||||
const interactive = card.type !== 'link';
|
||||
const horizontal = interactive || embedded;
|
||||
horizontal = typeof horizontal === 'boolean' ? horizontal : interactive || embedded;
|
||||
const className = classnames('status-card', { horizontal, compact, interactive }, `status-card--${card.type}`);
|
||||
const ratio = getRatio(card);
|
||||
const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
|
||||
|
@ -140,24 +142,34 @@ const Card: React.FC<ICard> = ({
|
|||
const title = interactive ? (
|
||||
<a
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='status-card__title'
|
||||
href={card.url}
|
||||
title={trimmedTitle}
|
||||
rel='noopener'
|
||||
target='_blank'
|
||||
>
|
||||
<strong>{trimmedTitle}</strong>
|
||||
<span>{trimmedTitle}</span>
|
||||
</a>
|
||||
) : (
|
||||
<strong className='status-card__title' title={trimmedTitle}>{trimmedTitle}</strong>
|
||||
<span title={trimmedTitle}>{trimmedTitle}</span>
|
||||
);
|
||||
|
||||
const description = (
|
||||
<div className='status-card__content'>
|
||||
<span className='status-card__title'>{title}</span>
|
||||
<p className='status-card__description'>{trimmedDescription}</p>
|
||||
<span className='status-card__host'><Icon src={require('@tabler/icons/link.svg')} /> {card.provider_name}</span>
|
||||
</div>
|
||||
<Stack space={2} className='flex-1 overflow-hidden p-4'>
|
||||
{trimmedTitle && (
|
||||
<Text weight='bold'>{title}</Text>
|
||||
)}
|
||||
{trimmedDescription && (
|
||||
<Text>{trimmedDescription}</Text>
|
||||
)}
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Text tag='span' theme='muted'>
|
||||
<Icon src={require('@tabler/icons/link.svg')} />
|
||||
</Text>
|
||||
<Text tag='span' theme='muted' size='sm'>
|
||||
{card.provider_name}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
let embed: React.ReactNode = '';
|
||||
|
@ -234,7 +246,15 @@ const Card: React.FC<ICard> = ({
|
|||
);
|
||||
} else if (card.image) {
|
||||
embed = (
|
||||
<div className='status-card__image'>
|
||||
<div className={classnames(
|
||||
'status-card__image',
|
||||
'w-full rounded-l md:w-auto md:h-auto flex-none md:flex-auto',
|
||||
{
|
||||
'h-auto': horizontal,
|
||||
'h-[200px]': !horizontal,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{canvas}
|
||||
{thumbnail}
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { configureMockStore } from '@jedmao/redux-mock-store';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, RenderOptions } from '@testing-library/react';
|
||||
import { merge } from 'immutable';
|
||||
import React, { FC, ReactElement } from 'react';
|
||||
|
@ -9,6 +10,8 @@ import { Action, applyMiddleware, createStore } from 'redux';
|
|||
import thunk from 'redux-thunk';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
|
||||
import NotificationsContainer from '../features/ui/containers/notifications_container';
|
||||
import { default as rootReducer } from '../reducers';
|
||||
|
||||
|
@ -45,13 +48,15 @@ const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
|
|||
|
||||
return (
|
||||
<Provider store={props.store}>
|
||||
<IntlProvider locale={props.locale}>
|
||||
<MemoryRouter {...routerProps}>
|
||||
{children}
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IntlProvider locale={props.locale}>
|
||||
<MemoryRouter {...routerProps}>
|
||||
{children}
|
||||
|
||||
<NotificationsContainer />
|
||||
</MemoryRouter>
|
||||
</IntlProvider>
|
||||
<NotificationsContainer />
|
||||
</MemoryRouter>
|
||||
</IntlProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -20,4 +20,5 @@ export { StatusRecord, normalizeStatus } from './status';
|
|||
export { StatusEditRecord, normalizeStatusEdit } from './status_edit';
|
||||
export { TagRecord, normalizeTag } from './tag';
|
||||
|
||||
export { AdRecord, normalizeAd } from './soapbox/ad';
|
||||
export { SoapboxConfigRecord, normalizeSoapboxConfig } from './soapbox/soapbox_config';
|
||||
|
|
19
app/soapbox/normalizers/soapbox/ad.ts
Normal file
19
app/soapbox/normalizers/soapbox/ad.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import {
|
||||
Map as ImmutableMap,
|
||||
Record as ImmutableRecord,
|
||||
fromJS,
|
||||
} from 'immutable';
|
||||
|
||||
import { CardRecord, normalizeCard } from '../card';
|
||||
|
||||
export const AdRecord = ImmutableRecord({
|
||||
card: CardRecord(),
|
||||
impression: undefined as string | undefined,
|
||||
});
|
||||
|
||||
/** Normalizes an ad from Soapbox Config. */
|
||||
export const normalizeAd = (ad: Record<string, any>) => {
|
||||
const map = ImmutableMap<string, any>(fromJS(ad));
|
||||
const card = normalizeCard(map.get('card'));
|
||||
return AdRecord(map.set('card', card));
|
||||
};
|
|
@ -9,7 +9,10 @@ import trimStart from 'lodash/trimStart';
|
|||
import { toTailwind } from 'soapbox/utils/tailwind';
|
||||
import { generateAccent } from 'soapbox/utils/theme';
|
||||
|
||||
import { normalizeAd } from './ad';
|
||||
|
||||
import type {
|
||||
Ad,
|
||||
PromoPanelItem,
|
||||
FooterItem,
|
||||
CryptoAddress,
|
||||
|
@ -66,6 +69,7 @@ export const CryptoAddressRecord = ImmutableRecord({
|
|||
});
|
||||
|
||||
export const SoapboxConfigRecord = ImmutableRecord({
|
||||
ads: ImmutableList<Ad>(),
|
||||
appleAppId: null,
|
||||
logo: '',
|
||||
logoDarkMode: null,
|
||||
|
@ -110,6 +114,11 @@ export const SoapboxConfigRecord = ImmutableRecord({
|
|||
|
||||
type SoapboxConfigMap = ImmutableMap<string, any>;
|
||||
|
||||
const normalizeAds = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => {
|
||||
const ads = ImmutableList<Record<string, any>>(soapboxConfig.get('ads'));
|
||||
return soapboxConfig.set('ads', ads.map(normalizeAd));
|
||||
};
|
||||
|
||||
const normalizeCryptoAddress = (address: unknown): CryptoAddress => {
|
||||
return CryptoAddressRecord(ImmutableMap(fromJS(address))).update('ticker', ticker => {
|
||||
return trimStart(ticker, '$').toLowerCase();
|
||||
|
@ -175,6 +184,7 @@ export const normalizeSoapboxConfig = (soapboxConfig: Record<string, any>) => {
|
|||
normalizeFooterLinks(soapboxConfig);
|
||||
maybeAddMissingColors(soapboxConfig);
|
||||
normalizeCryptoAddresses(soapboxConfig);
|
||||
normalizeAds(soapboxConfig);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
23
app/soapbox/queries/ads.ts
Normal file
23
app/soapbox/queries/ads.ts
Normal file
|
@ -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<Ad[]>(['ads'], getAds, {
|
||||
placeholderData: [],
|
||||
});
|
||||
}
|
13
app/soapbox/queries/client.ts
Normal file
13
app/soapbox/queries/client.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60000, // 1 minute
|
||||
cacheTime: Infinity,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export { queryClient };
|
|
@ -1,3 +1,4 @@
|
|||
import { AdRecord } from 'soapbox/normalizers/soapbox/ad';
|
||||
import {
|
||||
PromoPanelItemRecord,
|
||||
FooterItemRecord,
|
||||
|
@ -7,6 +8,7 @@ import {
|
|||
|
||||
type Me = string | null | false | undefined;
|
||||
|
||||
type Ad = ReturnType<typeof AdRecord>;
|
||||
type PromoPanelItem = ReturnType<typeof PromoPanelItemRecord>;
|
||||
type FooterItem = ReturnType<typeof FooterItemRecord>;
|
||||
type CryptoAddress = ReturnType<typeof CryptoAddressRecord>;
|
||||
|
@ -14,6 +16,7 @@ type SoapboxConfig = ReturnType<typeof SoapboxConfigRecord>;
|
|||
|
||||
export {
|
||||
Me,
|
||||
Ad,
|
||||
PromoPanelItem,
|
||||
FooterItem,
|
||||
CryptoAddress,
|
||||
|
|
|
@ -305,18 +305,6 @@ a.status-card {
|
|||
}
|
||||
}
|
||||
|
||||
.status-card__title {
|
||||
@apply block font-medium mb-2 text-gray-800 dark:text-gray-200 no-underline;
|
||||
}
|
||||
|
||||
.status-card__content {
|
||||
@apply flex-1 overflow-hidden p-4;
|
||||
}
|
||||
|
||||
.status-card__description {
|
||||
@apply text-gray-700 dark:text-gray-600;
|
||||
}
|
||||
|
||||
.status-card__host {
|
||||
@apply text-primary-600 dark:text-accent-blue;
|
||||
display: flex;
|
||||
|
@ -338,6 +326,7 @@ a.status-card {
|
|||
flex: 0 0 40%;
|
||||
background: var(--brand-color--med);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
& > .svg-icon {
|
||||
width: 40px;
|
||||
|
@ -378,10 +367,6 @@ a.status-card {
|
|||
@apply flex flex-col md:flex-row;
|
||||
}
|
||||
|
||||
.status-card--link .status-card__image {
|
||||
@apply w-full rounded-l md:w-auto h-[200px] md:h-auto flex-none md:flex-auto;
|
||||
}
|
||||
|
||||
.material-status {
|
||||
padding-bottom: 10px;
|
||||
|
||||
|
|
|
@ -70,6 +70,7 @@
|
|||
"@tabler/icons": "^1.73.0",
|
||||
"@tailwindcss/forms": "^0.4.0",
|
||||
"@tailwindcss/typography": "^0.5.1",
|
||||
"@tanstack/react-query": "^4.0.10",
|
||||
"@testing-library/react": "^12.1.4",
|
||||
"@types/escape-html": "^1.0.1",
|
||||
"@types/http-link-header": "^1.0.3",
|
||||
|
|
24
yarn.lock
24
yarn.lock
|
@ -2296,6 +2296,20 @@
|
|||
lodash.isplainobject "^4.0.6"
|
||||
lodash.merge "^4.6.2"
|
||||
|
||||
"@tanstack/query-core@^4.0.0-beta.1":
|
||||
version "4.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.0.10.tgz#cae6f818006616dc72c95c863592f5f68b47548a"
|
||||
integrity sha512-9LsABpZXkWZHi4P1ozRETEDXQocLAxVzQaIhganxbNuz/uA3PsCAJxJTiQrknG5htLMzOF5MqM9G10e6DCxV1A==
|
||||
|
||||
"@tanstack/react-query@^4.0.10":
|
||||
version "4.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.0.10.tgz#92c71a2632c06450d848d4964959bd216cde03c0"
|
||||
integrity sha512-Wn5QhZUE5wvr6rGClV7KeQIUsdTmYR9mgmMZen7DSRWauHW2UTynFg3Kkf6pw+XlxxOLsyLWwz/Q6q1lSpM3TQ==
|
||||
dependencies:
|
||||
"@tanstack/query-core" "^4.0.0-beta.1"
|
||||
"@types/use-sync-external-store" "^0.0.3"
|
||||
use-sync-external-store "^1.2.0"
|
||||
|
||||
"@testing-library/dom@^8.0.0":
|
||||
version "8.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.12.0.tgz#fef5e545533fb084175dda6509ee71d7d2f72e23"
|
||||
|
@ -2876,6 +2890,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
|
||||
integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==
|
||||
|
||||
"@types/use-sync-external-store@^0.0.3":
|
||||
version "0.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43"
|
||||
integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==
|
||||
|
||||
"@types/uuid@^8.3.4":
|
||||
version "8.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc"
|
||||
|
@ -11645,6 +11664,11 @@ use-latest@^1.2.1:
|
|||
dependencies:
|
||||
use-isomorphic-layout-effect "^1.1.1"
|
||||
|
||||
use-sync-external-store@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
|
||||
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
|
||||
|
||||
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
|
|
Loading…
Reference in a new issue