From 6eb75044423a72a204bc450e448e19b65e4155ed Mon Sep 17 00:00:00 2001 From: picklesrus Date: Thu, 12 Nov 2020 14:21:23 -0500 Subject: [PATCH] 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'); + }); +});