From cffa8de6267a57673b7f6264fab671153f965d23 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 19 Mar 2026 12:15:23 +0100 Subject: [PATCH] Refactor: Relative timestamp component (#38275) --- app/javascript/entrypoints/public.tsx | 32 +- .../mastodon/components/account/index.tsx | 2 +- .../components/edited_timestamp/index.tsx | 5 +- app/javascript/mastodon/components/poll.tsx | 2 +- .../components/relative_timestamp.tsx | 288 ------------------ .../components/relative_timestamp/index.tsx | 83 +++++ .../relative_timestamp.stories.tsx | 65 ++++ .../detail/collection_list_item.tsx | 7 +- .../notifications/components/report.jsx | 2 +- .../ui/components/compare_history_modal.jsx | 2 +- app/javascript/mastodon/utils/time.test.ts | 36 +++ app/javascript/mastodon/utils/time.ts | 246 +++++++++++++++ 12 files changed, 455 insertions(+), 315 deletions(-) delete mode 100644 app/javascript/mastodon/components/relative_timestamp.tsx create mode 100644 app/javascript/mastodon/components/relative_timestamp/index.tsx create mode 100644 app/javascript/mastodon/components/relative_timestamp/relative_timestamp.stories.tsx create mode 100644 app/javascript/mastodon/utils/time.test.ts create mode 100644 app/javascript/mastodon/utils/time.ts diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 4089575e41..8b67698f20 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -1,14 +1,20 @@ import { createRoot } from 'react-dom/client'; import { IntlMessageFormat } from 'intl-messageformat'; -import type { MessageDescriptor, PrimitiveType } from 'react-intl'; +import type { + FormatDateOptions, + IntlShape, + MessageDescriptor, + PrimitiveType, +} from 'react-intl'; import { defineMessages } from 'react-intl'; import axios from 'axios'; import { on } from 'delegated-events'; import { throttle } from 'lodash'; -import { timeAgoString } from '../mastodon/components/relative_timestamp'; +import { formatTime } from '@/mastodon/utils/time'; + import emojify from '../mastodon/features/emoji/emoji'; import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions'; import { loadLocale, getLocale } from '../mastodon/locales'; @@ -58,7 +64,7 @@ function loaded() { const formatMessage = ( { id, defaultMessage }: MessageDescriptor, values?: Record, - ) => { + ): string => { let message: string | undefined = undefined; if (id) message = localeData[id]; @@ -126,23 +132,23 @@ function loaded() { .querySelectorAll('time.time-ago') .forEach((content) => { const datetime = new Date(content.dateTime); - const now = new Date(); const timeGiven = content.dateTime.includes('T'); content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime); - content.textContent = timeAgoString( - { - formatMessage, - formatDate: (date: Date, options) => + const now = Date.now(); + content.textContent = formatTime({ + // We don't want to show future dates. + timestamp: Math.min(datetime.getTime(), now), + now, + intl: { + formatMessage: formatMessage as IntlShape['formatMessage'], + formatDate: (date: Date, options: FormatDateOptions) => new Intl.DateTimeFormat(locale, options).format(date), }, - datetime, - now.getTime(), - now.getFullYear(), - timeGiven, - ); + noTime: !timeGiven, + }); }); updateDefaultQuotePrivacyFromPrivacy( diff --git a/app/javascript/mastodon/components/account/index.tsx b/app/javascript/mastodon/components/account/index.tsx index 7397dfd1d1..f8668bce32 100644 --- a/app/javascript/mastodon/components/account/index.tsx +++ b/app/javascript/mastodon/components/account/index.tsx @@ -275,7 +275,7 @@ export const Account: React.FC = ({ if (account?.mute_expires_at) { muteTimeRemaining = ( <> - · + · ); } diff --git a/app/javascript/mastodon/components/edited_timestamp/index.tsx b/app/javascript/mastodon/components/edited_timestamp/index.tsx index 36f8db8abf..eb07559cb2 100644 --- a/app/javascript/mastodon/components/edited_timestamp/index.tsx +++ b/app/javascript/mastodon/components/edited_timestamp/index.tsx @@ -60,10 +60,7 @@ export const EditedTimestamp: React.FC<{ const renderItem = useCallback( (item: HistoryItem, index: number, onClick: React.MouseEventHandler) => { const formattedDate = ( - + ); const formattedName = ( diff --git a/app/javascript/mastodon/components/poll.tsx b/app/javascript/mastodon/components/poll.tsx index b5b5fb3673..3ab31f4229 100644 --- a/app/javascript/mastodon/components/poll.tsx +++ b/app/javascript/mastodon/components/poll.tsx @@ -70,7 +70,7 @@ export const Poll: React.FC = ({ pollId, disabled, status }) => { if (expired) { return intl.formatMessage(messages.closed); } - return ; + return ; }, [expired, intl, poll]); const votesCount = useMemo(() => { if (!poll) { diff --git a/app/javascript/mastodon/components/relative_timestamp.tsx b/app/javascript/mastodon/components/relative_timestamp.tsx deleted file mode 100644 index 6253525091..0000000000 --- a/app/javascript/mastodon/components/relative_timestamp.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import { Component } from 'react'; - -import type { MessageDescriptor, PrimitiveType, IntlShape } from 'react-intl'; -import { injectIntl, defineMessages } from 'react-intl'; - -const messages = defineMessages({ - today: { id: 'relative_time.today', defaultMessage: 'today' }, - just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, - just_now_full: { - id: 'relative_time.full.just_now', - defaultMessage: 'just now', - }, - seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, - seconds_full: { - id: 'relative_time.full.seconds', - defaultMessage: '{number, plural, one {# second} other {# seconds}} ago', - }, - minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, - minutes_full: { - id: 'relative_time.full.minutes', - defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago', - }, - hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, - hours_full: { - id: 'relative_time.full.hours', - defaultMessage: '{number, plural, one {# hour} other {# hours}} ago', - }, - days: { id: 'relative_time.days', defaultMessage: '{number}d' }, - days_full: { - id: 'relative_time.full.days', - defaultMessage: '{number, plural, one {# day} other {# days}} ago', - }, - moments_remaining: { - id: 'time_remaining.moments', - defaultMessage: 'Moments remaining', - }, - seconds_remaining: { - id: 'time_remaining.seconds', - defaultMessage: '{number, plural, one {# second} other {# seconds}} left', - }, - minutes_remaining: { - id: 'time_remaining.minutes', - defaultMessage: '{number, plural, one {# minute} other {# minutes}} left', - }, - hours_remaining: { - id: 'time_remaining.hours', - defaultMessage: '{number, plural, one {# hour} other {# hours}} left', - }, - days_remaining: { - id: 'time_remaining.days', - defaultMessage: '{number, plural, one {# day} other {# days}} left', - }, -}); - -const dateFormatOptions = { - year: 'numeric', - month: 'short', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', -} as const; - -const shortDateFormatOptions = { - month: 'short', - day: 'numeric', -} as const; - -const SECOND = 1000; -const MINUTE = 1000 * 60; -const HOUR = 1000 * 60 * 60; -const DAY = 1000 * 60 * 60 * 24; - -const MAX_DELAY = 2147483647; - -const selectUnits = (delta: number) => { - const absDelta = Math.abs(delta); - - if (absDelta < MINUTE) { - return 'second'; - } else if (absDelta < HOUR) { - return 'minute'; - } else if (absDelta < DAY) { - return 'hour'; - } - - return 'day'; -}; - -const getUnitDelay = (units: string) => { - switch (units) { - case 'second': - return SECOND; - case 'minute': - return MINUTE; - case 'hour': - return HOUR; - case 'day': - return DAY; - default: - return MAX_DELAY; - } -}; - -export const timeAgoString = ( - intl: { - formatDate: IntlShape['formatDate']; - formatMessage: ( - { id, defaultMessage }: MessageDescriptor, - values?: Record, - ) => string; - }, - date: Date, - now: number, - year: number, - timeGiven: boolean, - short?: boolean, -) => { - const delta = now - date.getTime(); - - let relativeTime; - - if (delta < DAY && !timeGiven) { - relativeTime = intl.formatMessage(messages.today); - } else if (delta < 10 * SECOND) { - relativeTime = intl.formatMessage( - short ? messages.just_now : messages.just_now_full, - ); - } else if (delta < 7 * DAY) { - if (delta < MINUTE) { - relativeTime = intl.formatMessage( - short ? messages.seconds : messages.seconds_full, - { number: Math.floor(delta / SECOND) }, - ); - } else if (delta < HOUR) { - relativeTime = intl.formatMessage( - short ? messages.minutes : messages.minutes_full, - { number: Math.floor(delta / MINUTE) }, - ); - } else if (delta < DAY) { - relativeTime = intl.formatMessage( - short ? messages.hours : messages.hours_full, - { number: Math.floor(delta / HOUR) }, - ); - } else { - relativeTime = intl.formatMessage( - short ? messages.days : messages.days_full, - { number: Math.floor(delta / DAY) }, - ); - } - } else if (date.getFullYear() === year) { - relativeTime = intl.formatDate(date, shortDateFormatOptions); - } else { - relativeTime = intl.formatDate(date, { - ...shortDateFormatOptions, - year: 'numeric', - }); - } - - return relativeTime; -}; - -const timeRemainingString = ( - intl: IntlShape, - date: Date, - now: number, - timeGiven = true, -) => { - const delta = date.getTime() - now; - - let relativeTime; - - if (delta < DAY && !timeGiven) { - relativeTime = intl.formatMessage(messages.today); - } else if (delta < 10 * SECOND) { - relativeTime = intl.formatMessage(messages.moments_remaining); - } else if (delta < MINUTE) { - relativeTime = intl.formatMessage(messages.seconds_remaining, { - number: Math.floor(delta / SECOND), - }); - } else if (delta < HOUR) { - relativeTime = intl.formatMessage(messages.minutes_remaining, { - number: Math.floor(delta / MINUTE), - }); - } else if (delta < DAY) { - relativeTime = intl.formatMessage(messages.hours_remaining, { - number: Math.floor(delta / HOUR), - }); - } else { - relativeTime = intl.formatMessage(messages.days_remaining, { - number: Math.floor(delta / DAY), - }); - } - - return relativeTime; -}; - -interface Props { - intl: IntlShape; - timestamp: string; - year?: number; - futureDate?: boolean; - short?: boolean; -} -interface States { - now: number; -} -class RelativeTimestamp extends Component { - state = { - now: Date.now(), - }; - - _timer: number | undefined; - - shouldComponentUpdate(nextProps: Props, nextState: States) { - // As of right now the locale doesn't change without a new page load, - // but we might as well check in case that ever changes. - return ( - this.props.timestamp !== nextProps.timestamp || - this.props.intl.locale !== nextProps.intl.locale || - this.state.now !== nextState.now - ); - } - - UNSAFE_componentWillReceiveProps(nextProps: Props) { - if (this.props.timestamp !== nextProps.timestamp) { - this.setState({ now: Date.now() }); - } - } - - componentDidMount() { - this._scheduleNextUpdate(this.props, this.state); - } - - UNSAFE_componentWillUpdate(nextProps: Props, nextState: States) { - this._scheduleNextUpdate(nextProps, nextState); - } - - componentWillUnmount() { - window.clearTimeout(this._timer); - } - - _scheduleNextUpdate(props: Props, state: States) { - window.clearTimeout(this._timer); - - const { timestamp } = props; - const delta = new Date(timestamp).getTime() - state.now; - const unitDelay = getUnitDelay(selectUnits(delta)); - const unitRemainder = Math.abs(delta % unitDelay); - const updateInterval = 1000 * 10; - const delay = - delta < 0 - ? Math.max(updateInterval, unitDelay - unitRemainder) - : Math.max(updateInterval, unitRemainder); - - this._timer = window.setTimeout(() => { - this.setState({ now: Date.now() }); - }, delay); - } - - render() { - const { - timestamp, - intl, - futureDate, - year = new Date().getFullYear(), - short = true, - } = this.props; - - const timeGiven = timestamp.includes('T'); - const date = new Date(timestamp); - const relativeTime = futureDate - ? timeRemainingString(intl, date, this.state.now, timeGiven) - : timeAgoString(intl, date, this.state.now, year, timeGiven, short); - - return ( - - ); - } -} - -const RelativeTimestampWithIntl = injectIntl(RelativeTimestamp); - -export { RelativeTimestampWithIntl as RelativeTimestamp }; diff --git a/app/javascript/mastodon/components/relative_timestamp/index.tsx b/app/javascript/mastodon/components/relative_timestamp/index.tsx new file mode 100644 index 0000000000..493e535a71 --- /dev/null +++ b/app/javascript/mastodon/components/relative_timestamp/index.tsx @@ -0,0 +1,83 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { FC } from 'react'; + +import { useIntl } from 'react-intl'; + +import { + formatTime, + MAX_TIMEOUT, + relativeTimeParts, + SECOND, + unitToTime, +} from '@/mastodon/utils/time'; + +const dateFormatOptions = { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', +} as const; + +export const RelativeTimestamp: FC<{ + timestamp: string; + long?: boolean; + noTime?: boolean; + noFuture?: boolean; +}> = ({ timestamp, long = false, noTime = false, noFuture = false }) => { + const intl = useIntl(); + + const [now, setNow] = useState(() => Date.now()); + + const date = useMemo(() => { + const date = new Date(timestamp); + return noFuture ? new Date(Math.min(date.getTime(), now)) : date; + }, [noFuture, now, timestamp]); + const ts = date.getTime(); + + useEffect(() => { + let timeoutId = 0; + const scheduleNextUpdate = () => { + const { unit, delta } = relativeTimeParts(ts); + const unitDelay = unitToTime(unit); + const remainder = Math.abs(delta % unitDelay); + const minDelay = 10 * SECOND; + const delay = Math.min( + Math.max(delta < 0 ? unitDelay - remainder : remainder, minDelay), + MAX_TIMEOUT, + ); + + timeoutId = window.setTimeout(() => { + setNow(Date.now()); + scheduleNextUpdate(); + }, delay); + }; + + scheduleNextUpdate(); + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [ts]); + + const daysOnly = !timestamp.includes('T') || noTime; + const relativeTime = useMemo( + () => + formatTime({ + timestamp: ts, + intl, + short: !long, + noTime: daysOnly, + now, + }), + [ts, intl, long, daysOnly, now], + ); + + return ( + + ); +}; diff --git a/app/javascript/mastodon/components/relative_timestamp/relative_timestamp.stories.tsx b/app/javascript/mastodon/components/relative_timestamp/relative_timestamp.stories.tsx new file mode 100644 index 0000000000..978382515d --- /dev/null +++ b/app/javascript/mastodon/components/relative_timestamp/relative_timestamp.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { DAY } from '@/mastodon/utils/time'; + +import { RelativeTimestamp } from './index'; + +const meta = { + title: 'Components/RelativeTimestamp', + component: RelativeTimestamp, + args: { + timestamp: new Date(Date.now() - DAY * 3).toISOString(), + long: false, + noTime: false, + noFuture: false, + }, + argTypes: { + timestamp: { + control: 'date', + }, + }, + render(props) { + const { timestamp } = props; + const dateString = toDateString(timestamp); + + return ; + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Plain: Story = {}; + +export const Long: Story = { + args: { + long: true, + }, +}; + +export const DateOnly: Story = { + args: { + noTime: true, + }, +}; + +export const NoFuture: Story = { + args: { + timestamp: new Date(Date.now() + DAY * 3).toISOString(), + noFuture: true, + }, +}; + +// Storybook has a known bug with changing a date control from a string to number. +function toDateString(timestamp?: number | string) { + if (!timestamp) { + return new Date().toISOString(); + } + + if (typeof timestamp === 'number') { + return new Date(timestamp).toISOString(); + } + + return timestamp; +} diff --git a/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx b/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx index 34516c0634..73584a9e7b 100644 --- a/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx +++ b/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx @@ -53,12 +53,7 @@ export const CollectionMetaData: React.FC<{ id='collections.last_updated_at' defaultMessage='Last updated: {date}' values={{ - date: ( - - ), + date: , }} tagName='li' /> diff --git a/app/javascript/mastodon/features/notifications/components/report.jsx b/app/javascript/mastodon/features/notifications/components/report.jsx index ed043ae789..bc3631c86e 100644 --- a/app/javascript/mastodon/features/notifications/components/report.jsx +++ b/app/javascript/mastodon/features/notifications/components/report.jsx @@ -49,7 +49,7 @@ class Report extends ImmutablePureComponent {
- · + ·
{intl.formatMessage(messages[report.get('category')])}
diff --git a/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx b/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx index ae4c4ed4f7..e3363964a7 100644 --- a/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx @@ -50,7 +50,7 @@ class CompareHistoryModal extends PureComponent { const content = currentVersion.get('content'); const spoilerContent = escapeTextContentForBrowser(currentVersion.get('spoiler_text')); - const formattedDate = ; + const formattedDate = ; const formattedName = ; const label = currentVersion.get('original') ? ( diff --git a/app/javascript/mastodon/utils/time.test.ts b/app/javascript/mastodon/utils/time.test.ts new file mode 100644 index 0000000000..f1b206b424 --- /dev/null +++ b/app/javascript/mastodon/utils/time.test.ts @@ -0,0 +1,36 @@ +import { DAY, HOUR, MINUTE, relativeTimeParts, SECOND } from './time'; + +describe('relativeTimeParts', () => { + const now = Date.now(); + + test.concurrent.each([ + // Now + [0, { value: 0, unit: 'second' }], + + // Past + [-30 * SECOND, { value: -30, unit: 'second' }], + [-90 * SECOND, { value: -2, unit: 'minute' }], + [-30 * MINUTE, { value: -30, unit: 'minute' }], + [-90 * MINUTE, { value: -2, unit: 'hour' }], + [-5 * HOUR, { value: -5, unit: 'hour' }], + [-24 * HOUR, { value: -1, unit: 'day' }], + [-36 * HOUR, { value: -1, unit: 'day' }], + [-47 * HOUR, { value: -2, unit: 'day' }], + [-3 * DAY, { value: -3, unit: 'day' }], + + // Future + [SECOND, { value: 1, unit: 'second' }], + [59 * SECOND, { value: 59, unit: 'second' }], + [MINUTE, { value: 1, unit: 'minute' }], + [MINUTE + SECOND, { value: 1, unit: 'minute' }], + [59 * MINUTE, { value: 59, unit: 'minute' }], + [HOUR, { value: 1, unit: 'hour' }], + [HOUR + MINUTE, { value: 1, unit: 'hour' }], + [23 * HOUR, { value: 23, unit: 'hour' }], + [DAY, { value: 1, unit: 'day' }], + [DAY + HOUR, { value: 1, unit: 'day' }], + [2 * DAY, { value: 2, unit: 'day' }], + ])('should return correct value and unit for %d ms', (input, expected) => { + expect(relativeTimeParts(now + input, now)).toMatchObject(expected); + }); +}); diff --git a/app/javascript/mastodon/utils/time.ts b/app/javascript/mastodon/utils/time.ts new file mode 100644 index 0000000000..c7ed115d24 --- /dev/null +++ b/app/javascript/mastodon/utils/time.ts @@ -0,0 +1,246 @@ +import type { IntlShape } from 'react-intl'; +import { defineMessages } from 'react-intl'; + +export const SECOND = 1000; +export const MINUTE = SECOND * 60; +export const HOUR = MINUTE * 60; +export const DAY = HOUR * 24; + +export const MAX_TIMEOUT = 2147483647; // Maximum delay for setTimeout in browsers (approximately 24.8 days) + +export type TimeUnit = 'second' | 'minute' | 'hour' | 'day'; + +export function relativeTimeParts( + ts: number, + now = Date.now(), +): { value: number; unit: TimeUnit; delta: number } { + const delta = ts - now; + const absDelta = Math.abs(delta); + + if (absDelta < MINUTE) { + return { value: Math.floor(delta / SECOND), unit: 'second', delta }; + } + + if (absDelta < HOUR) { + return { value: Math.floor(delta / MINUTE), unit: 'minute', delta }; + } + + if (absDelta < DAY) { + return { value: Math.floor(delta / HOUR), unit: 'hour', delta }; + } + + // Round instead of use floor as days are big enough that the value is usually off by a few hours. + return { value: Math.round(delta / DAY), unit: 'day', delta }; +} + +export function isToday(ts: number, now = Date.now()): boolean { + const date = new Date(ts); + const nowDate = new Date(now); + return ( + date.getDate() === nowDate.getDate() && + date.getMonth() === nowDate.getMonth() && + date.getFullYear() === nowDate.getFullYear() + ); +} + +export function isSameYear(ts: number, now = Date.now()): boolean { + const date = new Date(ts); + const nowDate = new Date(now); + return date.getFullYear() === nowDate.getFullYear(); +} + +export function unitToTime(unit: TimeUnit): number { + switch (unit) { + case 'second': + return SECOND; + case 'minute': + return MINUTE; + case 'hour': + return HOUR; + case 'day': + return DAY; + } +} + +const timeMessages = defineMessages({ + today: { id: 'relative_time.today', defaultMessage: 'today' }, + just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, + just_now_full: { + id: 'relative_time.full.just_now', + defaultMessage: 'just now', + }, + seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, + seconds_full: { + id: 'relative_time.full.seconds', + defaultMessage: '{number, plural, one {# second} other {# seconds}} ago', + }, + minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, + minutes_full: { + id: 'relative_time.full.minutes', + defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago', + }, + hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, + hours_full: { + id: 'relative_time.full.hours', + defaultMessage: '{number, plural, one {# hour} other {# hours}} ago', + }, + days: { id: 'relative_time.days', defaultMessage: '{number}d' }, + days_full: { + id: 'relative_time.full.days', + defaultMessage: '{number, plural, one {# day} other {# days}} ago', + }, + moments_remaining: { + id: 'time_remaining.moments', + defaultMessage: 'Moments remaining', + }, + seconds_remaining: { + id: 'time_remaining.seconds', + defaultMessage: '{number, plural, one {# second} other {# seconds}} left', + }, + minutes_remaining: { + id: 'time_remaining.minutes', + defaultMessage: '{number, plural, one {# minute} other {# minutes}} left', + }, + hours_remaining: { + id: 'time_remaining.hours', + defaultMessage: '{number, plural, one {# hour} other {# hours}} left', + }, + days_remaining: { + id: 'time_remaining.days', + defaultMessage: '{number, plural, one {# day} other {# days}} left', + }, +}); + +const DAYS_LIMIT = 7; +const NOW_SECONDS = 10; + +export function formatTime({ + timestamp, + intl, + now = Date.now(), + noTime = false, + short = false, +}: { + timestamp: number; + intl: Pick; + now?: number; + noTime?: boolean; + short?: boolean; +}) { + const { value, unit } = relativeTimeParts(timestamp, now); + + // If we're only showing days, show "today" for the current day. + if (noTime && isToday(timestamp, now)) { + return intl.formatMessage(timeMessages.today); + } + + if (value > 0) { + return formatFuture({ value, unit, intl }); + } + + if (unit === 'day' && value < -DAYS_LIMIT) { + return formatAbsoluteTime({ timestamp, intl, now }); + } + + return formatRelativePastTime({ value, unit, intl, short }); +} + +export function formatAbsoluteTime({ + timestamp, + intl, + now = Date.now(), +}: { + timestamp: number; + intl: Pick; + now?: number; +}) { + return intl.formatDate(timestamp, { + month: 'short', + day: 'numeric', + // Only show the year if it's different from the current year. + year: isSameYear(timestamp, now) ? undefined : 'numeric', + }); +} + +export function formatFuture({ + unit, + value, + intl, +}: { + value: number; + unit: TimeUnit; + intl: Pick; +}) { + if (unit === 'day') { + return intl.formatMessage(timeMessages.days_remaining, { number: value }); + } + + if (unit === 'hour') { + return intl.formatMessage(timeMessages.hours_remaining, { + number: value, + }); + } + + if (unit === 'minute') { + return intl.formatMessage(timeMessages.minutes_remaining, { + number: value, + }); + } + + if (value > NOW_SECONDS) { + return intl.formatMessage(timeMessages.seconds_remaining, { + number: value, + }); + } + + return intl.formatMessage(timeMessages.moments_remaining); +} + +export function formatRelativePastTime({ + value, + unit, + intl, + short = false, +}: { + value: number; + unit: TimeUnit; + intl: Pick; + short?: boolean; +}) { + const absValue = Math.abs(value); + if (unit === 'day') { + return intl.formatMessage( + short ? timeMessages.days : timeMessages.days_full, + { + number: absValue, + }, + ); + } + + if (unit === 'hour') { + return intl.formatMessage( + short ? timeMessages.hours : timeMessages.hours_full, + { + number: absValue, + }, + ); + } + + if (unit === 'minute') { + return intl.formatMessage( + short ? timeMessages.minutes : timeMessages.minutes_full, + { number: absValue }, + ); + } + + if (absValue >= NOW_SECONDS) { + return intl.formatMessage( + short ? timeMessages.seconds : timeMessages.seconds_full, + { number: absValue }, + ); + } + + return intl.formatMessage( + short ? timeMessages.just_now : timeMessages.just_now_full, + ); +}