fix: use date-fns for abbreviated expire timer

pull/3206/head
Audric Ackermann 8 months ago
parent be23ef0e92
commit cd3dccea02

@ -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;

@ -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 }) => {

@ -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';

@ -99,7 +99,7 @@ const DebugMessageInfo = ({ messageId }: { messageId: string }) => {
{expirationDurationMs ? (
<LabelWithInfo
label={`Expiration Duration:`}
info={`${formatTimeDistance(Math.floor(expirationDurationMs / 1000), 0)}`}
info={`${formatTimeDistance(Math.floor(expirationDurationMs / 1000))}`}
/>
) : null}
{expirationTimestamp ? (

@ -36,7 +36,7 @@ const VALUES: Array<number> = [
function getName(seconds = 0) {
if (seconds >= 0) {
return formatTimeDistance(seconds, 0);
return formatTimeDistance(seconds);
}
return [seconds, 'seconds'].join(' ');

@ -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<string, boolean> = {};
// 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

@ -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();
});
});

@ -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<T extends (...args: any) => any> = Parameters<T>[0];
type Second<T extends (...args: any) => any> = Parameters<T>[1];
export const formatTimeDistance = (
date: First<typeof formatDistanceStrict>,
baseDate: Second<typeof formatDistanceStrict>,
durationSeconds: number,
baseDate: Date = new Date(0),
options?: Omit<FormatDistanceStrictOptions, 'locale'>
) => {
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<keyof Duration> = [];
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<typeof formatDistanceToNowStrict>,
durationSeconds: number,
options?: Omit<FormatDistanceToNowStrictOptions, 'locale'>
) => {
const locale = window.getLocale();
return formatDistanceToNowStrict(date, {
return formatDistanceToNowStrict(durationSeconds * 1000, {
locale: timeLocaleMap[locale],
...options,
});

Loading…
Cancel
Save