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 LoadGap from 'soapbox/components/load_gap';
|
||||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||||
import StatusContainer from 'soapbox/containers/status_container';
|
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 FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions';
|
||||||
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status';
|
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status';
|
||||||
import PendingStatus from 'soapbox/features/ui/components/pending_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 { 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';
|
||||||
|
|
||||||
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. */
|
||||||
|
@ -37,6 +41,8 @@ interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
||||||
timelineId?: string,
|
timelineId?: string,
|
||||||
/** Whether to display a gap or border between statuses in the list. */
|
/** Whether to display a gap or border between statuses in the list. */
|
||||||
divideType?: 'space' | 'border',
|
divideType?: 'space' | 'border',
|
||||||
|
/** Whether to display ads. */
|
||||||
|
showAds?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Feed of statuses, built atop ScrollableList. */
|
/** Feed of statuses, built atop ScrollableList. */
|
||||||
|
@ -49,8 +55,12 @@ const StatusList: React.FC<IStatusList> = ({
|
||||||
timelineId,
|
timelineId,
|
||||||
isLoading,
|
isLoading,
|
||||||
isPartial,
|
isPartial,
|
||||||
|
showAds = false,
|
||||||
...other
|
...other
|
||||||
}) => {
|
}) => {
|
||||||
|
const { data: ads } = useAds();
|
||||||
|
const soapboxConfig = useSoapboxConfig();
|
||||||
|
const adsInterval = Number(soapboxConfig.extensions.getIn(['ads', 'interval'], 40)) || 0;
|
||||||
const node = useRef<VirtuosoHandle>(null);
|
const node = useRef<VirtuosoHandle>(null);
|
||||||
|
|
||||||
const getFeaturedStatusCount = () => {
|
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 renderPendingStatus = (statusId: string) => {
|
||||||
const idempotencyKey = statusId.replace(/^末pending-/, '');
|
const idempotencyKey = statusId.replace(/^末pending-/, '');
|
||||||
|
|
||||||
|
@ -156,17 +175,27 @@ const StatusList: React.FC<IStatusList> = ({
|
||||||
|
|
||||||
const renderStatuses = (): React.ReactNode[] => {
|
const renderStatuses = (): React.ReactNode[] => {
|
||||||
if (isLoading || statusIds.size > 0) {
|
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) {
|
if (statusId === null) {
|
||||||
return renderLoadGap(index);
|
acc.push(renderLoadGap(index));
|
||||||
} else if (statusId.startsWith('末suggestions-')) {
|
} else if (statusId.startsWith('末suggestions-')) {
|
||||||
return renderFeedSuggestions();
|
acc.push(renderFeedSuggestions());
|
||||||
} else if (statusId.startsWith('末pending-')) {
|
} else if (statusId.startsWith('末pending-')) {
|
||||||
return renderPendingStatus(statusId);
|
acc.push(renderPendingStatus(statusId));
|
||||||
} else {
|
} else {
|
||||||
return renderStatus(statusId);
|
acc.push(renderStatus(statusId));
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
if (showAds && ad && showAd) {
|
||||||
|
acc.push(renderAd(ad));
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, [] as React.ReactNode[]);
|
||||||
} else {
|
} else {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,11 +32,13 @@ interface IStack extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
justifyContent?: 'center',
|
justifyContent?: 'center',
|
||||||
/** Extra class names on the <div> element. */
|
/** Extra class names on the <div> element. */
|
||||||
className?: string,
|
className?: string,
|
||||||
|
/** Whether to let the flexbox grow. */
|
||||||
|
grow?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vertical stack of child elements. */
|
/** Vertical stack of child elements. */
|
||||||
const Stack: React.FC<IStack> = (props) => {
|
const Stack: React.FC<IStack> = (props) => {
|
||||||
const { space, alignItems, justifyContent, className, ...filteredProps } = props;
|
const { space, alignItems, justifyContent, className, grow, ...filteredProps } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -48,6 +50,7 @@ const Stack: React.FC<IStack> = (props) => {
|
||||||
[alignItemsOptions[alignItems]]: typeof alignItems !== 'undefined',
|
[alignItemsOptions[alignItems]]: typeof alignItems !== 'undefined',
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
[justifyContentOptions[justifyContent]]: typeof justifyContent !== 'undefined',
|
[justifyContentOptions[justifyContent]]: typeof justifyContent !== 'undefined',
|
||||||
|
'flex-grow': grow,
|
||||||
}, className)}
|
}, className)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { IntlProvider } from 'react-intl';
|
import { IntlProvider } from 'react-intl';
|
||||||
|
@ -37,6 +38,7 @@ import {
|
||||||
useLocale,
|
useLocale,
|
||||||
} from 'soapbox/hooks';
|
} from 'soapbox/hooks';
|
||||||
import MESSAGES from 'soapbox/locales/messages';
|
import MESSAGES from 'soapbox/locales/messages';
|
||||||
|
import { queryClient } from 'soapbox/queries/client';
|
||||||
import { useCachedLocationHandler } from 'soapbox/utils/redirect';
|
import { useCachedLocationHandler } from 'soapbox/utils/redirect';
|
||||||
import { generateThemeCss } from 'soapbox/utils/theme';
|
import { generateThemeCss } from 'soapbox/utils/theme';
|
||||||
|
|
||||||
|
@ -281,11 +283,13 @@ const SoapboxHead: React.FC<ISoapboxHead> = ({ children }) => {
|
||||||
const Soapbox: React.FC = () => {
|
const Soapbox: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<SoapboxHead>
|
<QueryClientProvider client={queryClient}>
|
||||||
<SoapboxLoad>
|
<SoapboxHead>
|
||||||
<SoapboxMount />
|
<SoapboxLoad>
|
||||||
</SoapboxLoad>
|
<SoapboxMount />
|
||||||
</SoapboxHead>
|
</SoapboxLoad>
|
||||||
|
</SoapboxHead>
|
||||||
|
</QueryClientProvider>
|
||||||
</Provider>
|
</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);
|
expect(screen.queryAllByTestId('prev-page')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await user.click(screen.getByTestId('next-page'));
|
||||||
user.click(screen.getByTestId('next-page'));
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('prev-page')).toBeInTheDocument();
|
expect(screen.getByTestId('prev-page')).toBeInTheDocument();
|
||||||
|
|
|
@ -90,6 +90,7 @@ const HomeTimeline: React.FC = () => {
|
||||||
onLoadMore={handleLoadMore}
|
onLoadMore={handleLoadMore}
|
||||||
timelineId='home'
|
timelineId='home'
|
||||||
divideType='space'
|
divideType='space'
|
||||||
|
showAds
|
||||||
emptyMessage={
|
emptyMessage={
|
||||||
<Stack space={1}>
|
<Stack space={1}>
|
||||||
<Text size='xl' weight='medium' align='center'>
|
<Text size='xl' weight='medium' align='center'>
|
||||||
|
|
|
@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
import Blurhash from 'soapbox/components/blurhash';
|
import Blurhash from 'soapbox/components/blurhash';
|
||||||
import Icon from 'soapbox/components/icon';
|
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 { normalizeAttachment } from 'soapbox/normalizers';
|
||||||
|
|
||||||
import type { Card as CardEntity, Attachment } from 'soapbox/types/entities';
|
import type { Card as CardEntity, Attachment } from 'soapbox/types/entities';
|
||||||
|
@ -51,6 +51,7 @@ interface ICard {
|
||||||
compact?: boolean,
|
compact?: boolean,
|
||||||
defaultWidth?: number,
|
defaultWidth?: number,
|
||||||
cacheWidth?: (width: number) => void,
|
cacheWidth?: (width: number) => void,
|
||||||
|
horizontal?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
const Card: React.FC<ICard> = ({
|
const Card: React.FC<ICard> = ({
|
||||||
|
@ -61,6 +62,7 @@ const Card: React.FC<ICard> = ({
|
||||||
compact = false,
|
compact = false,
|
||||||
cacheWidth,
|
cacheWidth,
|
||||||
onOpenMedia,
|
onOpenMedia,
|
||||||
|
horizontal,
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const [width, setWidth] = useState(defaultWidth);
|
const [width, setWidth] = useState(defaultWidth);
|
||||||
const [embedded, setEmbedded] = useState(false);
|
const [embedded, setEmbedded] = useState(false);
|
||||||
|
@ -132,7 +134,7 @@ const Card: React.FC<ICard> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const interactive = card.type !== 'link';
|
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 className = classnames('status-card', { horizontal, compact, interactive }, `status-card--${card.type}`);
|
||||||
const ratio = getRatio(card);
|
const ratio = getRatio(card);
|
||||||
const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
|
const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
|
||||||
|
@ -140,24 +142,34 @@ const Card: React.FC<ICard> = ({
|
||||||
const title = interactive ? (
|
const title = interactive ? (
|
||||||
<a
|
<a
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className='status-card__title'
|
|
||||||
href={card.url}
|
href={card.url}
|
||||||
title={trimmedTitle}
|
title={trimmedTitle}
|
||||||
rel='noopener'
|
rel='noopener'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
>
|
>
|
||||||
<strong>{trimmedTitle}</strong>
|
<span>{trimmedTitle}</span>
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<strong className='status-card__title' title={trimmedTitle}>{trimmedTitle}</strong>
|
<span title={trimmedTitle}>{trimmedTitle}</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
const description = (
|
const description = (
|
||||||
<div className='status-card__content'>
|
<Stack space={2} className='flex-1 overflow-hidden p-4'>
|
||||||
<span className='status-card__title'>{title}</span>
|
{trimmedTitle && (
|
||||||
<p className='status-card__description'>{trimmedDescription}</p>
|
<Text weight='bold'>{title}</Text>
|
||||||
<span className='status-card__host'><Icon src={require('@tabler/icons/link.svg')} /> {card.provider_name}</span>
|
)}
|
||||||
</div>
|
{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 = '';
|
let embed: React.ReactNode = '';
|
||||||
|
@ -234,7 +246,15 @@ const Card: React.FC<ICard> = ({
|
||||||
);
|
);
|
||||||
} else if (card.image) {
|
} else if (card.image) {
|
||||||
embed = (
|
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}
|
{canvas}
|
||||||
{thumbnail}
|
{thumbnail}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { configureMockStore } from '@jedmao/redux-mock-store';
|
import { configureMockStore } from '@jedmao/redux-mock-store';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { render, RenderOptions } from '@testing-library/react';
|
import { render, RenderOptions } from '@testing-library/react';
|
||||||
import { merge } from 'immutable';
|
import { merge } from 'immutable';
|
||||||
import React, { FC, ReactElement } from 'react';
|
import React, { FC, ReactElement } from 'react';
|
||||||
|
@ -9,6 +10,8 @@ import { Action, applyMiddleware, createStore } from 'redux';
|
||||||
import thunk from 'redux-thunk';
|
import thunk from 'redux-thunk';
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
import { queryClient } from 'soapbox/queries/client';
|
||||||
|
|
||||||
import NotificationsContainer from '../features/ui/containers/notifications_container';
|
import NotificationsContainer from '../features/ui/containers/notifications_container';
|
||||||
import { default as rootReducer } from '../reducers';
|
import { default as rootReducer } from '../reducers';
|
||||||
|
|
||||||
|
@ -45,13 +48,15 @@ const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Provider store={props.store}>
|
<Provider store={props.store}>
|
||||||
<IntlProvider locale={props.locale}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter {...routerProps}>
|
<IntlProvider locale={props.locale}>
|
||||||
{children}
|
<MemoryRouter {...routerProps}>
|
||||||
|
{children}
|
||||||
|
|
||||||
<NotificationsContainer />
|
<NotificationsContainer />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -20,4 +20,5 @@ export { StatusRecord, normalizeStatus } from './status';
|
||||||
export { StatusEditRecord, normalizeStatusEdit } from './status_edit';
|
export { StatusEditRecord, normalizeStatusEdit } from './status_edit';
|
||||||
export { TagRecord, normalizeTag } from './tag';
|
export { TagRecord, normalizeTag } from './tag';
|
||||||
|
|
||||||
|
export { AdRecord, normalizeAd } from './soapbox/ad';
|
||||||
export { SoapboxConfigRecord, normalizeSoapboxConfig } from './soapbox/soapbox_config';
|
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 { toTailwind } from 'soapbox/utils/tailwind';
|
||||||
import { generateAccent } from 'soapbox/utils/theme';
|
import { generateAccent } from 'soapbox/utils/theme';
|
||||||
|
|
||||||
|
import { normalizeAd } from './ad';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
Ad,
|
||||||
PromoPanelItem,
|
PromoPanelItem,
|
||||||
FooterItem,
|
FooterItem,
|
||||||
CryptoAddress,
|
CryptoAddress,
|
||||||
|
@ -66,6 +69,7 @@ export const CryptoAddressRecord = ImmutableRecord({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SoapboxConfigRecord = ImmutableRecord({
|
export const SoapboxConfigRecord = ImmutableRecord({
|
||||||
|
ads: ImmutableList<Ad>(),
|
||||||
appleAppId: null,
|
appleAppId: null,
|
||||||
logo: '',
|
logo: '',
|
||||||
logoDarkMode: null,
|
logoDarkMode: null,
|
||||||
|
@ -110,6 +114,11 @@ export const SoapboxConfigRecord = ImmutableRecord({
|
||||||
|
|
||||||
type SoapboxConfigMap = ImmutableMap<string, any>;
|
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 => {
|
const normalizeCryptoAddress = (address: unknown): CryptoAddress => {
|
||||||
return CryptoAddressRecord(ImmutableMap(fromJS(address))).update('ticker', ticker => {
|
return CryptoAddressRecord(ImmutableMap(fromJS(address))).update('ticker', ticker => {
|
||||||
return trimStart(ticker, '$').toLowerCase();
|
return trimStart(ticker, '$').toLowerCase();
|
||||||
|
@ -175,6 +184,7 @@ export const normalizeSoapboxConfig = (soapboxConfig: Record<string, any>) => {
|
||||||
normalizeFooterLinks(soapboxConfig);
|
normalizeFooterLinks(soapboxConfig);
|
||||||
maybeAddMissingColors(soapboxConfig);
|
maybeAddMissingColors(soapboxConfig);
|
||||||
normalizeCryptoAddresses(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 {
|
import {
|
||||||
PromoPanelItemRecord,
|
PromoPanelItemRecord,
|
||||||
FooterItemRecord,
|
FooterItemRecord,
|
||||||
|
@ -7,6 +8,7 @@ import {
|
||||||
|
|
||||||
type Me = string | null | false | undefined;
|
type Me = string | null | false | undefined;
|
||||||
|
|
||||||
|
type Ad = ReturnType<typeof AdRecord>;
|
||||||
type PromoPanelItem = ReturnType<typeof PromoPanelItemRecord>;
|
type PromoPanelItem = ReturnType<typeof PromoPanelItemRecord>;
|
||||||
type FooterItem = ReturnType<typeof FooterItemRecord>;
|
type FooterItem = ReturnType<typeof FooterItemRecord>;
|
||||||
type CryptoAddress = ReturnType<typeof CryptoAddressRecord>;
|
type CryptoAddress = ReturnType<typeof CryptoAddressRecord>;
|
||||||
|
@ -14,6 +16,7 @@ type SoapboxConfig = ReturnType<typeof SoapboxConfigRecord>;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Me,
|
Me,
|
||||||
|
Ad,
|
||||||
PromoPanelItem,
|
PromoPanelItem,
|
||||||
FooterItem,
|
FooterItem,
|
||||||
CryptoAddress,
|
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 {
|
.status-card__host {
|
||||||
@apply text-primary-600 dark:text-accent-blue;
|
@apply text-primary-600 dark:text-accent-blue;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -338,6 +326,7 @@ a.status-card {
|
||||||
flex: 0 0 40%;
|
flex: 0 0 40%;
|
||||||
background: var(--brand-color--med);
|
background: var(--brand-color--med);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
& > .svg-icon {
|
& > .svg-icon {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
|
@ -378,10 +367,6 @@ a.status-card {
|
||||||
@apply flex flex-col md:flex-row;
|
@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 {
|
.material-status {
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
|
|
||||||
|
|
|
@ -70,6 +70,7 @@
|
||||||
"@tabler/icons": "^1.73.0",
|
"@tabler/icons": "^1.73.0",
|
||||||
"@tailwindcss/forms": "^0.4.0",
|
"@tailwindcss/forms": "^0.4.0",
|
||||||
"@tailwindcss/typography": "^0.5.1",
|
"@tailwindcss/typography": "^0.5.1",
|
||||||
|
"@tanstack/react-query": "^4.0.10",
|
||||||
"@testing-library/react": "^12.1.4",
|
"@testing-library/react": "^12.1.4",
|
||||||
"@types/escape-html": "^1.0.1",
|
"@types/escape-html": "^1.0.1",
|
||||||
"@types/http-link-header": "^1.0.3",
|
"@types/http-link-header": "^1.0.3",
|
||||||
|
|
24
yarn.lock
24
yarn.lock
|
@ -2296,6 +2296,20 @@
|
||||||
lodash.isplainobject "^4.0.6"
|
lodash.isplainobject "^4.0.6"
|
||||||
lodash.merge "^4.6.2"
|
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":
|
"@testing-library/dom@^8.0.0":
|
||||||
version "8.12.0"
|
version "8.12.0"
|
||||||
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.12.0.tgz#fef5e545533fb084175dda6509ee71d7d2f72e23"
|
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"
|
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
|
||||||
integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==
|
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":
|
"@types/uuid@^8.3.4":
|
||||||
version "8.3.4"
|
version "8.3.4"
|
||||||
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc"
|
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc"
|
||||||
|
@ -11645,6 +11664,11 @@ use-latest@^1.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
use-isomorphic-layout-effect "^1.1.1"
|
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:
|
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||||
|
|
Loading…
Reference in a new issue