A Ubiquity Tutorial

This documents captures some of the lessons/patterns we learned while developing our set of feedly ubiquity commands. Note: if you are looking for just a Ubiquity 101 tutorial, Jono just posted a great one.

The example we will be using in this tutorial is the feedly save-for-later command which allows you to tag and save for later (aka star) an article in feedly and Google Reader (and optionally adding notes).

This tutorial is based on the Ubiquity 2.0 pre API.

Table of Content

  • Modeling our command
  • Implementing the preview of the command
  • Implementing the execute method of the command
  • Implementing an noun_type_feedly_tag noun type
  • Access to the source code of the feedly commands

Step #1: Modeling our command

We first had to determine what we wanted the user to type in to invoke our command. In our case:
save-for-later (note) (tagged aTag)

Then we translated it into a command definition:
CmdUtils.CreateCommand({
name: “save-for-later”,
takes: {“note”: noun_arb_text},
modifiers: { “tagged”: noun_type_feedly_tag },
icon: “chrome://feedly/skin/icon-16×16.png”,

As you can see, it is very clean and simple: all we had to do was to map the different inputs to noun types and define modifiers. The ubiquity parser does the rest of the magic. In our case, note is any arbitrary text but the tag is of noun_type_feedly_tag so that we can assist the user in re-using his existing set of tags if he wants to.

This decomposition has the benefit of cutting the work in pieces. Part 1: implement a share command, Part 2: implement the noun_type_feedly_tag.

Wishlist for the ubiquity team: some best practices regarding how to name commands (- or no -, etc..) would be great.

Step #2: Implementing the preview of the command

There are 2 core methods a command must implement preview and execute. The preview methods is invoked as the user makes progress in typing the command. The goal is to a) reassure him that we understand what he is typing and the context of the page he is using and b) give him visibility into a potential preview of what the result of the excution of the command might be.

What make this a little more complex is that “preview” could be running. In our example, the preview needs to take the URL and RSS feed information associate with the page and query our back ends to correlate that URL into the id of an article the use is subscribed too. Once correlated, we can determine if the current page has already been save and show that in the preview. That interaction with the back end needs to be asynchronous to prevent the UI threat to freeze. Asynchrony by itself is not hard. The challenge is to know if by the time you receive the callback, if the user is still trying to preview the command and to now if the input has changed/evolved.

Thankfully Ubiquity has a couple ways to help us address this problem. Way #1) if you use the AJAX GET calls they have in their framework, they will transparently cancel the callback when the preview is cancelled. Way #2) If you are managing async calls yourself, you can listen for preview-change the preview change event and flag your state so that when you receive your async callback, you know if you have been cancelled or not.

Here is a snippet of the preview method of the save-for-later command as well as the async function which tries to map the currentl url and feed info to an article in feedly/Google Reader (in bold the part of the code related to async behavior).

preview: function( pblock, directObj, modifiers )
{
    if( assertPreview( pblock, false ) == false )
        return;

    askResolveEntry(function( anEntry )
    {
        if( anEntry.isQuicklisted() == false )
        {
            var previewHTML = "Save this article for later ";
            if( directObj.text && directObj.text.length > 0 )
                previewHTML += "with note '" + directObj.text + "'";

            if( modifiers.tagged && modifiers.tagged.text.length > 0 )
                previewHTML += " and tagged as '" + modifiers.tagged.text + "'";

            pblock.innerHTML = previewHTML;
        }
        else
        pblock.innerHTML = "Article already saved for later";
    },
    pblock,
    function()
    {
        var previewHTML = "Save this article for later ";
        if( directObj.text && directObj.text.length > 0 )
        previewHTML += "with note '" + directObj.text + "'";

        if( modifiers.tagged && modifiers.tagged.text.length > 0 )
        previewHTML += " and tagged as '" + modifiers.tagged.text + "'";

        pblock.innerHTML = previewHTML;
    }
    );
},

// Note: this only callback
function askResolveEntry( onComplete, pblock, onNoRSS )
{
    var aborted = false;

    var rssContext = getRSSContext();
    if( rssContext == null )
    {
        if( onNoRSS != null )
        onNoRSS();

        return;
    }

    // In preview mode, we need to be causious and listen for changes to abort the resolution
    if( pblock != null )
    {
        function onPreviewChange()
        {
            pblock.removeEventListener( "preview-change",  onPreviewChange, false );
            aborted = true;
        }

        pblock.addEventListener( "preview-change", onPreviewChange, false );
    }

    if( pblock )
        pblock.innerHTML = "Correlating page to an RSS article. Please wait...";

    var reader = lookupService( "reader" );
    reader.askResolveEntryId(     rssContext,
    function( entryId )
    {
        if( aborted == true )
        return;

        if( pblock != null )
        pblock.removeEventListener( "preview-change",  onPreviewChange, false );

        if( entryId == null )
        {
            if( onNoRSS != null )
            {
                onNoRSS();
            }
            else
            {
                if( pblock != null )
                    pblock.innerHTML = "This page does not map to an RSS article";
                else
                    displayMessage( "This page does not map to an RSS article" );
            }
            return;
        }

        onComplete( reader.lookupEntry( entryId ) );
    }
    );
}

Wishlist for the ubiquity team: try to implement a better lifycle into the preview concept so that is it easier to determine if/when an async preview call has been cancelled.

Step #3: Implementing the execute method of the command

In a lot of ways the execution part is simpler than the preview because it can not be canceled and the execute method gets as input all the different parts it needs already parsed by the ubiquity engine. Here is what our save-for-later execute command looks like (in bold, the part of the code where the command access his input).

execute: function(directObj, headers)
{
    if( assertExecute( false ) == false )
    return;

    var that = this;
    askResolveEntry(function( anEntry )
    {
        var reader = lookupService( "reader" );

        if( ( directObj.text != null && directObj.text.length > 0 ) )
        {
            // create a note
            var snippet = CmdUtils.getHtmlSelection() || "";
            if( snippet == "" )
            snippet = anEntry.getContentOrSummary();

            var tags = [];
            if( headers.tagged && headers.tagged.text.length > 0 )
            tags =  headers.tagged.text.split( "," );

            var note = {
                annotation: directObj.text || "",
                linkify : false,
                share: false,
                snippet: snippet,
                srcTitle: anEntry.getSourceTitle(),
                srcUrl: anEntry.getSourceAlternateLink(),
                tags: tags,
                title: anEntry.getTitle(),
                url: anEntry.getAlternateLink()
            }

            that.saveNote( note );
        }
        else
        {
            reader.quicklistEntry( anEntry.getId() );
            displayMessage( "Article saved for later" );
        }
    },
    null,
    function()
    {
        // create a note
        var snippet = CmdUtils.getHtmlSelection() || "";

        var tags = [];
        if( headers.tagged && headers.tagged.text.length > 0 )
        tags =  headers.tagged.text.split( "," );

        var document = CmdUtils.getDocumentInsecure();
        var location = CmdUtils.getWindowInsecure().location;

        var note = {
            annotation: directObj.text || "",
            linkify : false,
            share: false,
            snippet: snippet,
            srcTitle: location.hostname,
            srcUrl: location.protocol + "//" + location.host,
            tags: tags,
            title: document.title,
            url:   document.URL
        }

        that.saveNote( note );
    }
    );
},

saveNote: function( note )
{
    note.tags.push( "feedly.saved" );
    note.tags.push( "read" );

    // post it to the server
    var reader = lookupService( "reader" );
    reader.askCreateNote(    note,
    function () {
        displayMessage( "Article saved for later" );
    },
    function (code,msg) {
        displayMessage( "Error:" + code + " -- " + msg );
    } );
}

Note to other developers: You need a Growl installed and configured properly in order to get display message to work.

Wishlist for the ubiquity team: if would be nice if the command could during the execute morph the ubiquity UI into a notification panel and report progress on the execution in context. To see what I mean, please take a look at how the email, tweet or friendfeed control work in the feedly home user interface.

Step #4: Implementing an noun_type_feedly_tag noun type

Noun types are used to type the input of a command. The key benefit is to provide suggestions to the users based on what they have already started to type. Nouns Types can be synchronous or asynchronous, depending on weither you have everything need to make your suggestions in memory or if you need to interact with a back end. In our case, the noun ended up bein sync because our extention has in memory a local cache of all the user tags. Here is the source code for implementing the noun_type_feedly_tag noun type.

var noun_type_feedly_topic = {
    _name: "topic",

    suggest: function( text, html, callback )
    {
        try
        {
            var brain = lookupService( "brain" );
            if( brain == null )
            return [];

            // get list of tags from reader
            var memes = brain.listMemes();
            var suggestions  = [ CmdUtils.makeSugg( text ) ];

            for( var i = 0; i < memes.length && suggestions.length < 5 ; i++  )
            {
                var meme = memes[ i ].text

                // already added
                if( meme == text )
                continue;

                if( text =="" || meme.match( text, "i" ) )
                suggestions.push( CmdUtils.makeSugg( meme ) );
            }

            return suggestions;
        }
        catch( e )
        {
            $debug( "[topic noun][suggest] failed because:" + e.name + " -- " + e.message );
            return [];
        }
    }
};

Note: As part of the feedly quick-mail command we implemented an async noun time related to contact. If you are interested, here is what an async noun type looks like, here it is:

var noun_type_feedly_contact = {
    _name: "contact",

    suggest: function( text, html, callback )
    {
        if( this.contacts != null )
        {
            var suggestions = this.completeSuggestion( this.contacts, text );
            return suggestions;
        }
        else
        {
            try
            {
                var addressBook = lookupService( "addressBook" );
                if( addressBook == null )
                return [];

                var that = this;
                addressBook.askContacts(function( contacts )
                {
                    that.contacts = contacts;
                    that.completeSuggestion( contacts, text, callback );
                },
                function()
                {
                }
                );
                return [];
            }
            catch( e )
            {
                $debug( "[contact noun][suggest] failed because:" + e.name + " -- " + e.message );
                return [];
            }
        }
    },

    completeSuggestion: function( contacts, pattern, callback )
    {
        var matchCount = 0;
        var suggestions = [];
        for( var i = 0; i < contacts.length && matchCount < 5; i++ )
        {
            for( var j = 0; j < contacts[ i ].userEmails.length; j++ )
            {
                var c = contacts[ i ].displayName + contacts[ i ].userEmails[ j ];
                if( c.match( pattern, "i" ) )
                {
                    var aSugg = CmdUtils.makeSugg(
                                    contacts[ i ].userEmails[ j ],
                                    contacts[ i ].userEmails[ j ],
                                    contacts[ i ] // passing in the contact data structure to the command
                                    );
                    if( callback != null )
                        callback( aSugg );
                    else
                        suggestions.push( aSugg )
                    matchCount++
                }
            }
        }
        return suggestions;
    }
};

One of the things that is very interesting about nouns is that they can be used to provide complex data structure to the preview and execute methods. By this I mean that you can print the name of a user in the UI but pass in a complex contact card to the execute method. In the above example, we print the email and attach the complete contact object to the suggestion. Very well done.

Bug for the ubiquity team: if an async noun type does not return any suggestion, the ubiquity UI will have a little blick between the execution of the return and the execution of first callback. It would be great if the UI could evolve to include the notion of work on suggestions please wait.

Wishlist for the ubiquity team: I am wondering why we could not extend the callback pattern to be able to callback with a set of suggestions and make all noun use the same callback pattern (some sync callback, some async callback). Given that a lot of noun will end up having some kind of cache (first call async, next call sync), it would help unify and clean up the code a little bit.

Step #5 Access to the source code of the feedly commands

For people who are interested in learning more about how we implemented the rest of our commands, here is a link to our command.js file (or color-coded snipt version).

The best way to play with this commands is to install ubiquity 0.2pre and the latest version of feedly (feedly will automatically register those commands – and unregister them when you un-install them).

Note: you will see that most of these command are very simple because they are a thin layer on top of the services registered with the feedly service bus. Over time, we are looking at better documenting the list of services available and the list of methods offered by each of the services (before we do that, we need to understand a little better the security model ubiquity is gravitating towards to offer a more permission-based pattern for commands to reference the feedly service).

If you have any questions, you can contact use at @edwk or @feedly on twitter or team@devhd.com

Finally, special thanks to Aza and team for the great work they are doing, we like their vision of a more task-driven web and are impressed by how quickly they are iterating towards it. You should expect to see more feedly+ubiquity integration in the future.

Author: @feedly

Read more. Know more.

2 thoughts on “A Ubiquity Tutorial”

Comments are closed.