diff --git a/package.json b/package.json index 3de18bc3b7a..730b21ca96c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@babel/preset-react": "^7.0.0", "@babel/preset-typescript": "^7.16.7", "@hypothesis/frontend-build": "^3.0.0", - "@hypothesis/frontend-shared": "^8.2.0", + "@hypothesis/frontend-shared": "^8.7.0", "@hypothesis/frontend-testing": "^1.2.0", "@npmcli/arborist": "^7.0.0", "@octokit/rest": "^21.0.0", diff --git a/src/sidebar/components/Annotation/AnnotationTimestamps.tsx b/src/sidebar/components/Annotation/AnnotationTimestamps.tsx index 5a0951238e3..551e6c6f2fb 100644 --- a/src/sidebar/components/Annotation/AnnotationTimestamps.tsx +++ b/src/sidebar/components/Annotation/AnnotationTimestamps.tsx @@ -1,11 +1,10 @@ -import { Link } from '@hypothesis/frontend-shared'; -import { useEffect, useMemo, useState } from 'preact/hooks'; - import { + Link, decayingInterval, formatRelativeDate, - formatDate, -} from '../../util/time'; + formatDateTime, +} from '@hypothesis/frontend-shared'; +import { useEffect, useMemo, useState } from 'preact/hooks'; export type AnnotationTimestampProps = { annotationCreated: string; @@ -44,7 +43,7 @@ export default function AnnotationTimestamps({ const created = useMemo(() => { return { - absolute: formatDate(createdDate), + absolute: formatDateTime(createdDate, { includeWeekday: true }), relative: formatRelativeDate(createdDate, now), }; }, [createdDate, now]); @@ -54,7 +53,7 @@ export default function AnnotationTimestamps({ return {}; } return { - absolute: formatDate(updatedDate), + absolute: formatDateTime(updatedDate, { includeWeekday: true }), relative: formatRelativeDate(updatedDate, now), }; }, [updatedDate, now]); diff --git a/src/sidebar/components/Annotation/test/AnnotationTimestamps-test.js b/src/sidebar/components/Annotation/test/AnnotationTimestamps-test.js index a777a6b1db5..e757fbceb3f 100644 --- a/src/sidebar/components/Annotation/test/AnnotationTimestamps-test.js +++ b/src/sidebar/components/Annotation/test/AnnotationTimestamps-test.js @@ -23,13 +23,13 @@ describe('AnnotationTimestamps', () => { clock = sinon.useFakeTimers(); fakeTime = { - formatDate: sinon.stub().returns('absolute date'), + formatDateTime: sinon.stub().returns('absolute date'), formatRelativeDate: sinon.stub().returns('fuzzy string'), decayingInterval: sinon.stub(), }; $imports.$mock({ - '../../util/time': fakeTime, + '@hypothesis/frontend-shared': fakeTime, }); }); diff --git a/src/sidebar/util/test/time-test.js b/src/sidebar/util/test/time-test.js index 034b0e449ad..f24295324c3 100644 --- a/src/sidebar/util/test/time-test.js +++ b/src/sidebar/util/test/time-test.js @@ -1,262 +1,6 @@ -import { - clearFormatters, - decayingInterval, - formatDate, - formatDateTime, - formatRelativeDate, - nextFuzzyUpdate, -} from '../time'; - -const second = 1000; -const minute = second * 60; -const hour = minute * 60; -const day = hour * 24; +import { formatDateTime } from '../time'; describe('sidebar/util/time', () => { - let sandbox; - let fakeIntl; - - beforeEach(() => { - sandbox = sinon.createSandbox(); - sandbox.useFakeTimers(); - - fakeIntl = { - DateTimeFormat: sinon.stub().returns({ - format: sinon.stub(), - }), - }; - // Clear the formatters cache so that mocked formatters - // from one test run don't affect the next. - clearFormatters(); - }); - - afterEach(() => { - sandbox.restore(); - }); - - const fakeDate = isoString => { - // Since methods like Date.getFullYear output the year in - // whatever timezone the node timezone is set to, these - // methods must be mocked/mapped to their UTC equivalents when - // testing such as getUTCFullYear in order to have timezone - // agnostic tests. - // Example: - // An annotation was posted at 2019-01-01T01:00:00 UTC and now the - // current date is a few days later; 2019-01-10. - // - A user in the UK who views the annotation will see “Jan 1” - // on the annotation card (correct) - // - A user in San Francisco who views the annotation will see - // “Dec 31, 2018" on the annotation card (also correct from - // their point of view). - const date = new Date(isoString); - date.getFullYear = sinon.stub().returns(date.getUTCFullYear()); - return date; - }; - - describe('formatRelativeDate', () => { - it('Handles empty dates', () => { - const date = null; - const expect = ''; - assert.equal(formatRelativeDate(date, undefined), expect); - }); - - [ - { now: '1970-01-01T00:00:10.000Z', text: 'Just now' }, - { now: '1970-01-01T00:00:29.000Z', text: 'Just now' }, - { now: '1970-01-01T00:00:49.000Z', text: '49 secs ago' }, - { now: '1970-01-01T00:01:05.000Z', text: '1 min ago' }, - { now: '1970-01-01T00:03:05.000Z', text: '3 mins ago' }, - { now: '1970-01-01T01:00:00.000Z', text: '1 hr ago' }, - { now: '1970-01-01T04:00:00.000Z', text: '4 hrs ago' }, - ].forEach(test => { - it('creates correct fuzzy string for fixture ' + test.now, () => { - const timeStamp = fakeDate('1970-01-01T00:00:00.000Z'); - const now = fakeDate(test.now); - assert.equal(formatRelativeDate(timeStamp, now), test.text); - }); - }); - - [ - { - now: '1970-01-02T03:00:00.000Z', - text: '2 Jan', - options: { day: 'numeric', month: 'short' }, - }, - { - now: '1970-01-04T00:30:00.000Z', - text: '4 Jan', - options: { day: 'numeric', month: 'short' }, - }, - { - now: '1970-07-03T00:00:00.000Z', - text: '3 July', - options: { day: 'numeric', month: 'short' }, - }, - { - now: '1971-01-01T00:00:00.000Z', - text: '1 Jan 1970', - options: { day: 'numeric', month: 'short', year: 'numeric' }, - }, - { - now: '1971-03-01T00:00:00.000Z', - text: '1 Jan 1970', - options: { day: 'numeric', month: 'short', year: 'numeric' }, - }, - { - now: '1972-01-01T00:00:00.000Z', - text: '1 Jan 1970', - options: { day: 'numeric', month: 'short', year: 'numeric' }, - }, - { - now: '1978-01-01T00:00:00.000Z', - text: '1 Jan 1970', - options: { day: 'numeric', month: 'short', year: 'numeric' }, - }, - ].forEach(test => { - it( - 'passes correct arguments to `Intl.DateTimeFormat.format` for fixture ' + - test.now, - () => { - const timeStamp = fakeDate('1970-01-01T00:00:00.000Z'); - const now = fakeDate(test.now); - - fakeIntl.DateTimeFormat().format.returns(test.text); // eslint-disable-line new-cap - assert.equal(formatRelativeDate(timeStamp, now, fakeIntl), test.text); - assert.calledWith(fakeIntl.DateTimeFormat, undefined, test.options); - assert.calledWith(fakeIntl.DateTimeFormat().format, timeStamp); // eslint-disable-line new-cap - }, - ); - }); - }); - - describe('decayingInterval', () => { - it('handles empty dates', () => { - const date = null; - decayingInterval(date, undefined); - }); - - it('never invokes callback if date is invalid', () => { - const date = new Date('foo bar'); - const callback = sinon.stub(); - - decayingInterval(date, callback); - sandbox.clock.tick(600 * day); - - assert.notCalled(callback); - }); - - it('uses a short delay for recent timestamps', () => { - const date = new Date().toISOString(); - const callback = sandbox.stub(); - decayingInterval(date, callback); - sandbox.clock.tick(6 * second); - assert.called(callback); - sandbox.clock.tick(6 * second); - assert.calledTwice(callback); - }); - - it('uses a longer delay for older timestamps', () => { - const date = new Date().toISOString(); - const ONE_MINUTE = minute; - sandbox.clock.tick(10 * ONE_MINUTE); - const callback = sandbox.stub(); - decayingInterval(date, callback); - sandbox.clock.tick(ONE_MINUTE / 2); - assert.notCalled(callback); - sandbox.clock.tick(ONE_MINUTE); - assert.called(callback); - sandbox.clock.tick(ONE_MINUTE); - assert.calledTwice(callback); - }); - - it('returned function cancels the timer', () => { - const date = new Date().toISOString(); - const callback = sandbox.stub(); - const cancel = decayingInterval(date, callback); - cancel(); - sandbox.clock.tick(minute); - assert.notCalled(callback); - }); - - it('does not set a timeout for dates > 24hrs ago', () => { - const date = new Date().toISOString(); - const ONE_DAY = day; - sandbox.clock.tick(10 * ONE_DAY); - const callback = sandbox.stub(); - - decayingInterval(date, callback); - sandbox.clock.tick(ONE_DAY * 2); - - assert.notCalled(callback); - }); - }); - - describe('nextFuzzyUpdate', () => { - it('handles empty dates', () => { - const date = null; - const expect = null; - assert.equal(nextFuzzyUpdate(date, undefined), expect); - }); - - it('returns `null` if date is invalid', () => { - const date = new Date('foo bar'); - assert.equal(nextFuzzyUpdate(date), null); - }); - - it('returns `null` if "now" date is invalid', () => { - const date = new Date(); - const now = new Date('foo bar'); - assert.equal(nextFuzzyUpdate(date, now), null); - }); - - [ - { now: '1970-01-01T00:00:10.000Z', expectedUpdateTime: 5 * second }, // we have a minimum of 5 secs - { now: '1970-01-01T00:00:20.000Z', expectedUpdateTime: 5 * second }, - { now: '1970-01-01T00:00:49.000Z', expectedUpdateTime: 5 * second }, - { now: '1970-01-01T00:01:05.000Z', expectedUpdateTime: minute }, - { now: '1970-01-01T00:03:05.000Z', expectedUpdateTime: minute }, - { now: '1970-01-01T04:00:00.000Z', expectedUpdateTime: hour }, - { now: '1970-01-02T03:00:00.000Z', expectedUpdateTime: null }, - { now: '1970-01-04T00:30:00.000Z', expectedUpdateTime: null }, - { now: '1970-07-02T00:00:00.000Z', expectedUpdateTime: null }, - { now: '1978-01-01T00:00:00.000Z', expectedUpdateTime: null }, - ].forEach(test => { - it('gives correct next fuzzy update time for fixture ' + test.now, () => { - const timeStamp = fakeDate('1970-01-01T00:00:00.000Z'); - const now = fakeDate(test.now); - assert.equal(nextFuzzyUpdate(timeStamp, now), test.expectedUpdateTime); - }); - }); - }); - - describe('formatDate', () => { - // Normalize "special" spaces (eg. "\u202F") to standard spaces. - function normalizeSpaces(str) { - return str.normalize('NFKC'); - } - - it('returns absolute formatted date', () => { - const date = new Date('2020-05-04T23:02:01'); - const fakeIntl = locale => ({ - DateTimeFormat: function (_, options) { - return new Intl.DateTimeFormat(locale, options); - }, - }); - - assert.equal( - normalizeSpaces(formatDate(date, fakeIntl('en-US'))), - 'Monday, May 04, 2020, 11:02 PM', - ); - - clearFormatters(); - - assert.equal( - normalizeSpaces(formatDate(date, fakeIntl('de-DE'))), - 'Montag, 04. Mai 2020, 23:02', - ); - }); - }); - describe('formatDateTime', () => { [ new Date(Date.UTC(2023, 11, 20, 3, 5, 38)), diff --git a/src/sidebar/util/time.ts b/src/sidebar/util/time.ts index b2fa6febb88..26056618df6 100644 --- a/src/sidebar/util/time.ts +++ b/src/sidebar/util/time.ts @@ -1,268 +1,3 @@ -const SECOND = 1000; -const MINUTE = 60 * SECOND; -const HOUR = 60 * MINUTE; - -/** - * Map of stringified `DateTimeFormatOptions` to cached `DateTimeFormat` instances. - */ -let formatters: Record = {}; - -/** - * Clears the cache of formatters. - */ -export function clearFormatters() { - formatters = {}; -} - -/** - * Calculate time delta in milliseconds between two `Date` objects - */ -function delta(date: Date, now: Date) { - // @ts-ignore - return now - date; -} - -type IntlType = typeof window.Intl; - -/** - * Return date string formatted with `options`. - * - * This is a caching wrapper for `Intl.DateTimeFormat.format`, useful because - * constructing a `DateTimeFormat` is expensive. - * - * @param Intl - Test seam. JS `Intl` API implementation. - */ -function format( - date: Date, - options: Intl.DateTimeFormatOptions, - /* istanbul ignore next */ - Intl: IntlType = window.Intl, -): string { - const key = JSON.stringify(options); - let formatter = formatters[key]; - if (!formatter) { - formatter = formatters[key] = new Intl.DateTimeFormat(undefined, options); - } - return formatter.format(date); -} - -/** - * @return formatted date - */ -type DateFormatter = (date: Date, now: Date, intl?: IntlType) => string; - -const nSec: DateFormatter = (date, now) => { - const n = Math.floor(delta(date, now) / SECOND); - return `${n} secs ago`; -}; - -const nMin: DateFormatter = (date, now) => { - const n = Math.floor(delta(date, now) / MINUTE); - const plural = n > 1 ? 's' : ''; - return `${n} min${plural} ago`; -}; - -const nHr: DateFormatter = (date, now) => { - const n = Math.floor(delta(date, now) / HOUR); - const plural = n > 1 ? 's' : ''; - return `${n} hr${plural} ago`; -}; - -const dayAndMonth: DateFormatter = (date, now, Intl) => { - return format(date, { month: 'short', day: 'numeric' }, Intl); -}; - -const dayAndMonthAndYear: DateFormatter = (date, now, Intl) => { - return format( - date, - { day: 'numeric', month: 'short', year: 'numeric' }, - Intl, - ); -}; - -type Breakpoint = { - test: (date: Date, now: Date) => boolean; - formatter: DateFormatter; - nextUpdate: number | null; -}; - -const BREAKPOINTS: Breakpoint[] = [ - { - // Less than 30 seconds - test: (date, now) => delta(date, now) < 30 * SECOND, - formatter: () => 'Just now', - nextUpdate: 1 * SECOND, - }, - { - // Less than 1 minute - test: (date, now) => delta(date, now) < 1 * MINUTE, - formatter: nSec, - nextUpdate: 1 * SECOND, - }, - { - // Less than one hour - test: (date, now) => delta(date, now) < 1 * HOUR, - formatter: nMin, - nextUpdate: 1 * MINUTE, - }, - { - // Less than one day - test: (date, now) => delta(date, now) < 24 * HOUR, - formatter: nHr, - nextUpdate: 1 * HOUR, - }, - { - // This year - test: (date, now) => date.getFullYear() === now.getFullYear(), - formatter: dayAndMonth, - nextUpdate: null, - }, -]; - -const DEFAULT_BREAKPOINT: Breakpoint = { - test: /* istanbul ignore next */ () => true, - formatter: dayAndMonthAndYear, - nextUpdate: null, -}; - -/** - * Returns a dict that describes how to format the date based on the delta - * between date and now. - * - * @param date - The date to consider as the timestamp to format. - * @param now - The date to consider as the current time. - * @return An object that describes how to format the date. - */ -function getBreakpoint(date: Date, now: Date): Breakpoint { - for (const breakpoint of BREAKPOINTS) { - if (breakpoint.test(date, now)) { - return breakpoint; - } - } - return DEFAULT_BREAKPOINT; -} - -/** - * See https://262.ecma-international.org/6.0/#sec-time-values-and-time-range - */ -function isDateValid(date: Date): boolean { - return !isNaN(date.valueOf()); -} - -/** - * Return the number of milliseconds until the next update for a given date - * should be handled, based on the delta between `date` and `now`. - * - * @return ms until next update or `null` if no update should occur - */ -export function nextFuzzyUpdate(date: Date | null, now: Date): number | null { - if (!date || !isDateValid(date) || !isDateValid(now)) { - return null; - } - - let nextUpdate = getBreakpoint(date, now).nextUpdate; - - if (nextUpdate === null) { - return null; - } - - // We don't want to refresh anything more often than 5 seconds - nextUpdate = Math.max(nextUpdate, 5 * SECOND); - - // setTimeout limit is MAX_INT32=(2^31-1) (in ms), - // which is about 24.8 days. So we don't set up any timeouts - // longer than 24 days, that is, 2073600 seconds. - nextUpdate = Math.min(nextUpdate, 2073600 * SECOND); - - return nextUpdate; -} - -/** - * Start an interval whose frequency depends on the age of a timestamp. - * - * This is useful for refreshing UI components displaying timestamps generated - * by `formatRelativeDate`, since the output changes less often for older timestamps. - * - * @param date - Date string to use to determine the interval frequency - * @param callback - Interval callback - * @return A function that cancels the interval - */ -export function decayingInterval( - date: string, - callback: () => void, -): () => void { - let timer: number | undefined; - const timestamp = new Date(date); - - const update = () => { - const fuzzyUpdate = nextFuzzyUpdate(timestamp, new Date()); - if (fuzzyUpdate === null) { - return; - } - const nextUpdate = fuzzyUpdate + 500; - timer = setTimeout(() => { - callback(); - update(); - }, nextUpdate); - }; - - update(); - - return () => clearTimeout(timer); -} - -/** - * Formats a date as a short approximate string relative to the current date. - * - * The level of precision is proportional to how recent the date is. - * - * For example: - * - * - "Just now" - * - "5 minutes ago" - * - "25 Oct 2018" - * - * @param date - The date to consider as the timestamp to format. - * @param now - The date to consider as the current time. - * @param Intl - Test seam. JS `Intl` API implementation. - * @return A 'fuzzy' string describing the relative age of the date. - */ -export function formatRelativeDate( - date: Date | null, - now: Date, - Intl?: IntlType, -): string { - if (!date) { - return ''; - } - return getBreakpoint(date, now).formatter(date, now, Intl); -} - -/** - * Formats a date as an absolute string in a human readable format. - * - * The exact format will vary depending on the locale, but the verbosity will - * be consistent across locales. In en-US for example this will look like: - * - * "Sunday, Dec 17, 2017, 10:00 AM" - * - * @param Intl - Test seam. JS `Intl` API implementation. - */ -export function formatDate(date: Date, Intl?: IntlType): string { - return format( - date, - { - year: 'numeric', - month: 'short', - day: '2-digit', - weekday: 'long', - hour: '2-digit', - minute: '2-digit', - }, - Intl, - ); -} - /** * Formats a date as `YYYY-MM-DD hh:mm`, using 24h and system timezone. */ diff --git a/yarn.lock b/yarn.lock index 3e705e072e6..1c50ddf030b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3035,15 +3035,15 @@ __metadata: languageName: node linkType: hard -"@hypothesis/frontend-shared@npm:^8.2.0": - version: 8.6.0 - resolution: "@hypothesis/frontend-shared@npm:8.6.0" +"@hypothesis/frontend-shared@npm:^8.7.0": + version: 8.7.0 + resolution: "@hypothesis/frontend-shared@npm:8.7.0" dependencies: highlight.js: ^11.6.0 wouter-preact: ^3.0.0 peerDependencies: preact: ^10.4.0 - checksum: 9332d7d39e9bfa3f4ad2f312be5d6a4180a3faa8662b54a9f44f3d754fda8fd6c3cc3eb19d176a48aa31e8aecd136105237d30a37c7563a73553be58e802376c + checksum: 646087a5d592080e7bdd336ecd18df047450b15c3410b4bd39b36a41702bfcdbbf39b378ad3a17a2442b825dfc0500d10c25be24b6c2a9d814052f33d3e2fe11 languageName: node linkType: hard @@ -9073,7 +9073,7 @@ __metadata: "@babel/preset-react": ^7.0.0 "@babel/preset-typescript": ^7.16.7 "@hypothesis/frontend-build": ^3.0.0 - "@hypothesis/frontend-shared": ^8.2.0 + "@hypothesis/frontend-shared": ^8.7.0 "@hypothesis/frontend-testing": ^1.2.0 "@npmcli/arborist": ^7.0.0 "@octokit/rest": ^21.0.0