20 Dec 2017

The Story of Auto-Completion in Nextcloud Comments

Submitted by blizzz
Photo by <a href="https://unsplash.com/photos/i5Crg4KLblY?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">andrew welch</a> on <a href="https://unsplash.com/?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>

AutoCompletion in Nextcloud's Commenting Feature

For a long time it is already possible to leave comments on files. With Nextcloud 11 we provide a way to mention users in comments, so that a notification is created for the respective individual. This was never really advertised, however, because it was lacking an auto-completion tool that offers you a type-ahead selection of users. The crucial point is that user IDs are neither known to the end user nor exposed to them. For instance, if users are provided by LDAP, it might look like "1d5566a5-87e6-4451-bd2f-e0e6ba5944d9". Nobody wants to see this and the average person also will not memorize it :)

It would be sad to see the functionality rot away being unseen in a dark corner. With Nextcloud 13 the time was ripe to finally include this missing bit, which is actually pretty fundamental and every application that allows text based communication amongst multiple people ships it.

The Plan

As a first step, I drafted a spec, consisting of three parts, subject to Nextcloud's layers. Let's start from the user facing aspects and get down in the stack:

  1. Web UI / Frontend

    The requirements are to request the user items for auto completion, offering the elements to find, pick and insert the mention and also to render it beautifully and in a consistent way. While talking to the server and rendering where to be done with means we had in place, for presentation and interaction I picked up the At.js plugin for jQuery. It can be adjusted and themed nicely, offers access points where they are needed and is just working pleasantly.

    One crucial point for the user experience is to have the results as quick as possible at hand, so that the author is as little as possible disturbed when writing the comment. The first idea was to pull in all possible users up front, but obviously this does not scale. In the implementation we pull them on demand, and in that regard I was also working on improving the performance of the LDAP backend to gain a positive user experience.

  2. Web API Endpoint

    This is the interface between the Web UI and the server. What the endpoint essentially will do is to gather the people from its sources, optionally let them be sorted, and send the results back to the client. Since it does not provide anything explicit for the Comments app, it ought to be a public and reusable API.

    Under the hood, I intended it to get the users from the infamous sharee endpoint specific to the file sharing app. No urge to reinvent wheels, right? However, it turned out that I needed to round out this wheel a little bit. More about this later.

  3. Server PHP API

    Provided our API endpoint can retrieve the users only one aspect is missing that needs to be added as an API. Since the newly crafted web endpoint is independent from the Comments app, also this component is. The service called AutoCompleteManager is supposed to take sorter plugin registrations and provide a method to run a result set through them.

    The idea is that persons that are most likely to be mentioned are pushed to the top of the result list. Those are identified by: people that have access to the file, people that were already commenting on the file or people the author interacted with. The necessary information are specific to other apps (comments and files_sharing in our case), hence they should provide a plugin. The API endpoint then only asks the service to run the results through the specified sorters.

The original plan is placed in the description of issue #2443 and contains more details, but be aware it does not reflect the final implementation.

Being a backend-person I started the implementation bottom up. The first to-do however was not working on the components mentioned above, but axe-shaping. The chosen source of persons for auto-completion, the sharee endpoint, has had all its logic in the Controller. This simply means that it does not belong to the public API scope (\OCP\ namespace) and was also designed to be consumed from the web interface. In short: refactoring!

The sharees endpoint

The sharee endpoint is a real treasure. The file sharing code requests it to gather instance users, federated users, email addresses, circles and groups that are available for sharing. It is a pretty useful method, in fact not only for file sharing itself. Despite being not an official API other apps are making use of it. One example is Deck which uses it for sharing suggestions, too.

On the server side we added a service to search through all the previously mentioned users, groups, circles, etc. Let us call them collaborators, to have a single, short term. The service is OCP\Collaboration\Collaborators\ISearch and offers two methods: one for searching, the other for registering plugins. Those plugins are the providers (sources) of these collaborators and search() delegates the query to each of them which is registered for the requested shareTypes (the technical term for sorts of collaborators). Also for backward compatibility (and to keep the changes in a certain range) the result follows the array based format used in the files_sharing app's controller, and an indicator whether to expect more results. Internally the introduced ISearchResult is being used to collect and manage the results from the providing plugins.

Each provider has to implement ISearchPlugin and more than one provider can be registered for each shareType. The existing logic was ported to each own provider, most residing in the server itself (because they themselves utilize server components) and additionally the circlesPlugin into the Circles app. Some glue code was necessary for registration. The apps announce the providers in their info.xml, which is then automatically fed to the register method on app load. The App Store's XSD schema was adjusted accordingly, so it will accept such apps.

The original sharee controller from thefiles_sharing app lost almost 600 lines of code and is now consuming the freshly formed PHP API. I cannot stress enough how good it is to have tests in place, which assure that everything still works as expected after refactoring (even if they need to be moved around). The refactor went in in pull request #6328. The adaption for the Circles app went in PRs #126, #135 and #136.

Backend efforts

Now that fundamentals are brought into shape, I was able to create the new auto completion endpoint and the services it depends on. The new AutoCompleteController responds to GET requests, and accepts a wide range of parameters of which only one is required and the others are optional. First, it requests instance users (defined by parameter) from the collaborator search. It merges the exact matches with the regular ones in one array, with exact matches (not just substrings) being on top. Then, if any sorter was defined by parameter, the auto-complete manager pipes the results through them. Finally, the sorted array is transformed into a simpler format that contains its ids, the end-user display name, and its source type (shareType) before being send back to the browser.

Auto-complete manager? Yes, \OCP\Collaboration\AutoComplete\IManager, forming the Server API aspect, was also introduced. It does not divert from the spec and is not difficult or special in any way.

Of course the sorters, especially the public interface ISorter, was also introduced, as well as the required info.xml glue. Two apps were equipped with sorter plugins: comments pushes people that commented on a file to the top and files_sharing puts people with access to the file on first.

Serving Layer 8

Having the foundations laid, the Web GUI only needs to use it. The first step I did was to ship the At.js plugin for jQuery and connect it to the API endpoint. Easy, until I realized that we fundamentally need to change the comment input elements for writing new as well as editing comments. For one, it does not provide all amenities feature-wise (sadly I do not remember what exactly), secondly HTML markup will not be formatted which we require to hide the user id behind the avatar and display name. It is the first time I heard about the contentEditable attribute. That's cool, and mostly what was necessary to do was replacing <input> with <div contentEditable="true">, applying the styles, changing a little bit of code for dealing with the value… and figuring out that you even can paste HTML into it! Now, this is handled as well.

Rendering was a topic, since we always want to show the mention in a nice way to the end user, even when editing. Mind, we send the plain text comment containing the user id to the server. The client is responsible for rendering the contents (the server sends the extracted mentions and the corresponding display name as meta data with the comment). A bit more work was to ensure that line breaks were kept when switching between the plain and rich forms.

A minor issue was ensuring that the contacts menu popup was also shown when clicking on a mention. Since Nextcloud 12 clicking on a user name shows a popup enabling to email or call the person, for example. The At.js plugin brought it's own set of CSS, which was adopted to our styling and so theming is fully supported. Eventually, a switch needed to be flipped so the plugin would not sort the already sorted results.

The backend- and frontend adaptions were merged with pull request 6982. A challenge that was left was making the retrieval of users from LDAP a lot faster to be acceptable. It was crucial for adoption and I made sure to have it solve before asking for final reviews.

Speed up the LDAP Backend

My strategy was to figure out what the bottleneck is, resolve it, measure and compare the effects of the changes and polish them. For the analysis part I was using the xdebug profiler for data collection and KCachegrind for visualization. The hog was quickly uncovered.

This requires a bit of explanation how the LDAP backend works when retrieving users. If not cached, we reach out to the LDAP server and inquire for the users matching the search term, based on a configured filter. The users receive an ID internal to Nextcloud based on an LDAP attribute, by default the entry's UUID. The ID is mapped together with the Distinguished Name (DN) for quick interaction and the UUID to detect DN changes. Depending on the configuration several other attributes are read and used in Nextcloud, for instance the email address, the photo or the quota. Since the records are read anyways, we also request most of these attributes (the inexpensive ones) with any search operation and apply them. Doing this is on each (non-cached) request is the hog.

Having the email up front, for instance, is important so that share notifications can be sent as soon as someone wants to share a file or folder with that person. So we cannot really skip that, but when we know that the user is already mapped, we do not need to update these features now. We can move it to a background job. Splitting off updating the features already did the trick! Provided that the users requested are already known, which is a matter of time.

In order to measure the before- and after states, first I prepared my dev instance. I connected to a redis cache via local socket, and ensured that before every run the cache is flushed. Unnecessary applications were closed. The command to measure was a command line call to search the LDAP server on Nextcloud, but the third batch of 500 for a provided search term: time sudo -u http ./occ ldap:search --limit=500 --offset=1000 "ha". I was running this ten times for the old state, an intermediate and the final state and went from averaging 14.7 seconds via 3.5s to finally 1.8s. This suffices. For auto-completion we request the first 10 results, a significantly lighter task.

Now we have a background job, which self-determines its run interval (in ranges) depending on the amount of known LDAP users, which iterates over the LDAP users fitting to the corresponding filter, mapping and updating their features. This kicks in only, if the background job mode is not set to "ajax" to avoid weird effects if it is triggered by the browser. Any serious setup should have the background jobs ran through cron. Also, it runs at least one hour after the last config change to interfere with setting up LDAP connections.

So, where are we?

Well, this feature is already merged in master which will become Nextcloud 13. Beta versions are already released and ready for testing. Some smaller issues were identified and fix. Currently there's also a discussion on whether avatars should appear in the comments text, or text-only is favourable. Either way, I am really happy to have this long-lasting item done and out. In a whole I am really satisfied with and looking for the 13 release!

This work has benefited from many collaborators, special thanks in random order to Jan, Maxence, Joas, Bernhard, Björn, Roeland and whoever I might have forgotten.

Add new comment