0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-08 02:52:39 -05:00

Updated search to be dynamic in admin-x-activitypub app (#21099)

refs
[AP-352](https://linear.app/tryghost/issue/AP-352/search-for-mastodon-usernames-in-ghost-admin)

Updated the search functionality in the admin-x-activitypub app to be
dynamic and utilise the search API endpoint provided by the activitypub
service

---------

Co-authored-by: Djordje Vlaisavljevic <dzvlais@gmail.com>
This commit is contained in:
Michael Barrett 2024-09-30 11:32:05 +01:00 committed by GitHub
parent e2bf950b63
commit 8fa9fb9c25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 576 additions and 87 deletions

View file

@ -31,15 +31,10 @@
"devDependencies": {
"@playwright/test": "1.46.1",
"@testing-library/react": "14.3.1",
"@tryghost/admin-x-design-system": "0.0.0",
"@tryghost/admin-x-framework": "0.0.0",
"@types/jest": "29.5.12",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"@radix-ui/react-form": "0.0.3",
"jest": "29.7.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"ts-jest": "29.1.5"
},
"nx": {
@ -67,5 +62,13 @@
]
}
}
},
"dependencies": {
"@radix-ui/react-form": "0.0.3",
"use-debounce": "10.0.3",
"@tryghost/admin-x-design-system": "0.0.0",
"@tryghost/admin-x-framework": "0.0.0",
"react": "18.3.1",
"react-dom": "18.3.1"
}
}

View file

@ -580,4 +580,47 @@ describe('ActivityPubAPI', function () {
expect(actual).toEqual(expected);
});
});
describe('search', function () {
test('It returns the results of the search', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
'https://activitypub.api/.ghost/activitypub/actions/search?query=%40foo%40bar.baz': {
response: JSONResponse({
profiles: [
{
handle: '@foo@bar.baz',
name: 'Foo Bar'
}
]
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.search('@foo@bar.baz');
const expected = {
profiles: [
{
handle: '@foo@bar.baz',
name: 'Foo Bar'
}
]
};
expect(actual).toEqual(expected);
});
});
});

View file

@ -3,6 +3,18 @@ export type Actor = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Activity = any;
export interface ProfileSearchResult {
actor: Actor;
handle: string;
followerCount: number;
isFollowing: boolean;
posts: Activity[];
}
export interface SearchResults {
profiles: ProfileSearchResult[];
}
export class ActivityPubAPI {
constructor(
private readonly apiUrl: URL,
@ -280,4 +292,24 @@ export class ActivityPubAPI {
const json = await this.fetchJSON(this.userApiUrl);
return json;
}
get searchApiUrl() {
return new URL('.ghost/activitypub/actions/search', this.apiUrl);
}
async search(query: string): Promise<SearchResults> {
const url = this.searchApiUrl;
url.searchParams.set('query', query);
const json = await this.fetchJSON(url, 'GET');
if (json && 'profiles' in json) {
return json as SearchResults;
}
return {
profiles: []
};
}
}

View file

@ -1,10 +1,12 @@
import NiceModal from '@ebay/nice-modal-react';
import React, {useEffect, useRef} from 'react';
import {Button, LoadingIndicator, NoValueLabel} from '@tryghost/admin-x-design-system';
import NiceModal from '@ebay/nice-modal-react';
import {LoadingIndicator, NoValueLabel} from '@tryghost/admin-x-design-system';
import APAvatar, {AvatarBadge} from './global/APAvatar';
import ActivityItem, {type Activity} from './activities/ActivityItem';
import ArticleModal from './feed/ArticleModal';
import FollowButton from './global/FollowButton';
import MainNavigation from './navigation/MainNavigation';
import getUsername from '../utils/get-username';
@ -171,13 +173,12 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
<div className=''>{getActivityDescription(activity)}</div>
{getExtendedDescription(activity)}
</div>
{isFollower(activity.actor.id) === false && (
<Button className='ml-auto' label='Follow' link onClick={(e) => {
e?.preventDefault();
alert('Implement me!');
}} />
)}
<FollowButton
className='ml-auto'
following={isFollower(activity.actor.id)}
handle={getUsername(activity.actor)}
type='link'
/>
</ActivityItem>
))}
</div>

View file

@ -1,52 +1,234 @@
import React, {useEffect, useRef, useState} from 'react';
import {Activity, ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Icon, LoadingIndicator, NoValueLabel, TextField} from '@tryghost/admin-x-design-system';
import {useDebounce} from 'use-debounce';
import APAvatar from './global/APAvatar';
import ActivityItem from './activities/ActivityItem';
import FollowButton from './global/FollowButton';
import MainNavigation from './navigation/MainNavigation';
import React from 'react';
import {Button, Icon} from '@tryghost/admin-x-design-system';
import NiceModal from '@ebay/nice-modal-react';
import ProfileSearchResultModal from './search/ProfileSearchResultModal';
import {useSearchForUser} from '../hooks/useActivityPubQueries';
interface SearchResultItem {
actor: ActorProperties;
handle: string;
followerCount: number;
isFollowing: boolean;
posts: Activity[];
}
interface SearchResultProps {
result: SearchResultItem;
update: (id: string, updated: Partial<SearchResultItem>) => void;
}
interface SearchProps {}
const SearchResult: React.FC<SearchResultProps> = ({result, update}) => {
const onFollow = () => {
update(result.actor.id!, {
isFollowing: true,
followerCount: result.followerCount + 1
});
};
const onUnfollow = () => {
update(result.actor.id!, {
isFollowing: false,
followerCount: result.followerCount - 1
});
};
return (
<ActivityItem
key={result.actor.id}
onClick={() => {
NiceModal.show(ProfileSearchResultModal, {profile: result, onFollow, onUnfollow});
}}
>
<APAvatar author={result.actor}/>
<div>
<div className='text-grey-600'>
<span className='font-bold text-black'>{result.actor.name} </span>{result.handle}
</div>
<div className='text-sm'>{new Intl.NumberFormat().format(result.followerCount)} followers</div>
</div>
<FollowButton
className='ml-auto'
following={result.isFollowing}
handle={result.handle}
type='link'
onFollow={onFollow}
onUnfollow={onUnfollow}
/>
</ActivityItem>
);
};
const Search: React.FC<SearchProps> = ({}) => {
// Initialise suggested profiles
const [suggested, setSuggested] = useState<SearchResultItem[]>([
{
actor: {
id: 'https://mastodon.social/@quillmatiq',
name: 'Anuj Ahooja',
preferredUsername: '@quillmatiq@mastodon.social',
image: {
url: 'https://anujahooja.com/assets/images/image12.jpg?v=601ebe30'
},
icon: {
url: 'https://anujahooja.com/assets/images/image12.jpg?v=601ebe30'
}
} as ActorProperties,
handle: '@quillmatiq@mastodon.social',
followerCount: 436,
isFollowing: false,
posts: []
},
{
actor: {
id: 'https://flipboard.social/@miaq',
name: 'Mia Quagliarello',
preferredUsername: '@miaq@flipboard.social',
image: {
url: 'https://m-cdn.flipboard.social/accounts/avatars/109/824/428/955/351/328/original/383f288b81ab280c.png'
},
icon: {
url: 'https://m-cdn.flipboard.social/accounts/avatars/109/824/428/955/351/328/original/383f288b81ab280c.png'
}
} as ActorProperties,
handle: '@miaq@flipboard.social',
followerCount: 533,
isFollowing: false,
posts: []
},
{
actor: {
id: 'https://techpolicy.social/@mallory',
name: 'Mallory',
preferredUsername: '@mallory@techpolicy.social',
image: {
url: 'https://techpolicy.social/system/accounts/avatars/109/378/338/180/403/396/original/20b043b0265cac73.jpeg'
},
icon: {
url: 'https://techpolicy.social/system/accounts/avatars/109/378/338/180/403/396/original/20b043b0265cac73.jpeg'
}
} as ActorProperties,
handle: '@mallory@techpolicy.social',
followerCount: 1100,
isFollowing: false,
posts: []
}
]);
const updateSuggested = (id: string, updated: Partial<SearchResultItem>) => {
const index = suggested.findIndex(result => result.actor.id === id);
setSuggested((current) => {
const newSuggested = [...current];
newSuggested[index] = {...newSuggested[index], ...updated};
return newSuggested;
});
};
// Initialise search query
const queryInputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState('');
const [debouncedQuery] = useDebounce(query, 300);
const [isQuerying, setIsQuerying] = useState(false);
const {searchQuery, updateProfileSearchResult: updateResult} = useSearchForUser('index', query !== '' ? debouncedQuery : query);
const {data, isFetching, isFetched} = searchQuery;
const results = data?.profiles || [];
const showLoading = (isFetching || isQuerying) && !isFetched;
const showNoResults = isFetched && results.length === 0;
const showSuggested = query === '' || (isFetched && results.length === 0);
useEffect(() => {
if (query !== '') {
setIsQuerying(true);
} else {
setIsQuerying(false);
}
}, [query]);
// Focus the query input on initial render
useEffect(() => {
if (queryInputRef.current) {
queryInputRef.current.focus();
}
}, []);
return (
<>
<MainNavigation title='Search' />
<div className='z-0 flex w-full flex-col items-center pt-8'>
<div className='mb-6 flex w-full max-w-[560px] items-center gap-2 rounded-full bg-grey-100 px-3 py-2 text-grey-500'><Icon name='magnifying-glass' size={18} />Search the Fediverse</div>
<ActivityItem>
<APAvatar/>
<div>
<div className='text-grey-600'><span className='font-bold text-black'>Lydia Mango</span> @username@domain.com</div>
<div className='text-sm'>1,535 followers</div>
</div>
<Button className='ml-auto' label='Follow' link />
</ActivityItem>
<ActivityItem>
<APAvatar/>
<div>
<div className='text-grey-600'><span className='font-bold text-black'>Tiana Passaquindici Arcand</span> @username@domain.com</div>
<div className='text-sm'>4,545 followers</div>
</div>
<Button className='ml-auto' label='Follow' link />
</ActivityItem>
<ActivityItem>
<APAvatar/>
<div>
<div className='text-grey-600'><span className='font-bold text-black'>Gretchen Press</span> @username@domain.com</div>
<div className='text-sm'>1,156 followers</div>
</div>
<Button className='ml-auto' label='Follow' link />
</ActivityItem>
<ActivityItem>
<APAvatar/>
<div>
<div className='text-grey-600'><span className='font-bold text-black'>Leo Lubin</span> @username@domain.com</div>
<div className='text-sm'>1,584 followers</div>
</div>
<Button className='ml-auto' label='Follow' link />
</ActivityItem>
<div className='relative flex w-full max-w-[560px] items-center '>
<Icon className='absolute left-3 top-3 z-10' colorClass='text-grey-500' name='magnifying-glass' size='sm' />
<TextField
className='mb-6 mr-12 flex h-10 w-full items-center rounded-lg border border-transparent bg-grey-100 px-[33px] py-1.5 transition-colors focus:border-green focus:bg-white focus:outline-2 dark:border-transparent dark:bg-grey-925 dark:text-white dark:placeholder:text-grey-800 dark:focus:border-green dark:focus:bg-grey-950 tablet:mr-0'
containerClassName='w-100'
inputRef={queryInputRef}
placeholder='Enter a username...'
title="Search"
type='text'
value={query}
clearBg
hideTitle
unstyled
onChange={e => setQuery(e.target.value)}
/>
{query && (
<Button
className='absolute top-3 p-1 sm:right-14 tablet:right-3'
icon='close'
iconColorClass='text-grey-700 !w-[10px] !h-[10px]'
size='sm'
unstyled
onClick={() => {
setQuery('');
queryInputRef.current?.focus();
}}
/>
)}
</div>
{showLoading && (
<LoadingIndicator size='lg'/>
)}
{showNoResults && (
<NoValueLabel icon='user'>
No users matching this username
</NoValueLabel>
)}
{results.map(result => (
<SearchResult
key={(result as SearchResultItem).actor.id}
result={result as SearchResultItem}
update={updateResult}
/>
))}
{showSuggested && (
<>
<span className='mb-1 flex w-full max-w-[560px] font-semibold'>Suggested accounts</span>
{suggested.map(profile => (
<SearchResult
key={profile.actor.id}
result={profile}
update={updateSuggested}
/>
))}
</>
)}
</div>
</>
);
};
export default Search;
export default Search;

View file

@ -21,7 +21,7 @@ const ActivityItem: React.FC<ActivityItemProps> = ({children, url = null, onClic
const childrenArray = React.Children.toArray(children);
const Item = (
<div className='flex w-full max-w-[560px] flex-col hover:bg-grey-75' onClick={() => {
<div className='flex w-full max-w-[560px] cursor-pointer flex-col hover:bg-grey-75' onClick={() => {
if (!url && onClick) {
onClick();
}

View file

@ -15,7 +15,7 @@ const APAvatar: React.FC<APAvatarProps> = ({author, size, badge}) => {
let iconSize = 18;
let containerClass = '';
let imageClass = 'z-10 rounded w-10 h-10 object-cover';
const badgeClass = `w-6 h-6 rounded-full absolute -bottom-2 -right-2 border-2 border-white content-box flex items-center justify-center `;
const badgeClass = `w-6 h-6 z-20 rounded-full absolute -bottom-2 -right-2 border-2 border-white content-box flex items-center justify-center `;
let badgeColor = '';
const [iconUrl, setIconUrl] = useState(author?.icon?.url);
@ -30,31 +30,32 @@ const APAvatar: React.FC<APAvatarProps> = ({author, size, badge}) => {
badgeColor = ' bg-purple-500';
break;
}
switch (size) {
case 'xs':
iconSize = 12;
containerClass = 'z-10 rounded bg-grey-100 flex items-center justify-center p-[3px] w-6 h-6';
containerClass = 'z-10 relative rounded bg-grey-100 shrink-0 flex items-center justify-center w-6 h-6';
imageClass = 'z-10 rounded w-6 h-6 object-cover';
break;
case 'sm':
containerClass = 'z-10 rounded bg-grey-100 flex items-center justify-center p-[10px] w-10 h-10';
containerClass = 'z-10 relative rounded bg-grey-100 shrink-0 flex items-center justify-center w-10 h-10';
break;
case 'lg':
containerClass = 'z-10 rounded bg-grey-100 flex items-center justify-center p-[10px] w-22 h-22';
containerClass = 'z-10 relative rounded-xl bg-grey-100 shrink-0 flex items-center justify-center w-22 h-22';
imageClass = 'z-10 rounded-xl w-22 h-22 object-cover';
break;
default:
containerClass = 'z-10 rounded bg-grey-100 flex items-center justify-center p-[10px] w-10 h-10';
containerClass = 'z-10 relative rounded bg-grey-100 shrink-0 flex items-center justify-center w-10 h-10';
break;
}
if (iconUrl) {
return (
<a className='relative z-10 h-10 w-10 shrink-0 pt-[3px] transition-opacity hover:opacity-80' href={author?.url} rel='noopener noreferrer' target='_blank'>
<a className={containerClass} href={author?.url} rel='noopener noreferrer' target='_blank'>
<img
className={imageClass}
src={iconUrl}
onError={() => setIconUrl(null)}
onError={() => setIconUrl(undefined)}
/>
{badge && (
<div className={`${badgeClass} ${badgeColor}`}>

View file

@ -0,0 +1,70 @@
import {useEffect, useState} from 'react';
import {Button} from '@tryghost/admin-x-design-system';
import {useFollow} from '../../hooks/useActivityPubQueries';
interface FollowButtonProps {
className?: string;
following: boolean;
handle: string;
type?: 'button' | 'link';
onFollow?: () => void;
onUnfollow?: () => void;
}
const noop = () => {};
const FollowButton: React.FC<FollowButtonProps> = ({
className,
following,
handle,
type = 'button',
onFollow = noop,
onUnfollow = noop
}) => {
const [isFollowing, setIsFollowing] = useState(following);
const mutation = useFollow('index',
noop,
() => {
setIsFollowing(false);
onUnfollow();
}
);
const handleClick = async () => {
if (isFollowing) {
setIsFollowing(false);
onUnfollow();
// @TODO: Implement unfollow mutation
} else {
setIsFollowing(true);
onFollow();
mutation.mutate(handle);
}
};
useEffect(() => {
setIsFollowing(following);
}, [following]);
return (
<Button
className={className}
color='black'
label={isFollowing ? 'Following' : 'Follow'}
link={type === 'link'}
onClick={(event) => {
event?.preventDefault();
event?.stopPropagation();
handleClick();
}}
/>
);
};
export default FollowButton;

View file

@ -1,28 +1,10 @@
import NiceModal from '@ebay/nice-modal-react';
import {ActivityPubAPI} from '../../api/activitypub';
import {Modal, TextField, showToast} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useMutation} from '@tanstack/react-query';
import {useRouting} from '@tryghost/admin-x-framework/routing';
import {useState} from 'react';
function useFollow(handle: string, onSuccess: () => void, onError: () => void) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useMutation({
async mutationFn(username: string) {
return api.follow(username);
},
onSuccess,
onError
});
}
import NiceModal from '@ebay/nice-modal-react';
import {Modal, TextField, showToast} from '@tryghost/admin-x-design-system';
import {useRouting} from '@tryghost/admin-x-framework/routing';
import {useFollow} from '../../hooks/useActivityPubQueries';
const FollowSite = NiceModal.create(() => {
const {updateRoute} = useRouting();

View file

@ -0,0 +1,113 @@
import React from 'react';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import {Activity, ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Heading, Modal} from '@tryghost/admin-x-design-system';
import APAvatar from '../global/APAvatar';
import FeedItem from '../feed/FeedItem';
import FollowButton from '../global/FollowButton';
interface ProfileSearchResultModalProps {
profile: {
actor: ActorProperties;
handle: string;
isFollowing: boolean;
posts: Activity[];
};
onFollow: () => void;
onUnfollow: () => void;
}
const noop = () => {};
const ProfileSearchResultModal: React.FC<ProfileSearchResultModalProps> = ({
profile,
onFollow = noop,
onUnfollow = noop
}) => {
const modal = useModal();
const attachments = (profile.actor.attachment || [])
.filter(attachment => attachment.type === 'PropertyValue');
const posts = profile.posts; // @TODO: Do any filtering / manipulation here
return (
<Modal
align='right'
animate={true}
footer={<></>}
height={'full'}
padding={false}
size='bleed'
width={640}
>
<div className='sticky top-0 z-50 border-grey-200 bg-white py-3'>
<div className='grid h-8 grid-cols-3'>
<div className='col-[3/4] flex items-center justify-end space-x-6 px-8'>
<Button icon='close' size='sm' unstyled onClick={() => modal.remove()}/>
</div>
</div>
</div>
<div className='z-0 mx-auto mt-4 flex w-full max-w-[580px] flex-col items-center pb-16'>
<div className='mx-auto w-full'>
{profile.actor.image && (<div className='h-[200px] w-full overflow-hidden rounded-lg bg-gradient-to-tr from-grey-200 to-grey-100'>
<img
alt={profile.actor.name}
className='h-full w-full object-cover'
src={profile.actor.image.url}
/>
</div>)}
<div className={`${profile.actor.image && '-mt-12'} px-4`}>
<div className='flex items-end justify-between'>
<div className='rounded-xl outline outline-4 outline-white'>
<APAvatar
author={profile.actor}
size='lg'
/>
</div>
<FollowButton
following={profile.isFollowing}
handle={profile.handle}
onFollow={onFollow}
onUnfollow={onUnfollow}
/>
</div>
<Heading className='mt-4' level={3}>{profile.actor.name}</Heading>
<span className='mt-1 inline-block text-[1.5rem] text-grey-800'>{profile.handle}</span>
<div
dangerouslySetInnerHTML={{__html: profile.actor.summary}}
className='ap-profile-content mt-3 text-[1.5rem] [&>p]:mb-3'
/>
{attachments.map(attachment => (
<span className='mt-3 line-clamp-1 flex flex-col text-[1.5rem]'>
<span className={`text-xs font-semibold`}>{attachment.name}</span>
<span dangerouslySetInnerHTML={{__html: attachment.value}} className='ap-profile-content truncate'/>
</span>
))}
<Heading className='mt-8' level={5}>Posts</Heading>
{posts.map((post) => {
if (post.type === 'Announce') {
return null;
} else {
return (
<FeedItem
actor={profile.actor}
comments={post.object.replies}
layout='feed'
object={post.object}
type={post.type}
onCommentClick={() => {}}
/>
);
}
})}
</div>
</div>
</div>
</Modal>
);
};
export default NiceModal.create(ProfileSearchResultModal);

View file

@ -1,5 +1,5 @@
import {Activity} from '../components/activities/ActivityItem';
import {ActivityPubAPI} from '../api/activitypub';
import {ActivityPubAPI, type ProfileSearchResult, type SearchResults} from '../api/activitypub';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
@ -231,3 +231,51 @@ export function useActivitiesForUser({
}
});
}
export function useSearchForUser(handle: string, query: string) {
const siteUrl = useSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
const queryClient = useQueryClient();
const queryKey = ['search', {handle, query}];
const searchQuery = useQuery({
enabled: query !== '',
queryKey,
async queryFn() {
return api.search(query);
}
});
const updateProfileSearchResult = (id: string, updated: Partial<ProfileSearchResult>) => {
queryClient.setQueryData(queryKey, (current: SearchResults | undefined) => {
if (!current) {
return current;
}
return {
...current,
profiles: current.profiles.map((item: ProfileSearchResult) => {
if (item.actor.id === id) {
return {...item, ...updated};
}
return item;
})
};
});
};
return {searchQuery, updateProfileSearchResult};
}
export function useFollow(handle: string, onSuccess: () => void, onError: () => void) {
const siteUrl = useSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return useMutation({
async mutationFn(username: string) {
return api.follow(username);
},
onSuccess,
onError
});
}

View file

@ -24,12 +24,12 @@ animation: bump 0.3s ease-in-out;
fill: #F50B23;
}
.ap-note-content a {
.ap-note-content a, .ap-profile-content a {
color: rgb(236 72 153) !important;
word-break: break-all;
}
.ap-note-content a:hover {
.ap-note-content a:hover, .ap-profile-content a:hover {
color: rgb(190, 25, 99) !important;
text-decoration: underline !important;
}

View file

@ -23,13 +23,22 @@ export type ObjectProperties = {
export type ActorProperties = {
'@context': string | (string | object)[];
attachment: object[];
attachment?: {
type: string;
name: string;
value: string;
}[];
discoverable: boolean;
featured: string;
followers: string;
following: string;
id: string | null;
image: string;
image: {
url: string;
};
icon: {
url: string;
};
inbox: string;
manuallyApprovesFollowers: boolean;
name: string;

View file

@ -30695,6 +30695,11 @@ use-callback-ref@^1.3.0:
dependencies:
tslib "^2.0.0"
use-debounce@10.0.3:
version "10.0.3"
resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-10.0.3.tgz#636094a37f7aa2bcc77b26b961481a0b571bf7ea"
integrity sha512-DxQSI9ZKso689WM1mjgGU3ozcxU1TJElBJ3X6S4SMzMNcm2lVH0AHmyXB+K7ewjz2BSUKJTDqTcwtSMRfB89dg==
use-isomorphic-layout-effect@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"