2.11.3 • Published 25 days ago

flybits v2.11.3

Weekly downloads
14
License
Apache-2.0
Repository
github
Last release
25 days ago

Flybits Inc.

Flybits.js

@version 2.11.0

Flybits.js is an isomorphic JavaScript SDK that can be used to build Flybits enabled applications on both the client and server side. That is to say, Flybits.js has been designed for creating mobile web applications, specifically Progressive Web Apps, and also Flybits enabled microservices.

Table of Contents

  1. Compatibility
  2. Getting Started
  3. Fundamentals
    1. Promises
    2. Standardized Errors
  4. Authentication
  5. Basic Data Consumption
    1. Retrieving Content
    2. Content Pagination
    3. Nested Paged Data
  6. Context Management
    1. Explicit Reporting of Context Data
    2. Registering Available Plugins
    3. Creating a Custom Client-Side Context Plugin
    4. Parameterized Context Attributes
    5. Analytics

Compatibility

To achieve client/server agnosticism, this SDK utilizes the new ES6 Promise (spec) object and the upcoming standard Fetch API (spec). Both have polyfill support readily available for platforms who have yet to implement them.

To maintain compatibility until all modern browsers and node environments catch up, it is recommended to include the polyfills below by default.

Browser:

Node:

Getting Started

Fetch, Include, Initialize

  1. Fetch the SDK

    The SDK is available using the Node Package Manager(npm)

    $ npm install flybits --save
  2. Include the SDK

    Browser:

    <script src="node_modules/flybits/dist/flybits.js"></script>
    <!-- or use the CDN link -->
    <script src="https://cdn.jsdelivr.net/npm/flybits@2.11.0/dist/flybits.min.js"></script>

    Node:

    var Flybits = require('flybits');

    ES6 Module:

    import Flybits from 'flybits/dist/flybits.mjs'
  3. Initialize the SDK

    Initialization is required to configure environment properties such as reporting delays, and host URLs among other things. There are two ways of initializing the Flybits SDK. One is by providing a JavaScript Object that will override the SDK default configuration:

    Flybits.init({
      HOST: '//api.flybits.com',
      CTXREPORTDELAY: 5000,
    }).then(function(cfg){
      /** cfg is the initialized state of the SDK configuration.
          It is not required to be used, it is only returned for your convenience.*/
    
      //start working with SDK
    }).catch(function(validationObj){
      // handle error
    });

    Another method of initialization is by providing a URI to a JSON file with which the SDK will read from to retrieve an Object to override SDK default configuration.

    Flybits.initFile('//resource.source.com/config.json').then(function(cfg){
      /** cfg is the initialized state of the SDK configuration.
          It is not required to be used, it is only returned for your convenience.*/
    
      // start working with SDK
    }).catch(function(validationObj){
      // handle error
    });

Fundamentals

The SDK is comprised of two key models, Content and Context. Content is primarily what the end-user will consume on their respective digital channels and comes from the Flybits core. This can be anything from push notifications to static/dynamic elements required to render a page/component in an application. Context is what defines the complete state of the user and encompasses the who/what/where/when/why/how about the user. Context can be reported to Flybits from all different channels from server applications to other end-clients, using this SDK to send Context is simply one of the possible methods.

You may be wondering where does the intelligence take place? This is the main benefit of Flybits being a Context-as-a-Service platform. End-clients built using this SDK can essentially become a dumb client that reports Context and fetches Content. It is the Flybits Context Engine in conjunction with Context Rules created in our Experience studio that will determine what Content is returned to the end-client. There does not need to be any hardcoded business logic for data segmentation on the end-client.

This is a key differentiator in that you can now build personalized applications that take into account contextual factors that lie outside of your end-users' devices. Personalization can now be more than simply a location or a step count. Context that is fed into Flybits can come from any source proprietary or public and all your application needs to do is fetch Content to receive relevant and personalized data.

Promises

A Promise represents an operation that has yet to be completed but will in the future.

All asynchronous operations in the SDK return a Promise which give developers full power and flexibility to manage chained operations, parallel model retrieval and deferred actions.

Standardized Errors

All handled errors in the Flybits SDK can be caught by appending a .catch() callback onto any promise and will invoke the callback with an instance of the Flybits.Validation class.

Flybits.api.Content.getAll().catch(function(validation){
  //handle error
});

The Flybits.Validation class comprises of a state property an errors array containing any and all errors that has been incurred by an SDK operation, and a type static property holding error code constants. The state property indicates the result of an operation. In the case of a .catch() callback it will always be false.

Authentication

In order to authenticate to Flybits one must use an Identity Provider(IDP). This SDK comes with a few default identity provider facilitating classes. If custom IDPs are required one can simply extend the IDP abstract class.

Below is a simple example of authenticating with a Flybits managed user account.

// Flybits managed IDP
var idp = new Flybits.idp.FlybitsIDP({
  password: 'pass123',
  email: 'email@company.com',
  projectID: 'E097EC76-AFA5-436D-8954-1287E220BBCB'
});

/**
 * IDP for OAuth 
 * var idp = new Flybits.idp.OAuthIDP({
 *   token: '12312231',
 *   providerName: Flybits.idp.OAuthIDP.provider.FACEBOOK,
 *   projectID: 'E097EC76-AFA5-436D-8954-1287E220BBCB'
 * });
 */ 

/**
 * Anonymous login 
 * var idp = new Flybits.idp.AnonymousIDP({
 *   projectID: 'E097EC76-AFA5-436D-8954-1287E220BBCB'
 * });
 */ 

Flybits.Session.connect(idp);

note: If you already have a pre-existing or independently retrieved Flybits JWT, you can inject it directly into the SDK. This will persist it locally and enable subsequent API calls to leverage the injected session.

const jwtStr = 'eyJhbGciOiJIUzI1NiIsImtpZCI6IkY3QkNENDUxLT...';
Flybits.Session.setUserToken(jwtStr);

Basic Data Consumption

Here are some examples of common workflows when consuming data from the Flybits core assuming User has already been authenticated.

Retrieving Content

This is the standard call that most applications will perform to request Content for their views. It's just this simple because all the intelligence and data segmentation is performed by the Flybits Context Engine and abstracted away from the end-clients. The below call will return all relevant Content instances for the current end-user.

Flybits.api.Content.getAll().then(function(resultObj){
  // do things with the relevant Content instances
  console.log(resultObj.result);
});

To retrieve the JSON body from a Content instance, whose values were defined in the Experience Studio, simply invoke the getData function on the instance. The reason this is an asynchronous function is because it is possible to fetch relavent Content instances without their body, however by default the SDK will request their bodies. Invoking getData will either resolve with its JSON body immediately or if the instance does not contain a body make an API request to retrieve it. Regardless, this logic is abstracted away from the user of the SDK.

Flybits.api.Content.getAll().then(function(resultObj){
  // assuming there is a Content instance in the response
  var contentInstance = resultObj.result[0];

  // retrieve JSON body
  return contentInstance.getData();
}).then(function(obj){
  // do stuff with body of Content instance.
});

Content Retrieval By Category

Within the Flybits Experience Studio content can be assigned a set of labels allowing for granular and flexible categorization. From the client application retrieval perspective here are some examples on specifying what content you would like to see:

// simple disjunction (boolean logical OR)
Flybits.api.Content.getAll({
  labels: ['offers', 'coffee']
}).then(function(resultObj){
  // do things with the relevant Content instances
  console.log(resultObj.result);
});

The above is an example of retrieving all relevant content that have labels offers OR coffee For advanced statements an expression in Conjunctive Normal Form (CNF) or Disjunctive Normal Form (DNF) is supported.

// CNF expression (boolean logic AND of ORs)
Flybits.api.Content.getAll({
  labelsFormula: '(offers,coffee,(starbucks;dunkin))'
}).then(function(resultObj){
  // do things with the relevant Content instances
  console.log(resultObj.result);
});

The above is an example of retrieving all relevant content that have labels offers AND coffee AND ( starbucks OR dunkin ). Content returned with these label combinations:

  • offers, coffee, starbucks
  • offers, coffee, dunkin

Content not returned with these label combinations:

  • offers, starbucks
  • offers, coffee, tims
  • ... etc

Advanced Expression Operators

Logical operators include:

  • NOT - !
  • AND - ,
  • OR - ;

Example labels and usage patterns following CNF and DNF structures:

(english)
((english))
(!(english))
(english;french)
(english,french)
(english,!french)
(english;french;spanish)
(english,french,spanish)
(english,!french,!spanish)
(english,(french;spanish),german)
((english,french);(spanish,german))

Content Pagination

A tedious aspect of consuming lengthy amounts of models from an API is pagination. Most pageable responses from the SDK will return a response Object that comprises of a result array of the requested models and a nextPageFn. If the nextPageFn is not undefined, then the resultant dataset has another page. Simply invoke the nextPageFn function to perform another asynchronous request for the next page of data.

This is super helpful with infinite scrolling use cases as it's simple to check for the existence of a nextPageFn as opposed to dealing with limits/offsets/totals and keeping track of what request parameters were used to perform the first request.

Note: if you really need the paging details, the paging Object of the last request can be retrieved using the accessor at Flybits.api.Content.getPaging().

Flybits.api.Content.getAll().then(function(resultObj){
  // do things with the first page of relevant Content instances
  console.log(resultObj.result);

  // fetch next page of Content instances if it exists
  return resultObj.nextPageFn? resultObj.nextPageFn():{};
}).then(function(resultObj){
  // do things with the second page of relevant Content instances
  console.log(resultObj.result);

  // fetch next page of Content instances if it exists
  return resultObj.nextPageFn? resultObj.nextPageFn():{};
});

Manual Pagination

If you would like to manually specify pagination parameters, you can so as such:

Flybits.api.Content.getAll({
  paging: {
    limit: 20,
    offset: 40
  }
}).then(function(resultObj){
  // do things with the relevant Content instances
  console.log(resultObj.result);
  
  // manually access last pagination object
  // {offset: 40, limit: 20, total: 100}
  console.log(Flybits.api.Content.getPaging())
});

Nested Paged Data

Depending on the Content template an instance was created from, the JSON body of a Content instance may have a property mapping to an array with pagination. In this case the SDK will replace the array with an instance of the PagedData class. Within the class is a data property that maps to the array of JSON data. To check if the PagedData has more pages, you can either check the existence of the nextPageFn on the PagedData instance or you can invoke the hasNext function. To properly retrieve the next page of data, invoke getNext. This function will resolve with an array of the next page of data, and it will also append the next page to the PagedData instance's internal data array. Thus if you are using a data-binding UI framework no work is needed to update your view. If your view is bound to the data property of the PagedData instance, simply invoke getNext and your view should update automatically.

// assuming previous Content instance retrieval
var contentInstance;

contentInstance.getData().then(function(obj){
  /**
   *  assuming Content JSON body
   *  {
   *    firstName: "Bob",
   *    lastName: "Loblaw",
   *    pets: PagedData
   *  }
   */

   // bind view to obj.pets.data
   console.log(obj.pets.data);
   if(obj.pets.hasNext()){
     obj.pets.getNext();
   } 
});

Context Management

The Flybits platform allows for Context Plugins to be built on both server and client side to connect/report proprietary data sources, sensory data, & engagement analytics to the Flybits core. The JavaScript SDK allows for two mechanisms of context data reporting:

Proactive Reporting For event driven situations where an action requires proactive adhoc reporting of context data, a simple convenience method can be used for explicit reporting. This method still leverages Context Manager batching capabilities and is considered the easiest way to report context data.

Scheduled Collection This form is best suited for periodically polling a data source such as device location or any other source for periodic state changes. In order to do this you must use available plugins or create a custom client-side Context Plugin and register it to the main Context Manager.

Custom Context Plugin creation essentially allows for developers to extend a ContextPlugin abstract class to leverage periodic collection services and batching capabilities. Proactive adhoc reporting is also available with custom Context Plugins.

Explicit Reporting of Context Data (>v2.7.0)

Starting in version 2.7.0 there is no longer a need to implement, instantiate, & register custom Context Plugins in order to report data. Using only a Context Plugin ID you may explicitly report context data at any point in your application lifecycle as long as the Context Manager has been initialized.

// Initialize Context Manager and report context data
Flybits.context.Manager.initialize().then(function(){
  Flybits.context.Manager.reportCtx('ctx.sdk.battery',{
    percentage: 22.3
  });
});

/** 
 * Alternatively you can listen for initialization success separately
 * from where initialization occurs.
 */
Flybits.context.Manager.ready.then(function(){
  Flybits.context.Manager.reportCtx('ctx.sdk.battery',{
    percentage: 22.3
  });
})

Registering Available Plugins

All Context plugins that extend the ContextPlugin abstract class will inherit the ability to periodically collect context values and have them stored locally. Upon registering the plugin with the Flybits.context.Manager periodic collection will begin. The Flybits.context.Manager will then periodically gather all collected context values and report them to the Flybits core. The period of collection for each context plugin can be configured individually as well as the period of reporting of the Flybits.context.Manager.

The Flybits.context.Manager also implements a debouncing algorithm so that if the length of one period has passed since it reported context to the core and new context values are collected locally, it will begin reporting immediately. However, it will still respect the frequency of the reporting period and not report more than once per period. For example, let us say there have been 4 context values from several different context plugins collected locally and the Flybits.context.Manager reporting period length is 2000ms. Because there are context values to report the manager will gather them all together and report them to the core at 0ms. After 2000ms has passed, the manager will check for locally collected context values, if there are none no report will occur. However if a context plugin collects values at the 2500ms mark, because the minimum of 2000ms has passed since the last report, the manager will awaken and perform a context report immediately instead of waiting for the 4000ms mark. This algorithm basically ensures a maximum performance implication on the client application in terms of network utilization but at the same time allows for reactivity should context state change suddenly.

This SDK includes some basic context plugins. This list will grow as native device access grows across browser vendors:

  • Location
  • Connectivity

Below is an example as to how one can register existing or custom plugins and begin reporting to the Flybits Core. The basic workflow is that context values are collected from the individual context plugin instances. Then they are batched together and reported to the Flybits Core.

// Instantiate context plugins
var x = new Flybits.context.Location({
  maxStoreSize: 50,
  refreshDelay: 10000
});
var y = new Flybits.context.Connectivity({
  maxStoreSize: 50,
  refreshDelay: 5000
});
/**
 * Remember that every custom context plugin must extend the ContextPlugin
 * abstract class.
 */
var z = new CustomContextPlugin({
  maxStoreSize: 50,
  refreshDelay: 1000
});

/**
 * Register plugin instances.  Once registered they will begin collecting
 * context data into a local persistent storage database.  Note the Manager
 * will not yet report these context values to the Flybits Core.
 */
var xStart = Flybits.context.Manager.register(x);
var yStart = Flybits.context.Manager.register(y);
var zStart = Flybits.context.Manager.register(z);


Promise.all([xStart,yStart,zStart]).then(function(){
  /**
   * It is only after explicit initialization that the context manager will
   * begin reporting values to the Core.
   */
  Flybits.context.Manager.initialize();
});

All Context plugins also allow for immediate injection of context values for use cases that do not fit a passive periodic collection model. Note that the data structure of the Object to be injected must either match the expected server format of the plugin created in the Flybits Developer Portal or match the expected parameter format in the SDK plugin object's _toServerFormat function. More Information can be found in the Creating a Custom Client-Side Context Plugin section.

// Instantiate plugin services
var x = new Flybits.context.Location({
  maxStoreSize: 50,
  refreshDelay: 5000
});
var z = new CustomContextPlugin({
  maxStoreSize: 50,
  refreshDelay: 1000
});

var xStart = Flybits.context.Manager.register(x);
var zStart = Flybits.context.Manager.register(z);

// inject context values
x.setState({
  coords: {
    accuracy: 1217,
    latitude: 43.6711263,
    longitude: -79.4140225
  },
  timestamp: 1501785980332
});

z.setState({
  page_view: 'Agenda',
  view_count: 23
});

Creating a Custom Client-Side Context Plugin

If your application is privy to proprietary information you would like to report to Flybits as a context parameter, you must extend the ContextPlugin abstract class. Proprietary information can include anything including user information or flags that can be used to segment your user base.

Below is an example of how to extend the ContextPlugin abstract class. You may wish to encapsulate the class definition below in a closure to allow for private static entities.

// Plugin constructor
var CustomContextPlugin = function(opts){
  // call parent abstract class constructor
  Flybits.context.ContextPlugin.call(this,opts);
  // custom initialization code here
};

// copy parent abstract class prototype
CustomContextPlugin.prototype = Object.create(Flybits.context.ContextPlugin.prototype);
CustomContextPlugin.prototype.constructor = CustomContextPlugin;

/**
 * This is the unique plugin name that can be found on the context plugin's info page in 
 * the Developer Portal project settings.
 */
CustomContextPlugin.prototype.TYPEID = CustomContextPlugin.TYPEID = 'ctx.sdk.contextpluginname';

/**
 * You must override the support check method to account for cases where your
 * custom plugin can potentially not be compatible with all user browser platforms.
 */
CustomContextPlugin.isSupported = CustomContextPlugin.prototype.isSupported = function(){
  var def = new Flybits.Deferred();
  // custom check to see if the custom context plugin is supported on users' platform
  def.resolve();
  return def.promise;
};

/**
 * Main function that is called whenever the context manager collects context.
 */
CustomContextPlugin.getState = CustomContextPlugin.prototype.getState = function(){
  var def = new Flybits.Deferred();
  // custom code to retrieve and return proprietary data
  def.resolve();
  return def.promise;
};

/**
 * Optional override of the transformation function used to convert objects collected from getState() or
 * injected by setState().  You do not need to implement this function if your context value
 * data structure is identical to the server expected key/map structure of the plugin.  This
 * server structure is defined from the Developer Portal project settings.
 */
CustomContextPlugin.prototype._toServerFormat = function(item){
  /**
   * Invoking the super class method before manipulating the `item` argument
   * is recommended in order to accomodate for Flybits specific server format.
   * Namely it is required for parsing parameterized context attributes.
   */
  //item = Flybits.context.ContextPlugin.prototype._toServerFormat.call(this,item);

  return {
    propertyKey: item.contextValueKey
  };
};

Parameterized Context Attributes

As you may already be aware, context data objects are JSON objects that contain a key value mapping of a context plugin's attribute name and its current value that is to be reported. Available attributes are defined when creating a custom context plugin in the Flybits Developer Portal. For times when there are variable subcategories to classify an attribute's value, you can define an attribute in the Developer Portal to be "parameterized".

For example if you'd like to capture context values for the viewing of pages in your application and you may not know all the pages available ahead of time or there may be very many pages, it is not very convenient to create an attribute for every page name when defining your custom context plugin:

// inconvenient multiple static attribute definitions
ctx.tenant.views.homePage
ctx.tenant.views.agendaPage
ctx.tenant.views.surveyPage
// ...
/**
 * `views` is the plugin name
 * attribute name for every possible page of application
 */

If you were to use a parameterized attribute named page, you would only need a single attribute to facilitate the same as the above example:

// convenient single attribute definition allowing for dynamic arguments
ctx.tenant.views.page.{pageName}
/**
 * `views` is the plugin name
 * `page` is the attribute name
 * `pageName` is the variable parameter
 */

So now when you are setting or collecting context values, you can do something like this:

var z = new CustomContextPlugin({
  maxStoreSize: 50,
  refreshDelay: 1000
});

z.setState({
  'page.agenda': 'viewed',
  'page.home': 'viewed',
  'page.survey': 'canceled'
  x:3
});

In this example as the pages of your application grows you can continue to report new pageName arguments without having to update your context plugin definition.

Analytics

Engagement analytics originating from the device follows the same paradigm as all Context data in the Flybits ecosystem and is modeled as Context Plugins. These reserved Context Plugins are available by default in all Flybits tenants:

  • ctx.flybits.contentAnalytics
  • ctx.flybits.pushAnalytics
  • ctx.flybits.pushContentAnalytics

Below are some of the attributes we capture:

Content Scoped Engagement Used to track general engagement around Content that was provisioned by Flybits to the SDK.

  • ctx.flybits.contentAnalytics.query.engaged.{content ID}
  • ctx.flybits.contentAnalytics.query.viewed.{content ID}
  • ctx.flybits.contentAnalytics.query.fulfilled.{content ID}

Content Component Scoped Engagement This is a specialization of Content engagement tracking to be able to pinpoint which button or component of a piece of Content was engaged with.

  • ctx.flybits.contentAnalytics.query.componentEngaged.{content ID}.{component ID}.{component name}.{component type}
  • ctx.flybits.contentAnalytics.query.componentViewed.{content ID}.{component ID}.{component name}.{component type}
  • ctx.flybits.contentAnalytics.query.componentFulfilled.{content ID}.{component ID}.{component name}.{component type}

Push Scoped Engagement This is meant to track engagement of push notifications.

  • ctx.flybits.pushAnalytics.query.viewed.{push ID}
  • ctx.flybits.pushAnalytics.query.engaged.{push ID}

Push Component Scoped Engagement Similar to Content Component Scoped analytics this is meant to track any components/buttons within a push notification that the end user may have interacted with.

  • ctx.flybits.pushAnalytics.query.componentEngaged.{push ID}.{component ID}

Push Content Scoped Engagement This is used to track engagement with Content that was sent in the push payload for deep-linking purposes.

  • ctx.flybits.pushContentAnalytics.query.componentEngaged.{content ID}.{push ID}.{component ID}.{component name}.{component type}
  • ctx.flybits.pushContentAnalytics.query.componentFulfilled.{content ID}.{push ID}.{component ID}
  • ctx.flybits.pushContentAnalytics.query.engaged.{content ID}.{push ID}
  • ctx.flybits.pushContentAnalytics.query.fulfilled.{content ID}.{push ID}
  • ctx.flybits.pushContentAnalytics.query.viewed.{content ID}.{push ID}

Reporting Analytics

Starting in v2.5.0 the above analytics Context Plugins will be automatically registered when the Context Manager is initialized and will contain helper functions.

Flybits Context Manager must be initialized before the below methods will function correctly.

  Flybits.context.Manager.initialize();

Below are some examples of how to report various Content analytics:

  var contentInstance // retrieved from Flybits.api.Content.getAll()
  Flybits.analytics.Manager.reportContent(contentInstance);

  var contentInstanceID
  Flybits.analytics.Manager.reportContentID(contentInstanceID);

  var action = Flybits.analytics.Manager.actions.VIEWED;
  Flybits.analytics.Manager.reportContent(contentInstance, action);

  action = Flybits.analytics.Manager.actions.COMPONENTENGAGED;
  Flybits.analytics.Manager.reportContent(contentInstance, action, {
    name: 'Submit',
    type: 'Button',
    compID: 'cta-button-1'
  });

Out of the box actions can be found at Flybits.analytics.Manager.actions namespace.

  • VIEWED
  • ENGAGED
  • COMPONENTENGAGED

If no action argument is supplied, Flybits.analytics.Manager.actions.ENGAGED will be used by default.

Reporting Analytics (<v2.5.0)

Because analytics is captured through Context Plugins, you can create custom Context Plugin for each of the analytics types you'd like to report on:

  • ctx.flybits.contentAnalytics
  • ctx.flybits.pushAnalytics
  • ctx.flybits.pushContentAnalytics

Here is an example:

  1. Creating a Custom Client-Side Context Plugin
var ContentAnalytics = function(opts){
  Flybits.context.ContextPlugin.call(this,opts);
  
  /** This plugin requires manual reporting and does 
   *  not need an automated refresh interval every X time.
   **/
  this.refreshDelay = Flybits.context.ContextPlugin.ONETIME;
};

ContentAnalytics.prototype = Object.create(Flybits.context.ContextPlugin.prototype);
ContentAnalytics.prototype.constructor = ContentAnalytics;
ContentAnalytics.prototype.TYPEID = ContentAnalytics.TYPEID = 'ctx.flybits.contentAnalytics';

// Always supported because these are Flybits reserved plugins
ContentAnalytics.isSupported = ContentAnalytics.prototype.isSupported = function(){
  return Promise.resolve();
};

// This plugin is only used for manual reporting.
ContentAnalytics.getState = ContentAnalytics.prototype.getState = function(){
  return Promise.resolve();
};
  1. Register Plugin with Context Manager Plugin regstration can occur at any time. The important part is to ensure the Context Manager is initialized sometime in your application lifecycle.
var contentAnalytics = new ContentAnalytics();
Flybits.context.Manager.register(contentAnalytics);
Flybits.context.Manager.initialize();
  1. Report Data At the event of an end-user action that is of interest perform the following:
var analyticsEvent = {};
analyticsEvent['viewed.' + content.id] = true;
// analyticsEvent['engaged.' + content.id] = true;
contentAnalytics.setState(analyticsEvent);
2.11.2

25 days ago

2.11.3

25 days ago

2.11.1

2 months ago

2.11.0

3 months ago

2.10.1

4 months ago

2.10.0

5 months ago

2.9.0

9 months ago

2.9.1

9 months ago

2.8.0

1 year ago

2.7.0

1 year ago

2.6.2

2 years ago

2.6.1

2 years ago

2.6.0

2 years ago

2.5.1

2 years ago

2.5.0

3 years ago

2.4.22

3 years ago

2.4.22-rc.0

4 years ago

2.4.21

4 years ago

2.4.20

4 years ago

2.4.19

4 years ago

2.4.18

5 years ago

2.4.17

5 years ago

2.4.16

6 years ago

2.4.15

6 years ago

2.4.14

6 years ago

2.4.13

6 years ago

2.4.12

6 years ago

2.4.11

6 years ago

2.4.10

6 years ago

2.3.10

6 years ago

2.3.9

6 years ago

2.3.8

7 years ago

2.2.8

7 years ago

2.2.7

7 years ago

2.2.6

7 years ago

2.2.5

7 years ago

2.2.4

7 years ago

2.2.3

7 years ago

2.1.3

7 years ago

2.0.1

7 years ago

2.0.0

7 years ago

1.3.0

7 years ago

1.1.5-beta

7 years ago

1.1.3-beta

7 years ago

1.1.2-beta

7 years ago

1.1.2

7 years ago

1.0.2

7 years ago

1.0.1

7 years ago

1.0.0

7 years ago

0.0.1

8 years ago