From 6eb75044423a72a204bc450e448e19b65e4155ed Mon Sep 17 00:00:00 2001 From: picklesrus Date: Thu, 12 Nov 2020 14:21:23 -0500 Subject: [PATCH 1/5] Initial time formatting pass. Does integer minutes, hours, days. TODOS: - set langauge appropriately - do partial hours and days. e.g. 1 hour 5 minutes instead of just 1 hour. Add a time formatting utility that formats a relative time in the future. Use it in the mute modal & comment box. --- src/lib/format-time.js | 114 ++++++++++++ src/views/preview/comment/compose-comment.jsx | 9 +- test/unit/components/compose-comment.test.jsx | 10 +- test/unit/lib/format-time.test.js | 167 ++++++++++++++++++ 4 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 src/lib/format-time.js create mode 100644 test/unit/lib/format-time.test.js diff --git a/src/lib/format-time.js b/src/lib/format-time.js new file mode 100644 index 000000000..ceb0684ca --- /dev/null +++ b/src/lib/format-time.js @@ -0,0 +1,114 @@ +/** + Given a timestamp in the future, calculate the largest, closest unit to show. + On the high end we stop at days. e.g. 56 days is still counted in days not months or weeks. + On the low end we stop at minutes. + @param {number} timeStamp A future time stamp in ms. + @returns {object} containing the unit (min, hours, days) and how many. e.g. {unit: minutes, duration: 3} + */ +const getTopLevelTimeUnit = timeStamp => { + const diff = timeStamp - Date.now(); + const oneDayInMs = 1000 * 60 * 60 * 24; + const oneHourInMs = 1000 * 60 * 60; + const oneMinuteInMs = 1000 * 60; + let unit = 'minutes'; + let duration = diff / oneMinuteInMs; + if (diff > oneDayInMs) { + unit = 'days'; + duration = diff / oneDayInMs; + } else if (diff > oneHourInMs) { + unit = 'hours'; + duration = diff / oneHourInMs; + } + return { + unit: unit, + duration: duration + }; +}; + +/** +* This is used along with Intl.RelativeTimeFormat's formatToParts to extract +* the value from a given key. +* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/formatToParts +* Also see examples in format-time.test.jsx. +* @param {string} key The name of the key you want to look up +* @param {Array} parts An array of parts from formatToParts. +* @returns {string} The value associated with the given key. +*/ +const getValueFromKey = (key, parts) => { + for (const item of parts) { + if (item.type === key) { + return item.value; + } + } + // Just in case key doesn't exist. + return ''; +}; + +/** +* Given a future timestamp and a langauge, constructs a phrase to describe that time relative to now. +* e.g. 2 days 3 hours, 3 minutes, 7 hours 38 minutes, 2 horas 5 minutos. +* The largest time unit is days, the smallest is minutes. +* @param {number} futureTime a timestamp in ms to build a phrase for. +* @param {string} lang Langauge to build the phrase in. +* @returns {string} A phrase representing the relative time in the future. e.g. 3 days 5 hours. +*/ +module.exports.formatTimeUntil = (futureTime, lang) => { + const formatter = new Intl.RelativeTimeFormat(lang, { + localeMatcher: 'best fit', + numeric: 'always', + style: 'long' + }); + + const timeInfo = getTopLevelTimeUnit(futureTime); + let str = ''; + const parts = formatter.formatToParts(timeInfo.duration, + timeInfo.unit); + // This shouldn't happen, but is here for extra safety. + if (parts.length === 0) { + return ''; + } + // Extract the whole number from formatToParts list. + const topLevelValue = getValueFromKey('integer', parts); + const units = parts[parts.length - 1].value; + + // Add value and unit to the string. e.g. 3 hours or 2 minutes + str = `${topLevelValue}${units}`; + + // Convert the part after the decimal to a number so we can use it to calculate the next unit + const remainder = parseFloat(`.${getValueFromKey('fraction', parts)}`); + + // The smallest unit we show is minutes so we can stop if we're already there + // or, our remainder is smaller than the next level down. + // e.g. if it is 1 hour 30 sec, we just show 1 hour or + // if it is 1 day 45 min, we only show 1 day. + if (timeInfo.unit === 'minutes' || + (timeInfo.unit === 'hours' && remainder < 1.0 / 60) || + (timeInfo.unit === 'days' && remainder < 1.0 / 24)) { + return str; + } + + // Now we need to go figure out what the second part of the string is + // e.g. if we're 2 days 3 hours in the future, at this point we have "2 days" + // and need to figure out how many hours. + let remainingTime = 0; + let unitsOfRemainingTime = ''; + + if (timeInfo.unit === 'hours') { + remainingTime = remainder * 60; + unitsOfRemainingTime = 'minutes'; + } else if (timeInfo.unit === 'days') { + remainingTime = remainder * 24; + unitsOfRemainingTime = 'hours'; + } + + const remainingParts = formatter.formatToParts(remainingTime, unitsOfRemainingTime); + + // This shouldn't happen, but is here for extra safety. + if (remainingParts.length === 0) { + return str; + } + // Concatenate 2nd level value and unit. e.g. add "3 hours" to "1 day" to get "1 day 3 hours". + str = `${str}${getValueFromKey('integer', remainingParts)}${remainingParts[remainingParts.length - 1].value}`; + + return str; +}; diff --git a/src/views/preview/comment/compose-comment.jsx b/src/views/preview/comment/compose-comment.jsx index 9de5d1e59..59f949fa4 100644 --- a/src/views/preview/comment/compose-comment.jsx +++ b/src/views/preview/comment/compose-comment.jsx @@ -12,6 +12,7 @@ const InplaceInput = require('../../../components/forms/inplace-input.jsx'); const Button = require('../../../components/forms/button.jsx'); const CommentingStatus = require('../../../components/commenting-status/commenting-status.jsx'); const MuteModal = require('../../../components/modal/mute/modal.jsx'); +const formatTime = require('../../../lib/format-time'); const connect = require('react-redux').connect; @@ -79,7 +80,7 @@ class ComposeComment extends React.Component { let muteExpiresAt = 0; let rejectedStatus = ComposeStatus.REJECTED; if (body.status && body.status.mute_status) { - muteExpiresAt = body.status.mute_status.muteExpiresAt; + muteExpiresAt = body.status.mute_status.muteExpiresAt * 1000; // convert to ms rejectedStatus = ComposeStatus.REJECTED_MUTE; if (this.shouldShowMuteModal(body.status.mute_status.offenses)) { muteOpen = true; @@ -165,9 +166,9 @@ class ComposeComment extends React.Component {

Scratch thinks your comment was disrespectful.

- For the next {this.state.muteExpiresAt} you + For the next {formatTime.formatTimeUntil(this.state.muteExpiresAt, window._locale)} you won't be able to post comments. - Once {this.state.muteExpiresAt} have passed, + Once {formatTime.formatTimeUntil(this.state.muteExpiresAt, window._locale)} have passed, you will be able to comment again.

For more information, @@ -248,7 +249,7 @@ class ComposeComment extends React.Component { useStandardSizes className="mod-mute" shouldCloseOnOverlayClick={false} - timeMuted={this.state.muteExpiresAt} + timeMuted={formatTime.formatTimeUntil(this.state.muteExpiresAt, window._locale)} onRequestClose={this.handleMuteClose} /> ) : null} diff --git a/test/unit/components/compose-comment.test.jsx b/test/unit/components/compose-comment.test.jsx index 0d35d191d..1431e4e97 100644 --- a/test/unit/components/compose-comment.test.jsx +++ b/test/unit/components/compose-comment.test.jsx @@ -3,9 +3,9 @@ const {shallowWithIntl} = require('../../helpers/intl-helpers.jsx'); const ComposeComment = require('../../../src/views/preview/comment/compose-comment.jsx'); import configureStore from 'redux-mock-store'; - describe('Compose Comment test', () => { const mockStore = configureStore(); + let _mockFormat; const defaultProps = () =>({ user: { thumbnailUrl: 'scratch.mit.edu', @@ -15,6 +15,14 @@ describe('Compose Comment test', () => { let store; beforeEach(() => { + const mockFormatToParts = { + formatToParts: jest.fn() + }; + _mockFormat = Intl.RelativeTimeFormat = jest + .fn() + .mockImplementation(() => mockFormatToParts); + mockFormatToParts.formatToParts.mockReturnValue([]); + store = mockStore({ session: { session: { diff --git a/test/unit/lib/format-time.test.js b/test/unit/lib/format-time.test.js new file mode 100644 index 000000000..d128b02d9 --- /dev/null +++ b/test/unit/lib/format-time.test.js @@ -0,0 +1,167 @@ +const format = require('../../../src/lib/format-time'); + +describe('unit test lib/format-time.js', () => { + let realDateNow; + let _mockFormat; + const mockFormatExpression = { + formatToParts: jest.fn() + }; + beforeEach(() =>{ + realDateNow = Date.now.bind(global.Date); + global.Date.now = () => 0; + _mockFormat = Intl.RelativeTimeFormat = jest + .fn() + .mockImplementation(() => mockFormatExpression); + + }); + afterEach(()=>{ + global.Date.now = realDateNow; + jest.resetAllMocks(); + }); + + test('test timestamp that is 2 minutes in the future', () => { + let response; + const twoMin = 2 * 60 * 1000; + const formatToPartsResponse = [ + {type: 'literal', value: 'in'}, + {type: 'integer', value: '2', unit: 'minute'}, + {type: 'decimal', value: '.', unit: 'minute'}, + {type: 'fraction', value: '119', unit: 'minute'}, + {type: 'literal', value: 'minutes'} + ]; + mockFormatExpression.formatToParts.mockReturnValue(formatToPartsResponse); + + response = format.formatTimeUntil(twoMin, 'en'); + + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'minutes'); + + expect(response).toEqual('2minutes'); + }); + + test('test timestamp that is 2 hours in the future', () => { + let response; + const twoHours = 2 * 60 * 60 * 1000; + + const formatToPartsResponse = [ + {type: 'literal', value: 'in'}, + {type: 'integer', value: '2', unit: 'hours'}, + {type: 'decimal', value: '.', unit: 'hours'}, + {type: 'fraction', value: '0', unit: 'hours'}, + {type: 'literal', value: 'hours'} + ]; + mockFormatExpression.formatToParts.mockReturnValue(formatToPartsResponse); + response = format.formatTimeUntil(twoHours, 'en'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'hours'); + expect(response).toEqual('2hours'); + }); + + test('test timestamp that is exactly 2 days in the future', () => { + let response; + const twoDays = 2 * 60 * 60 * 24 * 1000; + const formatToPartsResponse = [ + {type: 'literal', value: 'in'}, + {type: 'integer', value: '2', unit: 'days'}, + {type: 'decimal', value: '.', unit: 'days'}, + {type: 'fraction', value: '0', unit: 'days'}, + {type: 'literal', value: 'days'} + ]; + mockFormatExpression.formatToParts.mockReturnValue(formatToPartsResponse); + + response = format.formatTimeUntil(twoDays, 'en'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'days'); + expect(response).toEqual('2days'); + }); + + + test('test timestamp that is 2.5 days in the future', () => { + let response; + const twoDays = 2.5 * 60 * 60 * 24 * 1000; + const formatToPartsResponseTwoAndAHalfDays = [ + {type: 'literal', value: 'in'}, + {type: 'integer', value: '2', unit: 'days'}, + {type: 'decimal', value: '.', unit: 'days'}, + {type: 'fraction', value: '5', unit: 'days'}, + {type: 'literal', value: 'days'} + ]; + const formatToPartsResponseTwelveHours = [ + {type: 'literal', value: 'in'}, + {type: 'integer', value: '12', unit: 'hours'}, + {type: 'decimal', value: '.', unit: 'hours'}, + {type: 'fraction', value: '0', unit: 'hours'}, + {type: 'literal', value: 'hours'} + ]; + mockFormatExpression.formatToParts + .mockReturnValueOnce(formatToPartsResponseTwoAndAHalfDays) + .mockReturnValueOnce(formatToPartsResponseTwelveHours); + + response = format.formatTimeUntil(twoDays, 'en'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2.5, 'days'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(12, 'hours'); + expect(response).toEqual('2days12hours'); + }); + + test('test timestamp that is 3.5 hours in the future', () => { + let response; + const twoDays = 3.5 * 60 * 60 * 1000; + const formatToPartsResponseOne = [ + {type: 'literal', value: 'in'}, + {type: 'integer', value: '3', unit: 'hours'}, + {type: 'decimal', value: '.', unit: 'hours'}, + {type: 'fraction', value: '5', unit: 'hours'}, + {type: 'literal', value: 'hours'} + ]; + const formatToPartsResponseTwo = [ + {type: 'literal', value: 'in'}, + {type: 'integer', value: '30', unit: 'minutes'}, + {type: 'decimal', value: '.', unit: 'minutes'}, + {type: 'fraction', value: '0', unit: 'minutes'}, + {type: 'literal', value: 'minutes'} + ]; + mockFormatExpression.formatToParts + .mockReturnValueOnce(formatToPartsResponseOne) + .mockReturnValueOnce(formatToPartsResponseTwo); + + response = format.formatTimeUntil(twoDays, 'en'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(3.5, 'hours'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(30, 'minutes'); + expect(response).toEqual('3hours30minutes'); + }); + + test('test timestamp that is 1 day and less than an hour in the future', () => { + let response; + const aDayand10Min = 1.007 * 60 * 60 * 24 * 1000; + const formatToPartsResponse = [ + {type: 'literal', value: 'in'}, + {type: 'integer', value: '1', unit: 'days'}, + {type: 'decimal', value: '.', unit: 'days'}, + {type: 'fraction', value: '0.007', unit: 'days'}, + {type: 'literal', value: 'days'} + ]; + + mockFormatExpression.formatToParts + .mockReturnValueOnce(formatToPartsResponse); + + response = format.formatTimeUntil(aDayand10Min, 'en'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(1.007, 'days'); + expect(response).toEqual('1days'); + }); + + test('test timestamp that is hour and less than a minute in the future', () => { + let response; + const anHourAnd30Sec = 1.008 * 60 * 60 * 1000; + const formatToPartsResponse = [ + {type: 'literal', value: 'in'}, + {type: 'integer', value: '1', unit: 'hours'}, + {type: 'decimal', value: '.', unit: 'hours'}, + {type: 'fraction', value: '0.008', unit: 'hours'}, + {type: 'literal', value: 'hours'} + ]; + + mockFormatExpression.formatToParts + .mockReturnValueOnce(formatToPartsResponse); + + response = format.formatTimeUntil(anHourAnd30Sec, 'en'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(1.008, 'hours'); + expect(response).toEqual('1hours'); + }); +}); From 13369c3809a67d35fd143e008d014aadf62cd778 Mon Sep 17 00:00:00 2001 From: picklesrus Date: Fri, 13 Nov 2020 08:37:18 -0500 Subject: [PATCH 2/5] add spaces --- src/lib/format-time.js | 4 ++-- test/unit/lib/format-time.test.js | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/lib/format-time.js b/src/lib/format-time.js index ceb0684ca..91f72381d 100644 --- a/src/lib/format-time.js +++ b/src/lib/format-time.js @@ -72,7 +72,7 @@ module.exports.formatTimeUntil = (futureTime, lang) => { const units = parts[parts.length - 1].value; // Add value and unit to the string. e.g. 3 hours or 2 minutes - str = `${topLevelValue}${units}`; + str = `${topLevelValue} ${units}`; // Convert the part after the decimal to a number so we can use it to calculate the next unit const remainder = parseFloat(`.${getValueFromKey('fraction', parts)}`); @@ -108,7 +108,7 @@ module.exports.formatTimeUntil = (futureTime, lang) => { return str; } // Concatenate 2nd level value and unit. e.g. add "3 hours" to "1 day" to get "1 day 3 hours". - str = `${str}${getValueFromKey('integer', remainingParts)}${remainingParts[remainingParts.length - 1].value}`; + str = `${str} ${getValueFromKey('integer', remainingParts)} ${remainingParts[remainingParts.length - 1].value}`; return str; }; diff --git a/test/unit/lib/format-time.test.js b/test/unit/lib/format-time.test.js index d128b02d9..3f64ec1c5 100644 --- a/test/unit/lib/format-time.test.js +++ b/test/unit/lib/format-time.test.js @@ -35,7 +35,7 @@ describe('unit test lib/format-time.js', () => { expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'minutes'); - expect(response).toEqual('2minutes'); + expect(response).toEqual('2 minutes'); }); test('test timestamp that is 2 hours in the future', () => { @@ -52,7 +52,7 @@ describe('unit test lib/format-time.js', () => { mockFormatExpression.formatToParts.mockReturnValue(formatToPartsResponse); response = format.formatTimeUntil(twoHours, 'en'); expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'hours'); - expect(response).toEqual('2hours'); + expect(response).toEqual('2 hours'); }); test('test timestamp that is exactly 2 days in the future', () => { @@ -69,7 +69,7 @@ describe('unit test lib/format-time.js', () => { response = format.formatTimeUntil(twoDays, 'en'); expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'days'); - expect(response).toEqual('2days'); + expect(response).toEqual('2 days'); }); @@ -97,7 +97,7 @@ describe('unit test lib/format-time.js', () => { response = format.formatTimeUntil(twoDays, 'en'); expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2.5, 'days'); expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(12, 'hours'); - expect(response).toEqual('2days12hours'); + expect(response).toEqual('2 days 12 hours'); }); test('test timestamp that is 3.5 hours in the future', () => { @@ -124,7 +124,7 @@ describe('unit test lib/format-time.js', () => { response = format.formatTimeUntil(twoDays, 'en'); expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(3.5, 'hours'); expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(30, 'minutes'); - expect(response).toEqual('3hours30minutes'); + expect(response).toEqual('3 hours 30 minutes'); }); test('test timestamp that is 1 day and less than an hour in the future', () => { @@ -143,7 +143,7 @@ describe('unit test lib/format-time.js', () => { response = format.formatTimeUntil(aDayand10Min, 'en'); expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(1.007, 'days'); - expect(response).toEqual('1days'); + expect(response).toEqual('1 days'); }); test('test timestamp that is hour and less than a minute in the future', () => { @@ -162,6 +162,6 @@ describe('unit test lib/format-time.js', () => { response = format.formatTimeUntil(anHourAnd30Sec, 'en'); expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(1.008, 'hours'); - expect(response).toEqual('1hours'); + expect(response).toEqual('1 hours'); }); }); From bfc4202d11993b0fca72689ffec8f78966c27d49 Mon Sep 17 00:00:00 2001 From: picklesrus Date: Fri, 13 Nov 2020 14:14:42 -0500 Subject: [PATCH 3/5] Use singular units so the library handles plurals. --- src/lib/format-time.js | 20 ++++++++++---------- test/unit/lib/format-time.test.js | 18 +++++++++--------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/lib/format-time.js b/src/lib/format-time.js index 91f72381d..d2dbb0e3a 100644 --- a/src/lib/format-time.js +++ b/src/lib/format-time.js @@ -10,13 +10,13 @@ const getTopLevelTimeUnit = timeStamp => { const oneDayInMs = 1000 * 60 * 60 * 24; const oneHourInMs = 1000 * 60 * 60; const oneMinuteInMs = 1000 * 60; - let unit = 'minutes'; + let unit = 'minute'; let duration = diff / oneMinuteInMs; if (diff > oneDayInMs) { - unit = 'days'; + unit = 'day'; duration = diff / oneDayInMs; } else if (diff > oneHourInMs) { - unit = 'hours'; + unit = 'hour'; duration = diff / oneHourInMs; } return { @@ -81,9 +81,9 @@ module.exports.formatTimeUntil = (futureTime, lang) => { // or, our remainder is smaller than the next level down. // e.g. if it is 1 hour 30 sec, we just show 1 hour or // if it is 1 day 45 min, we only show 1 day. - if (timeInfo.unit === 'minutes' || - (timeInfo.unit === 'hours' && remainder < 1.0 / 60) || - (timeInfo.unit === 'days' && remainder < 1.0 / 24)) { + if (timeInfo.unit === 'minute' || + (timeInfo.unit === 'hour' && remainder < 1.0 / 60) || + (timeInfo.unit === 'day' && remainder < 1.0 / 24)) { return str; } @@ -93,12 +93,12 @@ module.exports.formatTimeUntil = (futureTime, lang) => { let remainingTime = 0; let unitsOfRemainingTime = ''; - if (timeInfo.unit === 'hours') { + if (timeInfo.unit === 'hour') { remainingTime = remainder * 60; - unitsOfRemainingTime = 'minutes'; - } else if (timeInfo.unit === 'days') { + unitsOfRemainingTime = 'minute'; + } else if (timeInfo.unit === 'day') { remainingTime = remainder * 24; - unitsOfRemainingTime = 'hours'; + unitsOfRemainingTime = 'hour'; } const remainingParts = formatter.formatToParts(remainingTime, unitsOfRemainingTime); diff --git a/test/unit/lib/format-time.test.js b/test/unit/lib/format-time.test.js index 3f64ec1c5..8923e973e 100644 --- a/test/unit/lib/format-time.test.js +++ b/test/unit/lib/format-time.test.js @@ -33,7 +33,7 @@ describe('unit test lib/format-time.js', () => { response = format.formatTimeUntil(twoMin, 'en'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'minutes'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'minute'); expect(response).toEqual('2 minutes'); }); @@ -51,7 +51,7 @@ describe('unit test lib/format-time.js', () => { ]; mockFormatExpression.formatToParts.mockReturnValue(formatToPartsResponse); response = format.formatTimeUntil(twoHours, 'en'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'hours'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'hour'); expect(response).toEqual('2 hours'); }); @@ -68,7 +68,7 @@ describe('unit test lib/format-time.js', () => { mockFormatExpression.formatToParts.mockReturnValue(formatToPartsResponse); response = format.formatTimeUntil(twoDays, 'en'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'days'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'day'); expect(response).toEqual('2 days'); }); @@ -95,8 +95,8 @@ describe('unit test lib/format-time.js', () => { .mockReturnValueOnce(formatToPartsResponseTwelveHours); response = format.formatTimeUntil(twoDays, 'en'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2.5, 'days'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(12, 'hours'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2.5, 'day'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(12, 'hour'); expect(response).toEqual('2 days 12 hours'); }); @@ -122,8 +122,8 @@ describe('unit test lib/format-time.js', () => { .mockReturnValueOnce(formatToPartsResponseTwo); response = format.formatTimeUntil(twoDays, 'en'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(3.5, 'hours'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(30, 'minutes'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(3.5, 'hour'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(30, 'minute'); expect(response).toEqual('3 hours 30 minutes'); }); @@ -142,7 +142,7 @@ describe('unit test lib/format-time.js', () => { .mockReturnValueOnce(formatToPartsResponse); response = format.formatTimeUntil(aDayand10Min, 'en'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(1.007, 'days'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(1.007, 'day'); expect(response).toEqual('1 days'); }); @@ -161,7 +161,7 @@ describe('unit test lib/format-time.js', () => { .mockReturnValueOnce(formatToPartsResponse); response = format.formatTimeUntil(anHourAnd30Sec, 'en'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(1.008, 'hours'); + expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(1.008, 'hour'); expect(response).toEqual('1 hours'); }); }); From b450d36a6422b17d92312710d2d944bd10a3a33d Mon Sep 17 00:00:00 2001 From: picklesrus Date: Mon, 30 Nov 2020 16:16:52 -0500 Subject: [PATCH 4/5] Redo time based on new requirements. --- src/components/modal/mute/modal.jsx | 4 +- src/lib/format-time.js | 95 ++-------- src/views/preview/comment/compose-comment.jsx | 10 +- test/unit/components/compose-comment.test.jsx | 10 +- test/unit/lib/format-time.test.js | 162 +++++------------- 5 files changed, 65 insertions(+), 216 deletions(-) diff --git a/src/components/modal/mute/modal.jsx b/src/components/modal/mute/modal.jsx index 80aed3e6c..ebbb3627e 100644 --- a/src/components/modal/mute/modal.jsx +++ b/src/components/modal/mute/modal.jsx @@ -57,12 +57,12 @@ class MuteModal extends React.Component {

- Once {this.props.timeMuted} have passed, you will be able to comment again. + Your account has been paused from commenting until then.

If you would like more information, you can read diff --git a/src/lib/format-time.js b/src/lib/format-time.js index d2dbb0e3a..987077521 100644 --- a/src/lib/format-time.js +++ b/src/lib/format-time.js @@ -1,114 +1,45 @@ /** Given a timestamp in the future, calculate the largest, closest unit to show. - On the high end we stop at days. e.g. 56 days is still counted in days not months or weeks. + On the high end we stop at hours. e.g. 15 days is still counted in hours not days or weeks. On the low end we stop at minutes. + This rounds duration to the nearest integer. e.g. 5.7 minutes => will return 6 as duration. @param {number} timeStamp A future time stamp in ms. - @returns {object} containing the unit (min, hours, days) and how many. e.g. {unit: minutes, duration: 3} + @returns {object} containing the unit (min, hours) and how many. e.g. {unit: minutes, duration: 3} */ -const getTopLevelTimeUnit = timeStamp => { +const getTimeUnitAndDuration = timeStamp => { const diff = timeStamp - Date.now(); - const oneDayInMs = 1000 * 60 * 60 * 24; const oneHourInMs = 1000 * 60 * 60; const oneMinuteInMs = 1000 * 60; + let unit = 'minute'; let duration = diff / oneMinuteInMs; - if (diff > oneDayInMs) { - unit = 'day'; - duration = diff / oneDayInMs; - } else if (diff > oneHourInMs) { + // We show minutes up to 2 hours, then switch to hours. + if (diff >= 2 * oneHourInMs) { unit = 'hour'; duration = diff / oneHourInMs; } + // Round to nearest hour or minute. + duration = Math.round(duration); return { unit: unit, duration: duration }; }; -/** -* This is used along with Intl.RelativeTimeFormat's formatToParts to extract -* the value from a given key. -* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/formatToParts -* Also see examples in format-time.test.jsx. -* @param {string} key The name of the key you want to look up -* @param {Array} parts An array of parts from formatToParts. -* @returns {string} The value associated with the given key. -*/ -const getValueFromKey = (key, parts) => { - for (const item of parts) { - if (item.type === key) { - return item.value; - } - } - // Just in case key doesn't exist. - return ''; -}; - /** * Given a future timestamp and a langauge, constructs a phrase to describe that time relative to now. -* e.g. 2 days 3 hours, 3 minutes, 7 hours 38 minutes, 2 horas 5 minutos. +* e.g. in 2 days, in 3 minutes, en 2 horas. * The largest time unit is days, the smallest is minutes. * @param {number} futureTime a timestamp in ms to build a phrase for. * @param {string} lang Langauge to build the phrase in. * @returns {string} A phrase representing the relative time in the future. e.g. 3 days 5 hours. */ -module.exports.formatTimeUntil = (futureTime, lang) => { +module.exports.formatRelativeTime = (futureTime, lang) => { const formatter = new Intl.RelativeTimeFormat(lang, { localeMatcher: 'best fit', numeric: 'always', style: 'long' }); - - const timeInfo = getTopLevelTimeUnit(futureTime); - let str = ''; - const parts = formatter.formatToParts(timeInfo.duration, - timeInfo.unit); - // This shouldn't happen, but is here for extra safety. - if (parts.length === 0) { - return ''; - } - // Extract the whole number from formatToParts list. - const topLevelValue = getValueFromKey('integer', parts); - const units = parts[parts.length - 1].value; - - // Add value and unit to the string. e.g. 3 hours or 2 minutes - str = `${topLevelValue} ${units}`; - - // Convert the part after the decimal to a number so we can use it to calculate the next unit - const remainder = parseFloat(`.${getValueFromKey('fraction', parts)}`); - - // The smallest unit we show is minutes so we can stop if we're already there - // or, our remainder is smaller than the next level down. - // e.g. if it is 1 hour 30 sec, we just show 1 hour or - // if it is 1 day 45 min, we only show 1 day. - if (timeInfo.unit === 'minute' || - (timeInfo.unit === 'hour' && remainder < 1.0 / 60) || - (timeInfo.unit === 'day' && remainder < 1.0 / 24)) { - return str; - } - - // Now we need to go figure out what the second part of the string is - // e.g. if we're 2 days 3 hours in the future, at this point we have "2 days" - // and need to figure out how many hours. - let remainingTime = 0; - let unitsOfRemainingTime = ''; - - if (timeInfo.unit === 'hour') { - remainingTime = remainder * 60; - unitsOfRemainingTime = 'minute'; - } else if (timeInfo.unit === 'day') { - remainingTime = remainder * 24; - unitsOfRemainingTime = 'hour'; - } - - const remainingParts = formatter.formatToParts(remainingTime, unitsOfRemainingTime); - - // This shouldn't happen, but is here for extra safety. - if (remainingParts.length === 0) { - return str; - } - // Concatenate 2nd level value and unit. e.g. add "3 hours" to "1 day" to get "1 day 3 hours". - str = `${str} ${getValueFromKey('integer', remainingParts)} ${remainingParts[remainingParts.length - 1].value}`; - - return str; + const timeInfo = getTimeUnitAndDuration(futureTime); + return formatter.format(timeInfo.duration, timeInfo.unit); }; diff --git a/src/views/preview/comment/compose-comment.jsx b/src/views/preview/comment/compose-comment.jsx index 59f949fa4..cbe03931f 100644 --- a/src/views/preview/comment/compose-comment.jsx +++ b/src/views/preview/comment/compose-comment.jsx @@ -165,11 +165,9 @@ class ComposeComment extends React.Component {

Scratch thinks your comment was disrespectful.

-

- For the next {formatTime.formatTimeUntil(this.state.muteExpiresAt, window._locale)} you - won't be able to post comments. - Once {formatTime.formatTimeUntil(this.state.muteExpiresAt, window._locale)} have passed, - you will be able to comment again. +

You will be able to comment + again {formatTime.formatRelativeTime(this.state.muteExpiresAt, window._locale)}. + Your account has been paused from commenting until then.

For more information, ) : null} diff --git a/test/unit/components/compose-comment.test.jsx b/test/unit/components/compose-comment.test.jsx index 1431e4e97..a251e3e01 100644 --- a/test/unit/components/compose-comment.test.jsx +++ b/test/unit/components/compose-comment.test.jsx @@ -15,14 +15,14 @@ describe('Compose Comment test', () => { let store; beforeEach(() => { - const mockFormatToParts = { - formatToParts: jest.fn() + const mockFormat = { + format: jest.fn() }; _mockFormat = Intl.RelativeTimeFormat = jest .fn() - .mockImplementation(() => mockFormatToParts); - mockFormatToParts.formatToParts.mockReturnValue([]); - + .mockImplementation(() => mockFormat); + mockFormat.format.mockReturnValue(''); + store = mockStore({ session: { session: { diff --git a/test/unit/lib/format-time.test.js b/test/unit/lib/format-time.test.js index 8923e973e..cd13ebc0a 100644 --- a/test/unit/lib/format-time.test.js +++ b/test/unit/lib/format-time.test.js @@ -4,7 +4,7 @@ describe('unit test lib/format-time.js', () => { let realDateNow; let _mockFormat; const mockFormatExpression = { - formatToParts: jest.fn() + format: jest.fn() }; beforeEach(() =>{ realDateNow = Date.now.bind(global.Date); @@ -22,146 +22,66 @@ describe('unit test lib/format-time.js', () => { test('test timestamp that is 2 minutes in the future', () => { let response; const twoMin = 2 * 60 * 1000; - const formatToPartsResponse = [ - {type: 'literal', value: 'in'}, - {type: 'integer', value: '2', unit: 'minute'}, - {type: 'decimal', value: '.', unit: 'minute'}, - {type: 'fraction', value: '119', unit: 'minute'}, - {type: 'literal', value: 'minutes'} - ]; - mockFormatExpression.formatToParts.mockReturnValue(formatToPartsResponse); - - response = format.formatTimeUntil(twoMin, 'en'); - - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'minute'); - - expect(response).toEqual('2 minutes'); + mockFormatExpression.format.mockReturnValue('in 2 minutes'); + response = format.formatRelativeTime(twoMin, 'en'); + expect(mockFormatExpression.format).toHaveBeenCalledWith(2, 'minute'); + expect(response).toEqual('in 2 minutes'); }); - test('test timestamp that is 2 hours in the future', () => { + test('test rounding timestamp that is 4.4 minutes rounds to 4', () => { let response; - const twoHours = 2 * 60 * 60 * 1000; - - const formatToPartsResponse = [ - {type: 'literal', value: 'in'}, - {type: 'integer', value: '2', unit: 'hours'}, - {type: 'decimal', value: '.', unit: 'hours'}, - {type: 'fraction', value: '0', unit: 'hours'}, - {type: 'literal', value: 'hours'} - ]; - mockFormatExpression.formatToParts.mockReturnValue(formatToPartsResponse); - response = format.formatTimeUntil(twoHours, 'en'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'hour'); - expect(response).toEqual('2 hours'); + const twoMin = 4.4 * 60 * 1000; + mockFormatExpression.format.mockReturnValue('in 4 minutes'); + response = format.formatRelativeTime(twoMin, 'en'); + expect(mockFormatExpression.format).toHaveBeenCalledWith(4, 'minute'); + expect(response).toEqual('in 4 minutes'); }); - test('test timestamp that is exactly 2 days in the future', () => { + test('test timestamp that is 95.25 minutes in the future', () => { let response; - const twoDays = 2 * 60 * 60 * 24 * 1000; - const formatToPartsResponse = [ - {type: 'literal', value: 'in'}, - {type: 'integer', value: '2', unit: 'days'}, - {type: 'decimal', value: '.', unit: 'days'}, - {type: 'fraction', value: '0', unit: 'days'}, - {type: 'literal', value: 'days'} - ]; - mockFormatExpression.formatToParts.mockReturnValue(formatToPartsResponse); - - response = format.formatTimeUntil(twoDays, 'en'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'day'); - expect(response).toEqual('2 days'); + const ninetyFiveMin = 95.25 * 60 * 1000; + mockFormatExpression.format.mockReturnValue('in 95 minutes'); + response = format.formatRelativeTime(ninetyFiveMin, 'en'); + expect(mockFormatExpression.format).toHaveBeenCalledWith(95, 'minute'); + expect(response).toEqual('in 95 minutes'); }); - - test('test timestamp that is 2.5 days in the future', () => { + test('test timestamp that is 48 hours in the future', () => { let response; - const twoDays = 2.5 * 60 * 60 * 24 * 1000; - const formatToPartsResponseTwoAndAHalfDays = [ - {type: 'literal', value: 'in'}, - {type: 'integer', value: '2', unit: 'days'}, - {type: 'decimal', value: '.', unit: 'days'}, - {type: 'fraction', value: '5', unit: 'days'}, - {type: 'literal', value: 'days'} - ]; - const formatToPartsResponseTwelveHours = [ - {type: 'literal', value: 'in'}, - {type: 'integer', value: '12', unit: 'hours'}, - {type: 'decimal', value: '.', unit: 'hours'}, - {type: 'fraction', value: '0', unit: 'hours'}, - {type: 'literal', value: 'hours'} - ]; - mockFormatExpression.formatToParts - .mockReturnValueOnce(formatToPartsResponseTwoAndAHalfDays) - .mockReturnValueOnce(formatToPartsResponseTwelveHours); + const fortyEightHrs = 48 * 60 * 60 * 1000; - response = format.formatTimeUntil(twoDays, 'en'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2.5, 'day'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(12, 'hour'); - expect(response).toEqual('2 days 12 hours'); + mockFormatExpression.format.mockReturnValue('in 48 hours'); + response = format.formatRelativeTime(fortyEightHrs, 'en'); + expect(mockFormatExpression.format).toHaveBeenCalledWith(48, 'hour'); + expect(response).toEqual('in 48 hours'); }); - test('test timestamp that is 3.5 hours in the future', () => { + test('test timestamp that is 2.6 hours rounds to 3', () => { let response; - const twoDays = 3.5 * 60 * 60 * 1000; - const formatToPartsResponseOne = [ - {type: 'literal', value: 'in'}, - {type: 'integer', value: '3', unit: 'hours'}, - {type: 'decimal', value: '.', unit: 'hours'}, - {type: 'fraction', value: '5', unit: 'hours'}, - {type: 'literal', value: 'hours'} - ]; - const formatToPartsResponseTwo = [ - {type: 'literal', value: 'in'}, - {type: 'integer', value: '30', unit: 'minutes'}, - {type: 'decimal', value: '.', unit: 'minutes'}, - {type: 'fraction', value: '0', unit: 'minutes'}, - {type: 'literal', value: 'minutes'} - ]; - mockFormatExpression.formatToParts - .mockReturnValueOnce(formatToPartsResponseOne) - .mockReturnValueOnce(formatToPartsResponseTwo); + const twoPlusHours = 2.6 * 60 * 60 * 1000; - response = format.formatTimeUntil(twoDays, 'en'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(3.5, 'hour'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(30, 'minute'); - expect(response).toEqual('3 hours 30 minutes'); + mockFormatExpression.format.mockReturnValue('in 3 hours'); + response = format.formatRelativeTime(twoPlusHours, 'en'); + expect(mockFormatExpression.format).toHaveBeenCalledWith(3, 'hour'); + expect(response).toEqual('in 3 hours'); }); - test('test timestamp that is 1 day and less than an hour in the future', () => { + test('test timestamp that is 4.2 hours in the future rounds to 4', () => { let response; - const aDayand10Min = 1.007 * 60 * 60 * 24 * 1000; - const formatToPartsResponse = [ - {type: 'literal', value: 'in'}, - {type: 'integer', value: '1', unit: 'days'}, - {type: 'decimal', value: '.', unit: 'days'}, - {type: 'fraction', value: '0.007', unit: 'days'}, - {type: 'literal', value: 'days'} - ]; - - mockFormatExpression.formatToParts - .mockReturnValueOnce(formatToPartsResponse); - - response = format.formatTimeUntil(aDayand10Min, 'en'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(1.007, 'day'); - expect(response).toEqual('1 days'); + const fourPlusHours = 4.2 * 60 * 60 * 1000; + mockFormatExpression.format.mockReturnValue('in 4 hours'); + response = format.formatRelativeTime(fourPlusHours, 'en'); + expect(mockFormatExpression.format).toHaveBeenCalledWith(4, 'hour'); + expect(response).toEqual('in 4 hours'); }); - test('test timestamp that is hour and less than a minute in the future', () => { + test('test timestamp that is 2 hours in the future is in hours', () => { let response; - const anHourAnd30Sec = 1.008 * 60 * 60 * 1000; - const formatToPartsResponse = [ - {type: 'literal', value: 'in'}, - {type: 'integer', value: '1', unit: 'hours'}, - {type: 'decimal', value: '.', unit: 'hours'}, - {type: 'fraction', value: '0.008', unit: 'hours'}, - {type: 'literal', value: 'hours'} - ]; + const threeHours = 2 * 60 * 60 * 1000; - mockFormatExpression.formatToParts - .mockReturnValueOnce(formatToPartsResponse); - - response = format.formatTimeUntil(anHourAnd30Sec, 'en'); - expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(1.008, 'hour'); - expect(response).toEqual('1 hours'); + mockFormatExpression.format.mockReturnValue('in 2 hours'); + response = format.formatRelativeTime(threeHours, 'en'); + expect(mockFormatExpression.format).toHaveBeenCalledWith(2, 'hour'); + expect(response).toEqual('in 2 hours'); }); }); From b1d4c6d1b573f60073c4c8e90a30ec1d9aeeee48 Mon Sep 17 00:00:00 2001 From: picklesrus Date: Mon, 7 Dec 2020 15:38:58 -0500 Subject: [PATCH 5/5] Add backup langauges and remove tests that aren't useful. --- src/lib/format-time.js | 2 +- test/unit/lib/format-time.test.js | 42 ++++++++++++------------------- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/src/lib/format-time.js b/src/lib/format-time.js index 987077521..23255511a 100644 --- a/src/lib/format-time.js +++ b/src/lib/format-time.js @@ -35,7 +35,7 @@ const getTimeUnitAndDuration = timeStamp => { * @returns {string} A phrase representing the relative time in the future. e.g. 3 days 5 hours. */ module.exports.formatRelativeTime = (futureTime, lang) => { - const formatter = new Intl.RelativeTimeFormat(lang, { + const formatter = new Intl.RelativeTimeFormat([lang].concat(window.navigator.languages), { localeMatcher: 'best fit', numeric: 'always', style: 'long' diff --git a/test/unit/lib/format-time.test.js b/test/unit/lib/format-time.test.js index cd13ebc0a..b04513096 100644 --- a/test/unit/lib/format-time.test.js +++ b/test/unit/lib/format-time.test.js @@ -20,68 +20,58 @@ describe('unit test lib/format-time.js', () => { }); test('test timestamp that is 2 minutes in the future', () => { - let response; const twoMin = 2 * 60 * 1000; mockFormatExpression.format.mockReturnValue('in 2 minutes'); - response = format.formatRelativeTime(twoMin, 'en'); + format.formatRelativeTime(twoMin, 'en'); expect(mockFormatExpression.format).toHaveBeenCalledWith(2, 'minute'); - expect(response).toEqual('in 2 minutes'); }); test('test rounding timestamp that is 4.4 minutes rounds to 4', () => { - let response; - const twoMin = 4.4 * 60 * 1000; + const fourPlusMin = 4.4 * 60 * 1000; mockFormatExpression.format.mockReturnValue('in 4 minutes'); - response = format.formatRelativeTime(twoMin, 'en'); + format.formatRelativeTime(fourPlusMin, 'en'); expect(mockFormatExpression.format).toHaveBeenCalledWith(4, 'minute'); - expect(response).toEqual('in 4 minutes'); }); test('test timestamp that is 95.25 minutes in the future', () => { - let response; const ninetyFiveMin = 95.25 * 60 * 1000; mockFormatExpression.format.mockReturnValue('in 95 minutes'); - response = format.formatRelativeTime(ninetyFiveMin, 'en'); + format.formatRelativeTime(ninetyFiveMin, 'en'); expect(mockFormatExpression.format).toHaveBeenCalledWith(95, 'minute'); - expect(response).toEqual('in 95 minutes'); + }); + + test('test timestamp that is 119 minutes in the future', () => { + const ninetyFiveMin = 119 * 60 * 1000; + mockFormatExpression.format.mockReturnValue('in 199 minutes'); + format.formatRelativeTime(ninetyFiveMin, 'en'); + expect(mockFormatExpression.format).toHaveBeenCalledWith(119, 'minute'); }); test('test timestamp that is 48 hours in the future', () => { - let response; const fortyEightHrs = 48 * 60 * 60 * 1000; - mockFormatExpression.format.mockReturnValue('in 48 hours'); - response = format.formatRelativeTime(fortyEightHrs, 'en'); + format.formatRelativeTime(fortyEightHrs, 'en'); expect(mockFormatExpression.format).toHaveBeenCalledWith(48, 'hour'); - expect(response).toEqual('in 48 hours'); }); test('test timestamp that is 2.6 hours rounds to 3', () => { - let response; const twoPlusHours = 2.6 * 60 * 60 * 1000; - mockFormatExpression.format.mockReturnValue('in 3 hours'); - response = format.formatRelativeTime(twoPlusHours, 'en'); + format.formatRelativeTime(twoPlusHours, 'en'); expect(mockFormatExpression.format).toHaveBeenCalledWith(3, 'hour'); - expect(response).toEqual('in 3 hours'); }); test('test timestamp that is 4.2 hours in the future rounds to 4', () => { - let response; const fourPlusHours = 4.2 * 60 * 60 * 1000; mockFormatExpression.format.mockReturnValue('in 4 hours'); - response = format.formatRelativeTime(fourPlusHours, 'en'); + format.formatRelativeTime(fourPlusHours, 'en'); expect(mockFormatExpression.format).toHaveBeenCalledWith(4, 'hour'); - expect(response).toEqual('in 4 hours'); }); test('test timestamp that is 2 hours in the future is in hours', () => { - let response; - const threeHours = 2 * 60 * 60 * 1000; - + const twoHours = 2 * 60 * 60 * 1000; mockFormatExpression.format.mockReturnValue('in 2 hours'); - response = format.formatRelativeTime(threeHours, 'en'); + format.formatRelativeTime(twoHours, 'en'); expect(mockFormatExpression.format).toHaveBeenCalledWith(2, 'hour'); - expect(response).toEqual('in 2 hours'); }); });