Bug 1407240 - Add mozIntl.RelativeTimeFormat. r?jfkthame draft
authorZibi Braniecki <zbraniecki@mozilla.com>
Tue, 10 Oct 2017 09:28:39 -0700
changeset 769763 870e1999b9a4d88773347ed7c955d2a4254632a0
parent 768965 97160a734959af73cc97af0bf8d198e301ebedae
push id103221
push userbmo:gandalf@aviary.pl
push dateTue, 20 Mar 2018 04:47:09 +0000
reviewersjfkthame
bugs1407240
milestone61.0a1
Bug 1407240 - Add mozIntl.RelativeTimeFormat. r?jfkthame MozReview-Commit-ID: FjqmqNTbbgk
toolkit/components/mozintl/MozIntlHelper.cpp
toolkit/components/mozintl/mozIMozIntl.idl
toolkit/components/mozintl/mozIMozIntlHelper.idl
toolkit/components/mozintl/mozIntl.js
toolkit/components/mozintl/test/test_mozintl.js
toolkit/components/mozintl/test/test_mozintlhelper.js
toolkit/content/license.html
--- a/toolkit/components/mozintl/MozIntlHelper.cpp
+++ b/toolkit/components/mozintl/MozIntlHelper.cpp
@@ -78,16 +78,37 @@ MozIntlHelper::AddDateTimeFormatConstruc
   if (!js::AddMozDateTimeFormatConstructor(cx, realIntlObj)) {
     return NS_ERROR_FAILURE;
   }
 
   return NS_OK;
 }
 
 NS_IMETHODIMP
+MozIntlHelper::AddRelativeTimeFormatConstructor(JS::Handle<JS::Value> val, JSContext* cx)
+{
+  if (!val.isObject()) {
+    return NS_ERROR_INVALID_ARG;
+  }
+
+  JS::Rooted<JSObject*> realIntlObj(cx, js::CheckedUnwrap(&val.toObject()));
+  if (!realIntlObj) {
+    return NS_ERROR_INVALID_ARG;
+  }
+
+  JSAutoCompartment ac(cx, realIntlObj);
+
+  if (!js::AddRelativeTimeFormatConstructor(cx, realIntlObj)) {
+    return NS_ERROR_FAILURE;
+  }
+
+  return NS_OK;
+}
+
+NS_IMETHODIMP
 MozIntlHelper::AddGetLocaleInfo(JS::Handle<JS::Value> val, JSContext* cx)
 {
   static const JSFunctionSpec funcs[] = {
     JS_SELF_HOSTED_FN("getLocaleInfo", "Intl_getLocaleInfo", 1, 0),
     JS_FS_END
   };
 
   return AddFunctions(cx, val, funcs);
--- a/toolkit/components/mozintl/mozIMozIntl.idl
+++ b/toolkit/components/mozintl/mozIMozIntl.idl
@@ -40,9 +40,10 @@ interface mozIMozIntl : nsISupports
   jsval getCalendarInfo([optional] in jsval locales);
   jsval getDisplayNames([optional] in jsval locales, [optional] in jsval options);
   jsval getLocaleInfo([optional] in jsval locales);
 
   readonly attribute jsval DateTimeFormat;
   readonly attribute jsval NumberFormat;
   readonly attribute jsval Collator;
   readonly attribute jsval PluralRules;
+  readonly attribute jsval RelativeTimeFormat;
 };
--- a/toolkit/components/mozintl/mozIMozIntlHelper.idl
+++ b/toolkit/components/mozintl/mozIMozIntlHelper.idl
@@ -37,9 +37,16 @@ interface mozIMozIntlHelper : nsISupport
    * Operating System regional preferences and adjust for that.
    *
    * That means that if user will manually select time format (hour12/24) or
    * adjust how the date should be displayed, MozDateTimeFormat will use that.
    *
    * This API should be used everywhere in the UI instead of regular Intl  API.
    */
   [implicit_jscontext] void addDateTimeFormatConstructor(in jsval intlObject);
+
+  /**
+   * Adds a RelativeTimeFormat constructor to the given object.  This function may only
+   * be called once within a realm/global object: calling it multiple times will
+   * throw.
+   */
+  [implicit_jscontext] void addRelativeTimeFormatConstructor(in jsval intlObject);
 };
--- a/toolkit/components/mozintl/mozIntl.js
+++ b/toolkit/components/mozintl/mozIntl.js
@@ -32,16 +32,125 @@ function getDateTimePatternStyle(option)
       return osPrefs.dateTimeFormatStyleMedium;
     case "short":
       return osPrefs.dateTimeFormatStyleShort;
     default:
       return osPrefs.dateTimeFormatStyleNone;
   }
 }
 
+/**
+ * Number of milliseconds in other time units.
+ *
+ * This is used by relative time format best unit
+ * calculations.
+ */
+const second = 1e3;
+const minute = 6e4;
+const hour = 36e5;
+const day = 864e5;
+
+/**
+ * Use by RelativeTimeFormat.
+ *
+ * Allows for defining a cached getter to perform
+ * calculations only once.
+ *
+ * @param {Object} obj - Object to place the getter on.
+ * @param {String} prop - Name of the property.
+ * @param {Function} get - Function that will be used as a getter.
+ */
+function defineCachedGetter(obj, prop, get) {
+  defineGetter(obj, prop, function() {
+    if (!this._[prop]) {
+      this._[prop] = get.call(this);
+    }
+    return this._[prop];
+  });
+}
+
+/**
+ * Used by RelativeTimeFormat.
+ *
+ * Defines a getter on an object
+ *
+ * @param {Object} obj - Object to place the getter on.
+ * @param {String} prop - Name of the property.
+ * @param {Function} get - Function that will be used as a getter.
+ */
+function defineGetter(obj, prop, get) {
+  Object.defineProperty(obj, prop, {get});
+}
+
+/**
+ * Used by RelativeTimeFormat.
+ *
+ * Allows for calculation of the beginning of
+ * a period for discrete distances.
+ *
+ * @param {Date} date - Date of which we're looking to find a start of.
+ * @param {String} unit - Period to calculate the start of.
+ *
+ * @returns {Date}
+ */
+function startOf(date, unit) {
+  date = new Date(date.getTime());
+  switch (unit) {
+    case "year": date.setMonth(0);
+    // falls through
+    case "month": date.setDate(1);
+    // falls through
+    case "day": date.setHours(0);
+    // falls through
+    case "hour": date.setMinutes(0);
+    // falls through
+    case "minute": date.setSeconds(0);
+    // falls through
+    case "second": date.setMilliseconds(0);
+  }
+  return date;
+}
+
+/**
+ * Used by RelativeTimeFormat.
+ *
+ * Calculates the best fit unit to use for an absolute diff distance based
+ * on thresholds.
+ *
+ * @param {Object} absDiff - Object with absolute diff per unit calculated.
+ *
+ * @returns {String}
+ */
+function bestFit(absDiff) {
+  switch (true) {
+    case absDiff.years > 0 && absDiff.months > threshold.month: return "year";
+    case absDiff.months > 0 && absDiff.days > threshold.day: return "month";
+    // case absDiff.months > 0 && absDiff.weeks > threshold.week: return "month";
+    // case absDiff.weeks > 0 && absDiff.days > threshold.day: return "week";
+    case absDiff.days > 0 && absDiff.hours > threshold.hour: return "day";
+    case absDiff.hours > 0 && absDiff.minutes > threshold.minute: return "hour";
+    case absDiff.minutes > 0 && absDiff.seconds > threshold.second: return "minute";
+    default: return "second";
+  }
+}
+
+/**
+ * Used by RelativeTimeFormat.
+ *
+ * Thresholds to use for calculating the best unit for relative time fromatting.
+ */
+const threshold = {
+  month: 2, // at least 2 months before using year.
+  // week: 4, // at least 4 weeks before using month.
+  day: 6, // at least 6 days before using month.
+  hour: 6, // at least 6 hours before using day.
+  minute: 59, // at least 59 minutes before using hour.
+  second: 59 // at least 59 seconds before using minute.
+};
+
 class MozIntl {
   constructor() {
     this._cache = {};
   }
 
   getCalendarInfo(locales, ...args) {
     if (!this._cache.hasOwnProperty("getCalendarInfo")) {
       mozIntlHelper.addGetCalendarInfo(this._cache);
@@ -68,17 +177,17 @@ class MozIntl {
 
   get DateTimeFormat() {
     if (!this._cache.hasOwnProperty("DateTimeFormat")) {
       mozIntlHelper.addDateTimeFormatConstructor(this._cache);
     }
 
     let DateTimeFormat = this._cache.DateTimeFormat;
 
-    class MozDateTimeFormat extends this._cache.DateTimeFormat {
+    class MozDateTimeFormat extends DateTimeFormat {
       constructor(locales, options, ...args) {
         let resolvedLocales = DateTimeFormat.supportedLocalesOf(getLocales(locales));
         if (options) {
           if (options.dateStyle || options.timeStyle) {
             options.pattern = osPrefs.getDateTimePattern(
               getDateTimePatternStyle(options.dateStyle),
               getDateTimePatternStyle(options.timeStyle),
               resolvedLocales[0]);
@@ -114,15 +223,98 @@ class MozIntl {
   get PluralRules() {
     class MozPluralRules extends Intl.PluralRules {
       constructor(locales, options, ...args) {
         super(getLocales(locales), options, ...args);
       }
     }
     return MozPluralRules;
   }
+
+  get RelativeTimeFormat() {
+    if (!this._cache.hasOwnProperty("RelativeTimeFormat")) {
+      mozIntlHelper.addRelativeTimeFormatConstructor(this._cache);
+    }
+
+    const RelativeTimeFormat = this._cache.RelativeTimeFormat;
+
+    class MozRelativeTimeFormat extends RelativeTimeFormat {
+      constructor(locales, options = {}, ...args) {
+
+        // If someone is asking for MozRelativeTimeFormat, it's likely they'll want
+        // to use `formatBestUnit` which works better with `auto`
+        if (options.numeric === undefined) {
+          options.numeric = "auto";
+        }
+        super(getLocales(locales), options, ...args);
+      }
+
+      formatBestUnit(date, {now = new Date()} = {}) {
+        const diff = {
+          _: {},
+          ms: date.getTime() - now.getTime(),
+          years: date.getFullYear() - now.getFullYear()
+        };
+
+        defineCachedGetter(diff, "months", function() {
+          return this.years * 12 + date.getMonth() - now.getMonth();
+        });
+        defineCachedGetter(diff, "days", function() {
+          return Math.trunc((startOf(date, "day") - startOf(now, "day")) / day);
+        });
+        defineCachedGetter(diff, "hours", function() {
+          return Math.trunc((startOf(date, "hour") - startOf(now, "hour")) / hour);
+        });
+        defineCachedGetter(diff, "minutes", function() {
+          return Math.trunc((startOf(date, "minute") - startOf(now, "minute")) / minute);
+        });
+        defineCachedGetter(diff, "seconds", function() {
+          return Math.trunc((startOf(date, "second") - startOf(now, "second")) / second);
+        });
+
+        const absDiff = {
+          _: {}
+        };
+
+        defineGetter(absDiff, "years", function() {
+          return Math.abs(diff.years);
+        });
+        defineGetter(absDiff, "months", function() {
+          return Math.abs(diff.months);
+        });
+        defineGetter(absDiff, "days", function() {
+          return Math.abs(diff.days);
+        });
+        defineGetter(absDiff, "hours", function() {
+          return Math.abs(diff.hours);
+        });
+        defineGetter(absDiff, "minutes", function() {
+          return Math.abs(diff.minutes);
+        });
+        defineGetter(absDiff, "seconds", function() {
+          return Math.abs(diff.seconds);
+        });
+
+        const unit = bestFit(absDiff);
+
+        switch (unit) {
+          case "year": return this.format(diff.years, unit);
+          case "month": return this.format(diff.months, unit);
+          case "day": return this.format(diff.days, unit);
+          case "hour": return this.format(diff.hours, unit);
+          case "minute": return this.format(diff.minutes, unit);
+          default:
+            if (unit !== "second") {
+              throw new TypeError(`Unsupported unit "${unit}"`);
+            }
+            return this.format(diff.seconds, unit);
+        }
+      }
+    }
+    return MozRelativeTimeFormat;
+  }
 }
 
 MozIntl.prototype.classID = Components.ID("{35ec195a-e8d0-4300-83af-c8a2cc84b4a3}");
 MozIntl.prototype.QueryInterface = XPCOMUtils.generateQI([Ci.mozIMozIntl, Ci.nsISupports]);
 
 var components = [MozIntl];
 this.NSGetFactory = XPCOMUtils.generateNSGetFactory(components);
--- a/toolkit/components/mozintl/test/test_mozintl.js
+++ b/toolkit/components/mozintl/test/test_mozintl.js
@@ -2,37 +2,39 @@
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 
 function run_test() {
   test_methods_presence();
   test_methods_calling();
   test_constructors();
+  test_rtf_formatBestUnit();
 
   ok(true);
 }
 
 function test_methods_presence() {
   equal(Services.intl.getCalendarInfo instanceof Function, true);
   equal(Services.intl.getDisplayNames instanceof Function, true);
   equal(Services.intl.getLocaleInfo instanceof Function, true);
-  equal(Services.intl.getLocaleInfo instanceof Object, true);
 }
 
 function test_methods_calling() {
   Services.intl.getCalendarInfo("pl");
   Services.intl.getDisplayNames("ar");
   Services.intl.getLocaleInfo("de");
   new Services.intl.DateTimeFormat("fr");
+  new Services.intl.RelativeTimeFormat("fr");
   ok(true);
 }
 
 function test_constructors() {
-  let constructors = ["DateTimeFormat", "NumberFormat", "PluralRules", "Collator"];
+  let constructors = [
+    "DateTimeFormat", "NumberFormat", "PluralRules", "Collator"];
 
   constructors.forEach(constructor => {
     let obj = new Intl[constructor]();
     let obj2 = new Services.intl[constructor]();
 
     equal(typeof obj, typeof obj2);
 
     Assert.throws(() => {
@@ -45,8 +47,113 @@ function test_constructors() {
       // All MozIntl APIs do not implement the legacy behavior and throw
       // when called without |new|.
       //
       // For more information see https://github.com/tc39/ecma402/pull/84 .
       Services.intl[constructor]();
     }, /class constructors must be invoked with |new|/);
   });
 }
+
+function testRTFBestUnit(anchor, value, expected) {
+  let rtf = new Services.intl.RelativeTimeFormat("en-US");
+  deepEqual(rtf.formatBestUnit(new Date(value), {now: anchor}), expected);
+}
+
+function test_rtf_formatBestUnit() {
+  {
+    // format seconds-distant dates
+    let anchor = new Date("2016-04-10 12:00:00");
+    testRTFBestUnit(anchor, "2016-04-10 11:59:01", "59 seconds ago");
+    testRTFBestUnit(anchor, "2016-04-10 12:00:00", "now");
+    testRTFBestUnit(anchor, "2016-04-10 12:00:59", "in 59 seconds");
+  }
+
+  {
+    // format minutes-distant dates
+    let anchor = new Date("2016-04-10 12:00:00");
+    testRTFBestUnit(anchor, "2016-04-10 11:01:00", "59 minutes ago");
+    testRTFBestUnit(anchor, "2016-04-10 11:59", "1 minute ago");
+    testRTFBestUnit(anchor, "2016-04-10 12:01", "in 1 minute");
+    testRTFBestUnit(anchor, "2016-04-10 12:01:59", "in 1 minute");
+    testRTFBestUnit(anchor, "2016-04-10 12:59:59", "in 59 minutes");
+  }
+
+  {
+    // format hours-distant dates
+    let anchor = new Date("2016-04-10 12:00:00");
+    testRTFBestUnit(anchor, "2016-04-10 00:00", "12 hours ago");
+    testRTFBestUnit(anchor, "2016-04-10 13:00", "in 1 hour");
+    testRTFBestUnit(anchor, "2016-04-10 13:59:59", "in 1 hour");
+    testRTFBestUnit(anchor, "2016-04-10 23:59:59", "in 11 hours");
+
+    anchor = new Date("2016-04-10 01:00");
+    testRTFBestUnit(anchor, "2016-04-09 19:00", "6 hours ago");
+    testRTFBestUnit(anchor, "2016-04-09 18:00", "yesterday");
+
+    anchor = new Date("2016-04-10 23:00");
+    testRTFBestUnit(anchor, "2016-04-11 05:00", "in 6 hours");
+    testRTFBestUnit(anchor, "2016-04-11 06:00", "tomorrow");
+
+    anchor = new Date("2016-01-31 23:00");
+    testRTFBestUnit(anchor, "2016-02-01 05:00", "in 6 hours");
+    testRTFBestUnit(anchor, "2016-02-01 07:00", "tomorrow");
+
+    anchor = new Date("2016-12-31 23:00");
+    testRTFBestUnit(anchor, "2017-01-01 05:00", "in 6 hours");
+    testRTFBestUnit(anchor, "2017-01-01 07:00", "tomorrow");
+  }
+
+  {
+    // format days-distant dates
+    let anchor = new Date("2016-04-10 12:00:00");
+    testRTFBestUnit(anchor, "2016-04-01 00:00", "9 days ago");
+    testRTFBestUnit(anchor, "2016-04-09 18:00", "yesterday");
+    testRTFBestUnit(anchor, "2016-04-11 09:00", "tomorrow");
+    testRTFBestUnit(anchor, "2016-04-30 23:59", "in 20 days");
+    testRTFBestUnit(anchor, "2016-03-31 23:59", "last month");
+    testRTFBestUnit(anchor, "2016-05-01 00:00", "next month");
+
+    anchor = new Date("2016-04-06 12:00");
+    testRTFBestUnit(anchor, "2016-03-31 23:59", "6 days ago");
+
+    anchor = new Date("2016-04-25 23:00");
+    testRTFBestUnit(anchor, "2016-05-01 00:00", "in 6 days");
+  }
+
+  {
+    // format months-distant dates
+    let anchor = new Date("2016-04-10 12:00:00");
+    testRTFBestUnit(anchor, "2016-01-01 00:00", "3 months ago");
+    testRTFBestUnit(anchor, "2016-03-01 00:00", "last month");
+    testRTFBestUnit(anchor, "2016-05-01 00:00", "next month");
+    testRTFBestUnit(anchor, "2016-12-01 23:59", "in 8 months");
+
+    anchor = new Date("2017-01-12 18:30");
+    testRTFBestUnit(anchor, "2016-12-29 18:30", "last month");
+
+    anchor = new Date("2016-12-29 18:30");
+    testRTFBestUnit(anchor, "2017-01-12 18:30", "next month");
+
+    anchor = new Date("2016-02-28 12:00");
+    testRTFBestUnit(anchor, "2015-12-31 23:59", "2 months ago");
+  }
+
+  {
+    // format year-distant dates
+    let anchor = new Date("2016-04-10 12:00:00");
+    testRTFBestUnit(anchor, "2014-04-01 00:00", "2 years ago");
+    testRTFBestUnit(anchor, "2015-03-01 00:00", "last year");
+    testRTFBestUnit(anchor, "2017-05-01 00:00", "next year");
+    testRTFBestUnit(anchor, "2024-12-01 23:59", "in 8 years");
+
+    anchor = new Date("2017-01-12 18:30");
+    testRTFBestUnit(anchor, "2016-01-01 18:30", "last year");
+    testRTFBestUnit(anchor, "2015-12-29 18:30", "2 years ago");
+
+    anchor = new Date("2016-12-29 18:30");
+    testRTFBestUnit(anchor, "2017-07-12 18:30", "next year");
+    testRTFBestUnit(anchor, "2017-02-12 18:30", "in 2 months");
+    testRTFBestUnit(anchor, "2018-01-02 18:30", "in 2 years");
+
+    testRTFBestUnit(anchor, "2098-01-02 18:30", "in 82 years");
+  }
+}
--- a/toolkit/components/mozintl/test/test_mozintlhelper.js
+++ b/toolkit/components/mozintl/test/test_mozintlhelper.js
@@ -32,23 +32,27 @@ function test_cross_global(miHelper) {
   equal(waivedX.getCalendarInfo() instanceof global.Object, true);
 }
 
 function test_methods_presence(miHelper) {
   equal(miHelper.addGetCalendarInfo instanceof Function, true);
   equal(miHelper.addGetDisplayNames instanceof Function, true);
   equal(miHelper.addGetLocaleInfo instanceof Function, true);
   equal(miHelper.addDateTimeFormatConstructor instanceof Function, true);
+  equal(miHelper.addRelativeTimeFormatConstructor instanceof Function, true);
 
   let x = {};
 
   miHelper.addGetCalendarInfo(x);
   equal(x.getCalendarInfo instanceof Function, true);
 
   miHelper.addGetDisplayNames(x);
   equal(x.getDisplayNames instanceof Function, true);
 
   miHelper.addGetLocaleInfo(x);
   equal(x.getLocaleInfo instanceof Function, true);
 
   miHelper.addDateTimeFormatConstructor(x);
   equal(x.DateTimeFormat instanceof Function, true);
+
+  miHelper.addRelativeTimeFormatConstructor(x);
+  equal(x.RelativeTimeFormat instanceof Function, true);
 }
--- a/toolkit/content/license.html
+++ b/toolkit/content/license.html
@@ -5720,16 +5720,50 @@ FITNESS FOR A PARTICULAR PURPOSE AND NON
 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 </pre>
 
 <hr>
 
+<h1><a id="relative-time"></a>relative-time License</h1>
+
+<p>This license applies to the file
+<code>toolkit/components/mozintl/mozIntl.js
+</code>.</p>
+<pre>
+The MIT License (MIT)
+
+Copyright (c) 2016 Rafael Xavier de Souza http://rafael.xavier.blog.br
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+</pre>
+
+<hr>
+
 <h1><a id="reselect"></a>Reselect License</h1>
 
 <p>This license applies to the file
 <code>devtools/client/shared/vendor/reselect.js</code>.</p>
 <pre>
 The MIT License (MIT)
 
 Copyright (c) 2015-2016 Reselect Contributors