From cd3dccea02f97357ad5cfc248b1c4def93381311 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 15 Aug 2024 12:18:45 +1000 Subject: [PATCH] fix: use date-fns for abbreviated expire timer --- .../conversation/SubtleNotification.tsx | 3 +- .../message-content/MessageReactBar.tsx | 47 ++---- .../message-item/GroupUpdateMessage.tsx | 5 +- .../message-info/components/MessageInfo.tsx | 2 +- .../disappearing_messages/timerOptions.ts | 2 +- ts/state/selectors/selectedConversation.ts | 37 +---- .../unit/utils/i18n/abbreviated_timer_test.ts | 89 +++++++++++ ts/util/i18n.ts | 144 +++++++++++++++--- 8 files changed, 225 insertions(+), 104 deletions(-) create mode 100644 ts/test/session/unit/utils/i18n/abbreviated_timer_test.ts diff --git a/ts/components/conversation/SubtleNotification.tsx b/ts/components/conversation/SubtleNotification.tsx index 529bd8d64..b7fe2a9b9 100644 --- a/ts/components/conversation/SubtleNotification.tsx +++ b/ts/components/conversation/SubtleNotification.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import styled from 'styled-components'; import { useIsIncomingRequest, useIsOutgoingRequest } from '../../hooks/useParamSelector'; +import { SessionUtilContact } from '../../session/utils/libsession/libsession_utils_contacts'; import { getSelectedHasMessages, hasSelectedConversationIncomingMessages, @@ -15,8 +16,6 @@ import { useSelectedNicknameOrProfileNameOrShortenedPubkey, } from '../../state/selectors/selectedConversation'; import { I18n } from '../basic/I18n'; -import { SessionUtilContact } from '../../session/utils/libsession/libsession_utils_contacts'; -import { SessionHtmlRenderer } from '../basic/SessionHTMLRenderer'; const Container = styled.div` display: flex; diff --git a/ts/components/conversation/message/message-content/MessageReactBar.tsx b/ts/components/conversation/message/message-content/MessageReactBar.tsx index e53200dbf..56ead33d4 100644 --- a/ts/components/conversation/message/message-content/MessageReactBar.tsx +++ b/ts/components/conversation/message/message-content/MessageReactBar.tsx @@ -1,12 +1,12 @@ import styled from 'styled-components'; import { isEmpty } from 'lodash'; -import moment from 'moment'; import useBoolean from 'react-use/lib/useBoolean'; import useInterval from 'react-use/lib/useInterval'; import { useMessageExpirationPropsById } from '../../../../hooks/useParamSelector'; import { DURATION } from '../../../../session/constants'; import { nativeEmojiData } from '../../../../util/emoji'; +import { formatAbbreviatedExpireDoubleTimer } from '../../../../util/i18n'; import { getRecentReactions } from '../../../../util/storage'; import { SpacerSM } from '../../../basic/Text'; import { SessionIcon, SessionIconButton } from '../../../icon'; @@ -96,51 +96,26 @@ function useIsRenderedExpiresInItem(messageId: string) { return expiryDetails.expirationTimestamp; } -// TODO - sort out all of these times and localize them - function formatTimeLeft({ timeLeftMs }: { timeLeftMs: number }) { - const timeLeft = moment(timeLeftMs).utc(); + const timeLeftSeconds = Math.floor(timeLeftMs / 1000); - if (timeLeftMs <= 0) { + if (timeLeftSeconds <= 0) { return `0s`; } - if (timeLeft.isBefore(moment.utc(0).add(1, 'minute'))) { - return window.i18n('disappearingMessagesCountdownBig', { - time_large: `${timeLeft.seconds()}s`, - }); - } - - if (timeLeft.isBefore(moment.utc(0).add(1, 'hour'))) { - const extraUnit = timeLeft.seconds() ? ` ${timeLeft.seconds()}s` : ''; - return window.i18n('disappearingMessagesCountdownBig', { - time_large: `${timeLeft.minutes()}m${extraUnit}`, - }); - } - - if (timeLeft.isBefore(moment.utc(0).add(1, 'day'))) { - const extraUnit = timeLeft.minutes() ? ` ${timeLeft.minutes()}m` : ''; - return window.i18n('disappearingMessagesCountdownBig', { - time_large: `${timeLeft.hours()}h${extraUnit}`, + const parts = formatAbbreviatedExpireDoubleTimer(timeLeftSeconds); + if (parts.length === 2) { + return window.i18n('disappearingMessagesCountdownBigSmall', { + time_large: parts[0], + time_small: parts[1], }); } - - if (timeLeft.isBefore(moment.utc(0).add(7, 'day'))) { - const extraUnit = timeLeft.hours() ? ` ${timeLeft.hours()}h` : ''; + if (parts.length === 1) { return window.i18n('disappearingMessagesCountdownBig', { - time_large: `${timeLeft.dayOfYear() - 1}d${extraUnit}`, + time_large: parts[0], }); } - - if (timeLeft.isBefore(moment.utc(0).add(31, 'day'))) { - const days = timeLeft.dayOfYear() - 1; - const weeks = Math.floor(days / 7); - const daysLeft = days % 7; - const extraUnit = daysLeft ? ` ${daysLeft}d` : ''; - return window.i18n('', { time_large: `${weeks}w${extraUnit}` }); - } - - return '...'; + throw new Error('formatTimeLeft unexpected duration given'); } const ExpiresInItem = ({ expirationTimestamp }: { expirationTimestamp?: number | null }) => { diff --git a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx index 0c7e55e2e..67bb5b053 100644 --- a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx +++ b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx @@ -4,10 +4,7 @@ import { PropsForGroupUpdate, PropsForGroupUpdateType, } from '../../../../state/ducks/conversations'; -import { - useSelectedDisplayNameInProfile, - useSelectedNicknameOrProfileNameOrShortenedPubkey, -} from '../../../../state/selectors/selectedConversation'; +import { useSelectedNicknameOrProfileNameOrShortenedPubkey } from '../../../../state/selectors/selectedConversation'; import { assertUnreachable } from '../../../../types/sqlSharedTypes'; import { ExpirableReadableMessage } from './ExpirableReadableMessage'; import { NotificationBubble } from './notification-bubble/NotificationBubble'; diff --git a/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx b/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx index a0c3ce629..dec604ad7 100644 --- a/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx +++ b/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx @@ -99,7 +99,7 @@ const DebugMessageInfo = ({ messageId }: { messageId: string }) => { {expirationDurationMs ? ( ) : null} {expirationTimestamp ? ( diff --git a/ts/session/disappearing_messages/timerOptions.ts b/ts/session/disappearing_messages/timerOptions.ts index dd3802658..d12db21c4 100644 --- a/ts/session/disappearing_messages/timerOptions.ts +++ b/ts/session/disappearing_messages/timerOptions.ts @@ -36,7 +36,7 @@ const VALUES: Array = [ function getName(seconds = 0) { if (seconds >= 0) { - return formatTimeDistance(seconds, 0); + return formatTimeDistance(seconds); } return [seconds, 'seconds'].join(' '); diff --git a/ts/state/selectors/selectedConversation.ts b/ts/state/selectors/selectedConversation.ts index bb10ed163..eda486d54 100644 --- a/ts/state/selectors/selectedConversation.ts +++ b/ts/state/selectors/selectedConversation.ts @@ -2,14 +2,13 @@ import { isString } from 'lodash'; import { useSelector } from 'react-redux'; import { useUnreadCount } from '../../hooks/useParamSelector'; import { isOpenOrClosedGroup } from '../../models/conversationAttributes'; +import { ConversationTypeEnum } from '../../models/types'; import { DisappearingMessageConversationModeType, DisappearingMessageConversationModes, } from '../../session/disappearing_messages/types'; import { PubKey } from '../../session/types'; import { UserUtils } from '../../session/utils'; -import { ReleasedFeatures } from '../../util/releaseFeature'; -import { ReduxConversationType } from '../ducks/conversations'; import { StateType } from '../reducer'; import { getIsMessageSelectionMode, @@ -17,7 +16,6 @@ import { getSelectedMessageIds, } from './conversations'; import { getCanWrite, getModerators, getSubscriberCount } from './sogsRoomInfo'; -import { ConversationTypeEnum } from '../../models/types'; /** * Returns the formatted text for notification setting. @@ -174,45 +172,12 @@ const getSelectedSubscriberCount = (state: StateType): number | undefined => { return getSubscriberCount(state, convo.id); }; -// TODO legacy messages support will be removed in a future release -const getSelectedConversationExpirationModesWithLegacy = (convo: ReduxConversationType) => { - if (!convo) { - return undefined; - } - - // NOTE this needs to be as any because the number of modes can change depending on if v2 is released or we are in single mode - let modes: any = DisappearingMessageConversationModes; - - // Note to Self and Closed Groups only support deleteAfterSend and legacy modes - const isClosedGroup = !convo.isPrivate && !convo.isPublic; - if (convo?.isMe || isClosedGroup) { - modes = [modes[0], ...modes.slice(2)]; - } - - // Legacy mode is the 2nd option in the UI - modes = [modes[0], modes[modes.length - 1], ...modes.slice(1, modes.length - 1)]; - - const modesWithDisabledState: Record = {}; - // The new modes are disabled by default - if (modes && modes.length > 1) { - modes.forEach((mode: any) => { - modesWithDisabledState[mode] = Boolean(mode !== 'legacy' && mode !== 'off'); - }); - } - - return modesWithDisabledState; -}; - export const getSelectedConversationExpirationModes = (state: StateType) => { const convo = getSelectedConversation(state); if (!convo) { return undefined; } - if (!ReleasedFeatures.isDisappearMessageV2FeatureReleasedCached()) { - return getSelectedConversationExpirationModesWithLegacy(convo); - } - // NOTE this needs to be as any because the number of modes can change depending on if v2 is released or we are in single mode let modes: any = DisappearingMessageConversationModes; // TODO legacy messages support will be removed in a future release diff --git a/ts/test/session/unit/utils/i18n/abbreviated_timer_test.ts b/ts/test/session/unit/utils/i18n/abbreviated_timer_test.ts new file mode 100644 index 000000000..7e6771c60 --- /dev/null +++ b/ts/test/session/unit/utils/i18n/abbreviated_timer_test.ts @@ -0,0 +1,89 @@ +import { expect } from 'chai'; +import { formatAbbreviatedExpireDoubleTimer } from '../../../../../util/i18n'; + +describe('formatAbbreviatedExpireDoubleTimer', () => { + it('<= 0 returns 0s', () => { + expect(formatAbbreviatedExpireDoubleTimer(0)).to.be.deep.eq(['0s']); + expect(formatAbbreviatedExpireDoubleTimer(-1)).to.be.deep.eq(['0s']); + expect(formatAbbreviatedExpireDoubleTimer(-3600)).to.be.deep.eq(['0s']); + expect(formatAbbreviatedExpireDoubleTimer(Number.MIN_SAFE_INTEGER)).to.be.deep.eq(['0s']); + }); + it('single units', () => { + expect(formatAbbreviatedExpireDoubleTimer(1)).to.be.deep.eq(['1s']); + expect(formatAbbreviatedExpireDoubleTimer(60 - 1)).to.be.deep.eq(['59s']); + expect(formatAbbreviatedExpireDoubleTimer(60)).to.be.deep.eq(['1m']); + expect(formatAbbreviatedExpireDoubleTimer(60 * 2)).to.be.deep.eq(['2m']); + expect(formatAbbreviatedExpireDoubleTimer(3600 - 60)).to.be.deep.eq(['59m']); + expect(formatAbbreviatedExpireDoubleTimer(3600)).to.be.deep.eq(['1h']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 - 3600)).to.be.deep.eq(['23h']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24)).to.be.deep.eq(['1d']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 7 - 3600 * 24)).to.be.deep.eq(['6d']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 7)).to.be.deep.eq(['1w']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 7 * 2)).to.be.deep.eq(['2w']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 7 * 4)).to.be.deep.eq(['4w']); + }); + it('double units', () => { + expect(formatAbbreviatedExpireDoubleTimer(60 + 1)).to.be.deep.eq(['1m', '1s']); + expect(formatAbbreviatedExpireDoubleTimer(60 + 59)).to.be.deep.eq(['1m', '59s']); + expect(formatAbbreviatedExpireDoubleTimer(60 + 60 + 59)).to.be.deep.eq(['2m', '59s']); + expect(formatAbbreviatedExpireDoubleTimer(3600 - 60 + 1)).to.be.deep.eq(['59m', '1s']); + expect(formatAbbreviatedExpireDoubleTimer(3600 - 1)).to.be.deep.eq(['59m', '59s']); + expect(formatAbbreviatedExpireDoubleTimer(3600 + 1)).to.be.deep.eq(['1h', '1s']); + expect(formatAbbreviatedExpireDoubleTimer(3600 + 59)).to.be.deep.eq(['1h', '59s']); + expect(formatAbbreviatedExpireDoubleTimer(3600 + 60 + 1)).to.be.deep.eq(['1h', '1m']); // even if we have an extra 1s to display','we crop at 2 units display + expect(formatAbbreviatedExpireDoubleTimer(3600 + 1)).to.be.deep.eq(['1h', '1s']); // we don't have minutes to display so we show h+s + expect(formatAbbreviatedExpireDoubleTimer(3600 * 23 + 1)).to.be.deep.eq(['23h', '1s']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 23 + 60 + 1)).to.be.deep.eq(['23h', '1m']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 + 1)).to.be.deep.eq(['1d', '1s']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 + 60)).to.be.deep.eq(['1d', '1m']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 + 60 + 1)).to.be.deep.eq(['1d', '1m']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 + 60 + 59)).to.be.deep.eq(['1d', '1m']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 + 60 * 2)).to.be.deep.eq(['1d', '2m']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 + 60 * 2)).to.be.deep.eq(['1d', '2m']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 + 60 * 59)).to.be.deep.eq(['1d', '59m']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 + 60 * 59 + 6)).to.be.deep.eq([ + '1d', + '59m', + ]); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 7 + 6)).to.be.deep.eq(['1w', '6s']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 7 + 60)).to.be.deep.eq(['1w', '1m']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 7 + 60 * 59)).to.be.deep.eq([ + '1w', + '59m', + ]); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 7 + 3600)).to.be.deep.eq(['1w', '1h']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 7 + 3600 + 1)).to.be.deep.eq([ + '1w', + '1h', + ]); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 7 + 3600 * 24 * 6)).to.be.deep.eq([ + '1w', + '6d', + ]); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 14 + 1)).to.be.deep.eq(['2w', '1s']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 14 + 59)).to.be.deep.eq(['2w', '59s']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 14 + 60)).to.be.deep.eq(['2w', '1m']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 14 + 60 + 1)).to.be.deep.eq(['2w', '1m']); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 14 + 60 * 59)).to.be.deep.eq([ + '2w', + '59m', + ]); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 14 + 3600 * 24)).to.be.deep.eq([ + '2w', + '1d', + ]); + expect(formatAbbreviatedExpireDoubleTimer(3600 * 24 * 14 + 3600 * 24 * 6)).to.be.deep.eq([ + '2w', + '6d', + ]); + }); + + it('throws if invalid', () => { + expect(() => { + formatAbbreviatedExpireDoubleTimer(Number.MAX_VALUE); + }).to.throw(); + expect(() => { + formatAbbreviatedExpireDoubleTimer(3600 * 24 * 7 * 4 + 1); // 1s more than 4 weeks + }).to.throw(); + }); +}); diff --git a/ts/util/i18n.ts b/ts/util/i18n.ts index ea32dfebc..835a6f4f7 100644 --- a/ts/util/i18n.ts +++ b/ts/util/i18n.ts @@ -1,11 +1,14 @@ // this file is a weird one as it is used by both sides of electron at the same time import { + Duration, FormatDistanceStrictOptions, FormatDistanceToNowStrictOptions, formatDistanceStrict, formatDistanceToNow, formatDistanceToNowStrict, + formatDuration, + intervalToDuration, subMilliseconds, } from 'date-fns'; import timeLocales from 'date-fns/locale'; @@ -283,56 +286,149 @@ export const loadEmojiPanelI18n = async () => { return undefined; }; -type First any> = Parameters[0]; -type Second any> = Parameters[1]; - export const formatTimeDistance = ( - date: First, - baseDate: Second, + durationSeconds: number, + baseDate: Date = new Date(0), options?: Omit ) => { const locale = window.getLocale(); - return formatDistanceStrict(date, baseDate, { + + return formatDistanceStrict(new Date(durationSeconds * 1000), baseDate, { locale: timeLocaleMap[locale], ...options, }); }; +/** + * date-fns `intervalToDuration` takes a duration in ms. + * This is a simple wrapper to avoid duplicating this (and not forget about it). + * + * Note: + * - date-fns intervalToDuration returns doesn't return 2w for 14d and such, so this forces it to be used. + * - this will throw if the duration is > 4 weeks + * + * @param seconds the seconds to get the durations from + * @returns a date-fns `Duration` type with the fields set + */ +const secondsToDuration = (seconds: number): Duration => { + if (seconds > 3600 * 24 * 28) { + throw new Error('secondsToDuration cannot handle more than 4 weeks for now'); + } + const duration = intervalToDuration({ start: 0, end: new Date(seconds * 1000) }); + + if (!duration) { + throw new Error('intervalToDuration failed to convert duration'); + } + + if (duration.days) { + duration.weeks = Math.floor(duration.days / 7); + duration.days %= 7; + } + + return duration; +}; + +/** + * We decided against localizing the abbreviated durations like 1h, 1m, 1s as most apps don't. + * This function just replaces any long form of "seconds?" to "s", "minutes?" to "m", etc. + * + * Note: + * We don't replace to 'months' as it would be the same as 'minutes', so this function shouldn't be used for a string containing months or longer units in it. + * + * Date-fns also doesn't support the 'narrow' syntax for formatDistanceStrict and even if it did, minutes are abbreviated as 'min' in english. + * + * @param unlocalized the string containing the units to abbreviate + * @returns the string with abbreviated units + */ +const unlocalizedDurationToAbbreviated = (unlocalized: string): string => { + return unlocalized + .replace(/ weeks?/g, 'w') + .replace(/ days?/g, 'd') + .replace(/ hours?/g, 'h') + .replace(/ minutes?/g, 'm') + .replace(/ seconds?/g, 's'); +}; + /** * Format an expiring/disappearing message timer to its abbreviated form. - * Note: we don't localize this, and cannot have a value > 2 weeks - * @param date - * @param options - * @returns + * Note: we don't localize this, and cannot have a value > 4 weeks + * + * @param timerSeconds the timer to format, in seconds + * @returns '1h' for a duration of 3600s. */ export const formatAbbreviatedExpireTimer = (timerSeconds: number) => { // Note: we keep this function in this file even if it is not localizing anything // so we have access to timeLocaleMap.en. - if (timerSeconds > DURATION_SECONDS.WEEKS * 2) { - throw new Error('formatAbbreviatedExpireTimer is not design to handle >2 weeks durations '); + if (timerSeconds > DURATION_SECONDS.WEEKS * 4) { + throw new Error('formatAbbreviatedExpireTimer is not design to handle >4 weeks durations '); + } + + const duration = secondsToDuration(timerSeconds); + + const unlocalized = formatDuration(duration, { + locale: timeLocaleMap.en, + }); + + return unlocalizedDurationToAbbreviated(unlocalized); +}; + +/** + * Format an expiring/disappearing message timer to its abbreviated form. + * Note: we don't localize this, and cannot have a value > 4 weeks + * + * @param timerSeconds the timer to format, in seconds + * @returns '1h' for a duration of 3600s. + */ +export const formatAbbreviatedExpireDoubleTimer = (timerSeconds: number) => { + // Note: we keep this function in this file even if it is not localizing anything + // so we have access to timeLocaleMap.en. + + if (timerSeconds > DURATION_SECONDS.WEEKS * 4) { + throw new Error( + 'formatAbbreviatedExpireDoubleTimer is not design to handle >4 weeks durations ' + ); + } + if (timerSeconds <= 0) { + return ['0s']; + } + + const duration = secondsToDuration(timerSeconds); + + const format: Array = []; + if (duration.months || duration.years) { + throw new Error("we don't support years or months to be !== 0"); + } + if (duration.weeks && format.length < 2) { + format.push('weeks'); + } + if (duration.days && format.length < 2) { + format.push('days'); + } + if (duration.hours && format.length < 2) { + format.push('hours'); + } + if (duration.minutes && format.length < 2) { + format.push('minutes'); + } + if (duration.seconds && format.length < 2) { + format.push('seconds'); } - const unlocalized = formatDistanceStrict(timerSeconds, 0, { + const unlocalized = formatDuration(duration, { locale: timeLocaleMap.en, + delimiter: '#', + format, }); - // Pretty dirty, but we don't want to localize the abbreviated durations. - // date-fns also doesn't support the 'narrow' syntax for formatDistanceStrict so we just abbreviate - // the strings that we know are in english - return unlocalized - .replace(/weeks?/g, 'w') - .replace(/days?/g, 'd') - .replace(/hours?/g, 'h') - .replace(/minutes?/g, 'm') - .replace(/seconds?/g, 's'); + return unlocalizedDurationToAbbreviated(unlocalized).split('#'); }; export const formatTimeDistanceToNow = ( - date: First, + durationSeconds: number, options?: Omit ) => { const locale = window.getLocale(); - return formatDistanceToNowStrict(date, { + return formatDistanceToNowStrict(durationSeconds * 1000, { locale: timeLocaleMap[locale], ...options, });