XF 2.1 What's new for developers in XF 2.1?

It's ok, developers, breathe a sigh of relief... We do have some good things for you in XF 2.1 but at a slightly smaller scale than the changes we introduced in XF 2.0 šŸ˜‰

Welcome to the seventh (admittedly developer oriented) "Have you seen...?" thread for XF 2.1. As ever, if you've not yet seen the previous entries, (why not?!) you can check them out here.

To ensure you're kept up to date, we strongly recommend giving that "Watch forum" link a scare and make sure you enable email notifications if you haven't done so already šŸŽƒ
 
Font Awesome changes

Due to a number of architectural changes within FA5, notably the ability to use different styles and the separation of brand icons to their own style, it has become necessary to introduce a few helpers within XF 2.1 to aid in calling icons in as simple of a way as possible.

Let's first take a look at a couple of small changes related to icons used in CSS.

Changes to the m-faBase() mixin

This is now geared up to handle both brand icons and any style you wish. By default using it will display a non-brand icon in the default weight, but there are some new arguments you can pass in.
Less:
&:before
{
   .m-faBase('Brands');
   .m-faContent(@fa-var-facebook);
}

Or if you want a Pro icon of a specific style:
Less:
&:before
{
   .m-faBase('Pro', @faWeight-solid);
   .m-faContent(@fa-var-bookmark);
}

There are three variables available for FA styles and they are @faWeight-solid, @faWeight-regular and @faWeight-light. These style variables actually correspond to the font weight values 900, 400 and 300 respectively.


Calling icons with the new <xf:fa> tag

You can, of course, still call icons using the same approaches as before (which are mostly similar to FA4) but you may find it easier to simply use our new template syntax tag instead, especially as generally you would want to use the correct FA5 style configured in the visitor's style.

For example, to call the user icon in the default style you can use:
HTML:
<i class="fa{{ fa_weight() }} fa-user"></i>
But it may be simpler to use:
HTML:
<xf:fa icon="fa-user" />
If you have a specific reason to use an icon from a different style, we support that too:
HTML:
<xf:fa icon="fas fa-user" />
And brand icons can be called like this:
HTML:
<xf:fa icon="fab fa-github" />


Accessing any FA icons with <xf:button> tag

For some time we've supported the icon attribute on our button tag, and this approach is still valid as in some cases this will also map to a default phrase to use with that icon style. For example:
HTML:
<xf:button icon="save" />
Produces:

localhost_21x_index.php (7).webp


But in XF 2.1 we've expanded this to support any FA icon using a new fa attribute. For example:
HTML:
<xf:button fa="fa-cloud-upload">Do the thing</xf:button>
Produces:

localhost_21x_index.php (8).webp


You can also use the fa attribute with <xf:submitrow /> in order to attach an icon to the primary submit button.

Adding extra context to inputs with icons

We'd recommend using this sparingly as generally it's not a UI convention that we use a lot in the software, but it is now possible to display an icon within an input in XF 2.1. For example:
HTML:
<xf:textboxrow rowtype="fullWidth noLabel" placeholder="Write a comment..." fa="fa-pencil" />
Produces:
localhost_21x_index.php (9).webp
 
New XF:Delete controller plugin

If you have ever created an add-on which has any UI for deleting database records, you may be familiar with the fairly repetitive task of writing the controller and template code to perform that action.

In XF 2.0, the code would look something like this:
PHP:
public function actionDelete(ParameterBag $params)
{
   $notice = $this->assertNoticeExists($params['notice_id']);
   if (!$notice->preDelete())
   {
      return $this->error($notice->getErrors());
   }

   if ($this->isPost())
   {
      $notice->delete();
      return $this->redirect($this->buildLink('notices'));
   }
   else
   {
      $viewParams = [
         'notice' => $notice
      ];
      return $this->view('XF:Notice\Delete', 'notice_delete', $viewParams);
   }
}

And then the template would look something like this:
HTML:
<xf:title>{{ phrase('confirm_action') }}</xf:title>

<xf:form action="{{ link('notices/delete', $notice) }}" ajax="true" class="block">
   <div class="block-container">
      <div class="block-body">
         <xf:inforow rowtype="confirm">
            {{ phrase('please_confirm_that_you_want_to_delete_following:') }}
            <strong><a href="{{ link('notices/edit', $notice) }}">{$notice.title}</a></strong>
         </xf:inforow>
      </div>
      <xf:submitrow rowtype="simple" icon="delete" />
   </div>
</xf:form>
In XF 2.1 we've introduced the Delete controller plugin to simplify much of this. No longer do you need to write an entirely new template. Instead, you just write an action in your controller which uses code similar to this:

PHP:
public function actionDelete(ParameterBag $params)
{
   $notice = $this->assertNoticeExists($params->notice_id);

   /** @var \XF\ControllerPlugin\Delete $plugin */
   $plugin = $this->plugin('XF:Delete');
   return $plugin->actionDelete(
      $notice,
      $this->buildLink('notices/delete', $notice),
      $this->buildLink('notices/edit', $notice),
      $this->buildLink('notices'),
      $notice->title
   );
}

The arguments passed into the controller plugin are simply the entity being deleted, the link to your controller action, a relevant edit/view link for the content, the URL to redirect to after deletion and the title of the content. There is an optional sixth argument so you can pass in a custom template, if needed.
 
Migrating from "Likes" to "Reactions"

If you currently have an add-on which implements a "Like" content handler you may be wondering how you migrate to "Reactions" in XF 2.1 and what will happen to your existing likes.

We have ensured that your add-on will continue to function as normal after upgrading to XF 2.1. This means that your existing like handler code, your existing like templates, alert templates for likes, entities, controllers and everything will continue to appear and function as it does now without you needing to make any changes.

In fact, if you'd like to, your add-on can continue to use likes going forward. That said, it would be safe to assume that the like system is now deprecated and it may be slated for removal at some point in the future. So it would be strongly recommended to embrace and implement the new reactions functionality as soon as possible.

But, the good news is, we've made it very simple to migrate to reactions through a number of different helpers.


AbstractSetup::migrateTableToReactions($tableName, $likesColumn = 'likes', $likeUsersColumn = 'like_users')

You would call this for each of your content types and pass in the table name where the changes need to apply. This renames the existing likes column to reaction_score, the existing like_users column to reaction_users and adds a new column named reactions (which stores a tally of which reactions have been applied to the content and how many times).


AbstractSetup::renameLikeAlertOptionsToReactions($contentTypes, $oldAction = 'like')

You can pass in an array of content types (or a single content type) here and this renames any "alert opt outs" to the new name. You will need to update any phrases related to alert opt outs too.


AbstractSetup::renameLikeAlertsToReactions($contentTypes, $renameNewsFeed = true, $oldAction = 'like')

This renames any existing "like" alerts to a "reaction" alert with the required extra data, and if enabled also any news feed entries. In addition to this, you will need to update any like alert/news feed templates. Use a core template such as alert_post_reaction and news_feed_item_post_reaction for reference.


AbstractSetup::renameLikePermissionsToReactions(array $permissionGroupIds, $likePermissionId = 'like')

Given an array of permission group IDs, this will rename any existing permission with an ID of like (or a custom ID) to react. The array you pass in should actually have the existing permission group ID as its key, and a true or false value to denote whether or not it is a content specific permission or global only, similar to this:
PHP:
$this->renameLikePermissionsToReactions([
   'forum' => true, // global and content
   'conversation' => false, // global only
   'profile_post' => false // global only
]);


XF\Entity\ReactionTrait trait

After removing most references to likes in your content entity, you will need to add in some new reaction specific stuff. To make this easier, we've added a new ReactionTrait. This will require you to implement a canReact method (which will mostly be the same as your existing canLike method).


Templates and controller actions

Similar to the existing XF:Like controller plugin, there is a new XF:Reaction controller plugin. These will help you very easily start supporting the user interface for likes. In fact, there are no custom templates to create at all for interacting with reactions as the controller plugin will call default templates for this purpose. You can check out XF\Pub\Controller\Post for examples.

The only thing you would need to change in your templates is the existing like link and like summary code. Even for these, we've got some new XF template tags to help. Check out the post_macros template for an example.
 
New InstallHelperTrait

This is mostly for information as you don't actually need to do anything with this new trait. This new trait is used by the existing AbstractSetup class. The main reason we added this is because there are a number of methods that were being duplicated across our core XF upgrade class and our add-on setup classes. Methods such as applying permissions, the aforementioned reaction migration methods, various schema manager methods and more are now available in there.
 
Sending push notifications

If you have an alert handler in your add-on, any alerts generated by it will automatically be sent as a push notification to any subscribed users. Technically you do not need to make any changes to support this, but we would recommend that you create a new push specific template for these alerts.

The reason being is that push notifications do not support any sort of arbitrary HTML so we have to convert them to plain text and try to extract a relevant URL out of them (which users will be redirected to when tapping on the notification on their devices).

As a specific example, if you have an alert template which is sent when someone reacts to your content, e.g. alert_post_reaction then we would recommend creating an equivalent push template, e.g. push_post_reaction. The format of the template is essentially a plain-text only version of the same template:
HTML:
{{ phrase('x_reacted_to_your_post_in_the_thread_y', {
   'name': $user.username ?: $alert.username,
   'reaction': reaction_title($extra.reaction_id),
   'title': prefix('thread', $content.Thread, 'plain') . $content.Thread.title
}) }}
<push:url>{{ link('canonical:posts', $content) }}</push:url>
This saves a bit of work in the alert pusher in terms of not having to convert anything to plain text or extracting the URL to use from the HTML.

However, you might have other uses for sending push notifications and we have made this as agnostic as possible through a new Trait. All you need to do is create a new service which uses the new XF\Service\PusherTrait and write the required methods to specify how to get the desired title and body for the notification.

To use your new service you simply pass in a user entity (the receiver) and any additional properties and then call the push method. And that's it!
 
Improved Composer support for add-ons

Composer is an invaluable tool which enables you to automatically install and upgrade any dependencies required by your code (and any dependencies required by your dependencies!) and create an autoloader so that this code is instantly accessible within your own code.

Although it is technically already possible for add-ons to include composer dependencies, there are a number of steps required to actually pull that code into the XF autoloader.

We've made this mostly automatic in XF 2.1 with thanks to @Sim's guide (linked above). All you need to do is add a composer_autoload entry to your addon.json file which points to your vendor composer directory and XF will automatically register any composer autoloaders found into our own autoloader.
 
Cache contexts

This was something we made reference to in the page caching announcement, but it is something that also applies to developers that make use of the caching system in their add-on.

In 2.0, there was only one cache configuration. If you wanted a cache handler, you'd call App::cache() and it'd give you the cache provider (or null if there was no provider). In 2.1, this method's signature has changed to App::cache($context = '', $fallbackToGlobal = true). The "cache" element of the container has been turned into a factory instead.

When requesting a cache object, you should provide a "context" value that will identify your style of usage. This allows forum owners to direct particular types of content to specific cache layers. If the owner hasn't configured a specific value for the context, the $fallbackToGlobal value applies. If true, then the global configuration will be returned. In most cases, this will be the desired behavior. However, if you have a system that could lead to caching large amounts of data, you may want to set this argument to false. This is how the page caching system works.

In terms of cache configuration, the global configuration is the same, via $config['cache']['provider'] and $config['cache']['config']. Specific contexts are configured under $config['cache']['context'][$contextName] (replacing the $contextName with the specific context name).

The contexts used out of the box include:
  • registry
  • sessions
  • css
  • page (this does not fallback to global)
 
Entity relation values via closures

When defining the conditions that make up a relationship between entities, you're limited to either fixed value comparisons or column-based comparisons. The only slightly more complex option involves basic concatenations. This is because depending on the context, we may be building a direct query to fetch the relationship or we may be building a join to fetch the relationship for a number of entities simultaneously.

2.1 reduces this limit by allowing the value part of a relationship to be defined using a closure. This is hard to explain without an example, so let's look at a relationship for a new 2.1 entity (with a bit of editing to keep the suspense :)):

Code:
'MasterDescription' => [
    'entity' => 'XF:Phrase',
    'type' => self::TO_ONE,
    'conditions' => [
        ['language_id', '=', 0],
        [
            'title', '=', function($context, $arg1)
            {
                if ($context == 'value')
                {
                    /** @var TypeOfEntityClass $entity */
                    $entity = $arg1;
                    return $entity->getPhraseName();
                }
                else
                {
                    $joinTable = $arg1;
                    return "CONCAT('phrase_group.', REPLACE(`$joinTable`.`entity_id`, ':', '_'))";
                }
            }
        ]
    ]

This is the relationship from a new entity type to the master version of the phrase that it creates. This isn't uncommon -- you'll see very similar things in a number of entities (such as options). However, in this case, the unique ID will be written with a ":" in it, and this isn't something that we allow in phrases so for this relationship to be picked up, we need to map the unique ID.

This is where the two contexts come up, "value" and "join".

Value is where we already have the relevant entity; in this case, it'd be $entity->MasterDescription. Internally, XenForo will be building a query to fetch this specific entity. Because we know the entity, we can use a helper method which will return the expected phrase name for us.

In the join context, we haven't actually fetched any entities yet. We'd simply be saying that we want the MasterDescription with the entity. Therefore, what we need to return is an SQL expression that can get the correct value for us. Instead of receiving an entity argument (which we don't have yet), we receive the SQL alias for the table of the entity that "owns" the relationship.

Note that this system only relates to the "value" portion of a relationship. You still need the first 2 arguments ("title" and "=" here) so XenForo can formulate the necessary parts of the query.

In many cases, this extra power isn't needed and there are alternative workarounds. For example, we could have defined another column that stored the "phrase value" of the entity ID and maintained that internally. However, now that isn't necessary.
 
Entity withAliases

In 2.0, when you fetched an entity (or a collection of them), you could eagerly fetch specific relationships using the with() finder method. However, this could cause some problems. It could lead to code duplication, where you were naming the same joins in numerous places. Further, it was hard for add-ons to extend. We attempted to workaround this in several entity-specific finders by providing methods specifically for listing eager joins (like in Finder\Thread::forFullView()), but this only covered certain cases.

Enter the withAliases concept. Let's look at an example for threads:

Code:
$structure->withAliases = [
   'full' => [
      'User',
      function()
      {
         $userId = \XF::visitor()->user_id;
         if ($userId)
         {
            return [
               'Read|' . $userId, 
               'UserPosts|' . $userId,
               'Watch|' . $userId,
               'Bookmarks|' . $userId
            ];
         }

         return null;
      }
   ],
   'fullForum' => [
      'full',
      function()
      {
         $with = ['Forum', 'Forum.Node'];

         $userId = \XF::visitor()->user_id;
         if ($userId)
         {
            $with[] = 'Forum.Read|' . $userId;
            $with[] = 'Forum.Watch|' . $userId;
         }

         return $with;
      }
   ]
];

Here we've defined two aliases, "full" and "fullForum". We'll look at a different approach to this in a moment.

When fetching threads, these can be accessed anywhere we expose the "with" concept to. For example, $threadFinder->with('full') or $em->find('XF:Thread', 123, 'full'). Internally, these will be expanded to include any named relationships within them. If we detect a closure, then that code will be run and the closure can return the names of any relationships that should be included.

If we look at the "fullForum" definition, we can see the first line is "full", which refers to the other withAlias we specified.

We can also refer to aliases on other entities. For example, if a "full" alias existed on the User relation, we could refer to User.full to automatically include all of the relationships that are defined in that case.

But let's look at another approach we could take instead of defining two different aliases, using the "with params" concept. XenForo uses the convention of using a vertical pipe to separate the name of a relationship to some sort of parameter to pass into it. That can be seen here with how we refer to fetching the read marking state for a particular user. With aliases support a similar idea, with the params passed into the closure.

Using this concept, we could define a single alias:
Code:
$structure->withAliases = [
    'full' => [
        'User',
        function()
        {
            $userId = \XF::visitor()->user_id;
            if ($userId)
            {
                return [
                    'Read|' . $userId, 'UserPosts|' . $userId,
                    'Watch|' . $userId,
                    'Bookmarks|' . $userId
                ];
            }

            return null;
        },
        function($withParams)
        {
            if (!empty($withParams['forum']))
            {
                $with = ['Forum', 'Forum.Node'];

                $userId = \XF::visitor()->user_id;
                if ($userId)
                {
                    $with[] = 'Forum.Read|' . $userId;
                    $with[] = 'Forum.Watch|' . $userId;
                }

                return $with;
            }
        }
    ]
];
Make note of the last closure, which takes the $withParams argument. This will be populated based on our use of the pipe. For example, if we call $threadFinder->with('full|forum'), the "forum" key of the $withParams will be set to true. If we just call "full" without the pipe, then no parameters will be passed through.

Multiple parameters can be passed through using syntax like alias|param1+param2, which will set "param1" and "param2" to true. (The closures will also receive the finder and the raw string after the pipe, to allow more specific uses.)

While this may not sound like a particularly significant feature, it should allow add-ons to more consistently include their joins when needed and it has helped pave the way for a feature we'll be discussing soon.
 
Dropping support for Internet Explorer 9 and Internet Explorer 10

While IE11 still attracts a reasonable amount of market share, this can likely be explained by it still being available in even the most recent versions of Windows 10. IE9 and IE10, on the other hand, haven't been around since the days of Windows 7 and Windows 8 respectively. Even Internet Explorer 10 was first released over 6 years ago.

Although we're not doing anything explicit that will totally "break" either of these browsers in XF 2.1, over time you may see fewer attempts at graceful degradation and any issues arising from this in these browsers will no longer be considered as valid bugs.

One such example of this is that we are no longer detecting whether a browser has flexbox support (which IE9 and IE10 do not) which may result in a less optimal experience for the very few users still using these browsers.
 
Removal of Modernizr

Beginning with XF 2.0 we started including Modernizr as a way for us to be able to detect browser support for certain features (or lack thereof). Modernizr is configurable in terms of which tests are included and we included a fairly reasonable amount of them, despite only really using a few of them. This was an 18KB file.

We aren't actually removing it from the XF download, simply because it's possible that some add-ons are making use of it to detect features beyond what we actually needed, though it's worth noting that it will no longer be loaded by default.

To replace it, we have implemented our own feature detection framework within the XF JS. As with any object within our JS, you should be able to extend it in order to add additional feature tests.

By default we are only detecting support for touch events, passive event listeners and whether the browser/device you're using has "hidden" scrollbars.

Although you can just continue to include Modernizr in your add-on code, we'd strongly encourage you to instead write your own feature tests using this framework instead.
 
Updates to label.iconic

Without going into a lot of detail, we decided that the CSS used in XF 2.0 to build the Font Awesome icons that are used for check boxes, radio buttons etc. was needlessly complicated and overly specific, which made customising them very difficult.

As a result, we've ditched all that code, and label.iconic now uses entirely new CSS.

This probably won't affect you too much in day-to-day life, but if you've included radio buttons, check boxes and other label.iconic properties in your add-ons and you haven't used the standard <xf:radio... or <xf:checkbox... constructions, or you've added custom CSS to your label.iconic elements, you should double-check the appearance with 2.1 and make adjustments if necessary.
 
And that's it for today! We are getting close to the end of the current HYS series for 2.1, but after today there is still three more HYS threads coming, all leading up to your first opportunity to play with XF 2.1 when we roll it out here which, we promise, is going to be very soon!

Until then, thanks so much for all of your support and kind words :)
 
@ChrisD with the withAliases do we just chain more closures to the array for a context with each additional add-on? The example isn't 100% clear.
 
Top Bottom