Merge branch 'ads' into 'develop'

Support ads in feed

See merge request soapbox-pub/soapbox-fe!1698
This commit is contained in:
Alex Gleason 2022-08-03 17:15:10 +00:00
commit 7b355dea6e
21 changed files with 402 additions and 48 deletions

Binary file not shown.

View file

@ -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 [];
}

View file

@ -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)}
/>
);

View file

@ -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}>
<QueryClientProvider client={queryClient}>
<SoapboxHead>
<SoapboxLoad>
<SoapboxMount />
</SoapboxLoad>
</SoapboxHead>
</QueryClientProvider>
</Provider>
);
};

View 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;

View 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 };

View 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;

View 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;

View file

@ -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();

View file

@ -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'>

View file

@ -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>

View file

@ -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,6 +48,7 @@ const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
return (
<Provider store={props.store}>
<QueryClientProvider client={queryClient}>
<IntlProvider locale={props.locale}>
<MemoryRouter {...routerProps}>
{children}
@ -52,6 +56,7 @@ const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
<NotificationsContainer />
</MemoryRouter>
</IntlProvider>
</QueryClientProvider>
</Provider>
);
};

View file

@ -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';

View 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));
};

View file

@ -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);
}),
);
};

View 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: [],
});
}

View 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 };

View file

@ -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,

View file

@ -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;

View file

@ -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",

View file

@ -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"