diff --git a/.eslintrc.js b/.eslintrc.js index 0ff74336c..7fa666e36 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -278,6 +278,7 @@ module.exports = { files: ['**/*.ts', '**/*.tsx'], rules: { 'no-undef': 'off', // https://stackoverflow.com/a/69155899 + 'consistent-return': 'off', }, parser: '@typescript-eslint/parser', }, diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 05cbd036f..db9695b87 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/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index 9ecb4a104..984bae782 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -32,11 +32,13 @@ interface IStack extends React.HTMLAttributes { justifyContent?: 'center', /** Extra class names on the
element. */ className?: string, + /** Whether to let the flexbox grow. */ + grow?: boolean, } /** Vertical stack of child elements. */ const Stack: React.FC = (props) => { - const { space, alignItems, justifyContent, className, ...filteredProps } = props; + const { space, alignItems, justifyContent, className, grow, ...filteredProps } = props; return (
= (props) => { [alignItemsOptions[alignItems]]: typeof alignItems !== 'undefined', // @ts-ignore [justifyContentOptions[justifyContent]]: typeof justifyContent !== 'undefined', + 'flex-grow': grow, }, className)} /> ); diff --git a/app/soapbox/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index f145ef890..220e22af4 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -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 = ({ children }) => { const Soapbox: React.FC = () => { return ( - - - - - + + + + + + + ); }; diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx new file mode 100644 index 000000000..1556d56e5 --- /dev/null +++ b/app/soapbox/features/ads/components/ad.tsx @@ -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 = ({ card, impression }) => { + const instance = useAppSelector(state => state.instance); + + const infobox = useRef(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 ( +
+ + + + + + + + + {instance.title} + + + + + + + + + + + + + + + + + + + + {}} horizontal /> + + + + {showInfo && ( +
+ + + + + + + + + + + +
+ )} +
+ ); +}; + +export default Ad; diff --git a/app/soapbox/features/ads/providers/index.ts b/app/soapbox/features/ads/providers/index.ts new file mode 100644 index 000000000..65e593985 --- /dev/null +++ b/app/soapbox/features/ads/providers/index.ts @@ -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 Promise> = { + 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, +} + +/** 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 => { + 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/ads/providers/rumble.ts b/app/soapbox/features/ads/providers/rumble.ts new file mode 100644 index 000000000..b39ee4cc9 --- /dev/null +++ b/app/soapbox/features/ads/providers/rumble.ts @@ -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; diff --git a/app/soapbox/features/ads/providers/soapbox-config.ts b/app/soapbox/features/ads/providers/soapbox-config.ts new file mode 100644 index 000000000..21163729c --- /dev/null +++ b/app/soapbox/features/ads/providers/soapbox-config.ts @@ -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; diff --git a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx index e01a32e9e..350cf385f 100644 --- a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx +++ b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx @@ -137,9 +137,7 @@ describe('', () => { 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(); 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/features/status/components/card.tsx b/app/soapbox/features/status/components/card.tsx index 4138a817f..fbbe0648d 100644 --- a/app/soapbox/features/status/components/card.tsx +++ b/app/soapbox/features/status/components/card.tsx @@ -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 = ({ @@ -61,6 +62,7 @@ const Card: React.FC = ({ 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 = ({ }; 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 = ({ const title = interactive ? ( e.stopPropagation()} - className='status-card__title' href={card.url} title={trimmedTitle} rel='noopener' target='_blank' > - {trimmedTitle} + {trimmedTitle} ) : ( - {trimmedTitle} + {trimmedTitle} ); const description = ( -
- {title} -

{trimmedDescription}

- {card.provider_name} -
+ + {trimmedTitle && ( + {title} + )} + {trimmedDescription && ( + {trimmedDescription} + )} + + + + + + {card.provider_name} + + + ); let embed: React.ReactNode = ''; @@ -234,7 +246,15 @@ const Card: React.FC = ({ ); } else if (card.image) { embed = ( -
+
{canvas} {thumbnail}
diff --git a/app/soapbox/jest/test-helpers.tsx b/app/soapbox/jest/test-helpers.tsx index 657919b5a..b7223caca 100644 --- a/app/soapbox/jest/test-helpers.tsx +++ b/app/soapbox/jest/test-helpers.tsx @@ -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 = ({ children, storeProps, routerProps = {} }) => { return ( - - - {children} + + + + {children} - - - + + + + ); }; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index d25cbd014..9fdc04f68 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -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'; diff --git a/app/soapbox/normalizers/soapbox/ad.ts b/app/soapbox/normalizers/soapbox/ad.ts new file mode 100644 index 000000000..c29ee9a3e --- /dev/null +++ b/app/soapbox/normalizers/soapbox/ad.ts @@ -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) => { + const map = ImmutableMap(fromJS(ad)); + const card = normalizeCard(map.get('card')); + return AdRecord(map.set('card', card)); +}; diff --git a/app/soapbox/normalizers/soapbox/soapbox_config.ts b/app/soapbox/normalizers/soapbox/soapbox_config.ts index eab311266..bf4f95eef 100644 --- a/app/soapbox/normalizers/soapbox/soapbox_config.ts +++ b/app/soapbox/normalizers/soapbox/soapbox_config.ts @@ -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(), appleAppId: null, logo: '', logoDarkMode: null, @@ -110,6 +114,11 @@ export const SoapboxConfigRecord = ImmutableRecord({ type SoapboxConfigMap = ImmutableMap; +const normalizeAds = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => { + const ads = ImmutableList>(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) => { normalizeFooterLinks(soapboxConfig); maybeAddMissingColors(soapboxConfig); normalizeCryptoAddresses(soapboxConfig); + normalizeAds(soapboxConfig); }), ); }; 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: [], + }); +} diff --git a/app/soapbox/queries/client.ts b/app/soapbox/queries/client.ts new file mode 100644 index 000000000..d772e9288 --- /dev/null +++ b/app/soapbox/queries/client.ts @@ -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 }; diff --git a/app/soapbox/types/soapbox.ts b/app/soapbox/types/soapbox.ts index 32c2f681c..1a37d1a88 100644 --- a/app/soapbox/types/soapbox.ts +++ b/app/soapbox/types/soapbox.ts @@ -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; type PromoPanelItem = ReturnType; type FooterItem = ReturnType; type CryptoAddress = ReturnType; @@ -14,6 +16,7 @@ type SoapboxConfig = ReturnType; export { Me, + Ad, PromoPanelItem, FooterItem, CryptoAddress, diff --git a/app/styles/components/status.scss b/app/styles/components/status.scss index 9f47c5f13..45abd605e 100644 --- a/app/styles/components/status.scss +++ b/app/styles/components/status.scss @@ -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; diff --git a/package.json b/package.json index a34f1b539..3c8ccb13f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 4469a177a..6677785fd 100644 --- a/yarn.lock +++ b/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"