diff --git a/ts/components/DisappearingTimeDialog.tsx b/ts/components/DisappearingTimeDialog.tsx index 96be9abee2d..89a33640c01 100644 --- a/ts/components/DisappearingTimeDialog.tsx +++ b/ts/components/DisappearingTimeDialog.tsx @@ -7,6 +7,7 @@ import { ConfirmationDialog } from './ConfirmationDialog'; import { Select } from './Select'; import type { LocalizerType } from '../types/Util'; import type { Theme } from '../util/theme'; +import { DurationInSeconds } from '../util/durations'; const CSS_MODULE = 'module-disappearing-time-dialog'; @@ -15,14 +16,14 @@ const DEFAULT_VALUE = 60; export type PropsType = Readonly<{ i18n: LocalizerType; theme?: Theme; - initialValue?: number; - onSubmit: (value: number) => void; + initialValue?: DurationInSeconds; + onSubmit: (value: DurationInSeconds) => void; onClose: () => void; }>; const UNITS = ['seconds', 'minutes', 'hours', 'days', 'weeks']; -const UNIT_TO_MS = new Map([ +const UNIT_TO_SEC = new Map([ ['seconds', 1], ['minutes', 60], ['hours', 60 * 60], @@ -50,14 +51,14 @@ export function DisappearingTimeDialog(props: PropsType): JSX.Element { let initialUnit = 'seconds'; let initialUnitValue = 1; for (const unit of UNITS) { - const ms = UNIT_TO_MS.get(unit) || 1; + const sec = UNIT_TO_SEC.get(unit) || 1; - if (initialValue < ms) { + if (initialValue < sec) { break; } initialUnit = unit; - initialUnitValue = Math.floor(initialValue / ms); + initialUnitValue = Math.floor(initialValue / sec); } const [unitValue, setUnitValue] = useState(initialUnitValue); @@ -84,7 +85,11 @@ export function DisappearingTimeDialog(props: PropsType): JSX.Element { text: i18n('DisappearingTimeDialog__set'), style: 'affirmative', action() { - onSubmit(unitValue * (UNIT_TO_MS.get(unit) || 1)); + onSubmit( + DurationInSeconds.fromSeconds( + unitValue * (UNIT_TO_SEC.get(unit) ?? 1) + ) + ); }, }, ]} diff --git a/ts/components/DisappearingTimerSelect.stories.tsx b/ts/components/DisappearingTimerSelect.stories.tsx index 04d681c8f2d..e8aa8fffe29 100644 --- a/ts/components/DisappearingTimerSelect.stories.tsx +++ b/ts/components/DisappearingTimerSelect.stories.tsx @@ -5,6 +5,7 @@ import React, { useState } from 'react'; import { DisappearingTimerSelect } from './DisappearingTimerSelect'; import { setupI18n } from '../util/setupI18n'; +import { DurationInSeconds } from '../util/durations'; import enMessages from '../../_locales/en/messages.json'; export default { @@ -23,7 +24,7 @@ const TimerSelectWrap: React.FC = ({ initialValue }) => { return ( setValue(newValue)} /> ); diff --git a/ts/components/DisappearingTimerSelect.tsx b/ts/components/DisappearingTimerSelect.tsx index f84de20ef06..f78a1015fe8 100644 --- a/ts/components/DisappearingTimerSelect.tsx +++ b/ts/components/DisappearingTimerSelect.tsx @@ -7,6 +7,7 @@ import classNames from 'classnames'; import type { LocalizerType } from '../types/Util'; import * as expirationTimer from '../util/expirationTimer'; +import { DurationInSeconds } from '../util/durations'; import { DisappearingTimeDialog } from './DisappearingTimeDialog'; import { Select } from './Select'; @@ -16,17 +17,17 @@ const CSS_MODULE = 'module-disappearing-timer-select'; export type Props = { i18n: LocalizerType; - value?: number; - onChange(value: number): void; + value?: DurationInSeconds; + onChange(value: DurationInSeconds): void; }; export const DisappearingTimerSelect: React.FC = (props: Props) => { - const { i18n, value = 0, onChange } = props; + const { i18n, value = DurationInSeconds.ZERO, onChange } = props; const [isModalOpen, setIsModalOpen] = useState(false); let expirationTimerOptions: ReadonlyArray<{ - readonly value: number; + readonly value: DurationInSeconds; readonly text: string; }> = expirationTimer.DEFAULT_DURATIONS_IN_SECONDS.map(seconds => { const text = expirationTimer.format(i18n, seconds, { @@ -42,7 +43,7 @@ export const DisappearingTimerSelect: React.FC = (props: Props) => { !expirationTimer.DEFAULT_DURATIONS_SET.has(value); const onSelectChange = (newValue: string) => { - const intValue = parseInt(newValue, 10); + const intValue = DurationInSeconds.fromSeconds(parseInt(newValue, 10)); if (intValue === -1) { setIsModalOpen(true); } else { @@ -54,7 +55,7 @@ export const DisappearingTimerSelect: React.FC = (props: Props) => { expirationTimerOptions = [ ...expirationTimerOptions, { - value: -1, + value: DurationInSeconds.fromSeconds(-1), text: i18n( isCustomTimeSelected ? 'selectedCustomDisappearingTimeOption' diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index ffddfd18cd2..67d9483adcd 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -13,6 +13,7 @@ import { CrashReportDialog } from './CrashReportDialog'; import type { ConversationType } from '../state/ducks/conversations'; import { MessageSearchResult } from './conversationList/MessageSearchResult'; import { setupI18n } from '../util/setupI18n'; +import { DurationInSeconds } from '../util/durations'; import enMessages from '../../_locales/en/messages.json'; import { ThemeType } from '../types/Util'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; @@ -953,7 +954,7 @@ export const GroupMetadataNoTimer = (): JSX.Element => ( mode: LeftPaneMode.SetGroupMetadata, groupAvatar: undefined, groupName: 'Group 1', - groupExpireTimer: 0, + groupExpireTimer: DurationInSeconds.ZERO, hasError: false, isCreating: false, isEditingAvatar: false, @@ -975,7 +976,7 @@ export const GroupMetadataRegularTimer = (): JSX.Element => ( mode: LeftPaneMode.SetGroupMetadata, groupAvatar: undefined, groupName: 'Group 1', - groupExpireTimer: 24 * 3600, + groupExpireTimer: DurationInSeconds.DAY, hasError: false, isCreating: false, isEditingAvatar: false, @@ -997,7 +998,7 @@ export const GroupMetadataCustomTimer = (): JSX.Element => ( mode: LeftPaneMode.SetGroupMetadata, groupAvatar: undefined, groupName: 'Group 1', - groupExpireTimer: 7 * 3600, + groupExpireTimer: DurationInSeconds.fromHours(7), hasError: false, isCreating: false, isEditingAvatar: false, diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 5120d57463b..2d42efa6140 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -28,6 +28,7 @@ import { ScrollBehavior } from '../types/Util'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import { usePrevious } from '../hooks/usePrevious'; import { missingCaseError } from '../util/missingCaseError'; +import type { DurationInSeconds } from '../util/durations'; import type { WidthBreakpoint } from './_util'; import { getConversationListWidthBreakpoint } from './_util'; import * as KeyboardLayout from '../services/keyboardLayout'; @@ -106,7 +107,7 @@ export type PropsType = { savePreferredLeftPaneWidth: (_: number) => void; searchInConversation: (conversationId: string) => unknown; setComposeGroupAvatar: (_: undefined | Uint8Array) => void; - setComposeGroupExpireTimer: (_: number) => void; + setComposeGroupExpireTimer: (_: DurationInSeconds) => void; setComposeGroupName: (_: string) => void; setComposeSearchTerm: (composeSearchTerm: string) => void; showArchivedConversations: () => void; diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index b9a50bbcde7..25f41573f78 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -12,6 +12,7 @@ import { DEFAULT_CONVERSATION_COLOR } from '../types/Colors'; import { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode'; import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability'; import { objectMap } from '../util/objectMap'; +import { DurationInSeconds } from '../util/durations'; const i18n = setupI18n('en', enMessages); @@ -107,7 +108,7 @@ const getDefaultArgs = (): PropsDataType => ({ selectedSpeaker: availableSpeakers[1], shouldShowStoriesSettings: true, themeSetting: 'system', - universalExpireTimer: 3600, + universalExpireTimer: DurationInSeconds.HOUR, whoCanFindMe: PhoneNumberDiscoverability.Discoverable, whoCanSeeMe: PhoneNumberSharingMode.Everybody, zoomFactor: 1, @@ -186,7 +187,7 @@ BlockedMany.args = { export const CustomUniversalExpireTimer = Template.bind({}); CustomUniversalExpireTimer.args = { - universalExpireTimer: 9000, + universalExpireTimer: DurationInSeconds.fromSeconds(9000), }; CustomUniversalExpireTimer.story = { name: 'Custom universalExpireTimer', diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index ae5e7393d97..2acea09cb5e 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -37,6 +37,7 @@ import { DEFAULT_DURATIONS_SET, format as formatExpirationTimer, } from '../util/expirationTimer'; +import { DurationInSeconds } from '../util/durations'; import { useEscapeHandling } from '../hooks/useEscapeHandling'; import { useUniqueId } from '../hooks/useUniqueId'; import { useTheme } from '../hooks/useTheme'; @@ -76,7 +77,7 @@ export type PropsDataType = { selectedMicrophone?: AudioDevice; selectedSpeaker?: AudioDevice; themeSetting: ThemeSettingType; - universalExpireTimer: number; + universalExpireTimer: DurationInSeconds; whoCanFindMe: PhoneNumberDiscoverability; whoCanSeeMe: PhoneNumberSharingMode; zoomFactor: ZoomFactorType; @@ -280,7 +281,7 @@ export const Preferences = ({ setGlobalDefaultConversationColor, shouldShowStoriesSettings, themeSetting, - universalExpireTimer = 0, + universalExpireTimer = DurationInSeconds.ZERO, whoCanFindMe, whoCanSeeMe, zoomFactor, @@ -954,7 +955,7 @@ export const Preferences = ({ { value: isCustomDisappearingMessageValue ? universalExpireTimer - : -1, + : DurationInSeconds.fromSeconds(-1), text: isCustomDisappearingMessageValue ? formatExpirationTimer(i18n, universalExpireTimer) : i18n('selectedCustomDisappearingTimeOption'), diff --git a/ts/components/StoryDetailsModal.tsx b/ts/components/StoryDetailsModal.tsx index 4ef2544b955..7af389ba262 100644 --- a/ts/components/StoryDetailsModal.tsx +++ b/ts/components/StoryDetailsModal.tsx @@ -13,9 +13,10 @@ import { Intl } from './Intl'; import { Modal } from './Modal'; import { SendStatus } from '../messages/MessageSendState'; import { Theme } from '../util/theme'; +import { formatDateTimeLong } from '../util/timestamp'; +import { DurationInSeconds } from '../util/durations'; import { ThemeType } from '../types/Util'; import { Time } from './Time'; -import { formatDateTimeLong } from '../util/timestamp'; import { groupBy } from '../util/mapUtil'; import { format as formatRelativeTime } from '../util/expirationTimer'; @@ -189,7 +190,7 @@ export const StoryDetailsModal = ({ } const timeRemaining = expirationTimestamp - ? expirationTimestamp - Date.now() + ? DurationInSeconds.fromMillis(expirationTimestamp - Date.now()) : undefined; return ( @@ -254,7 +255,7 @@ export const StoryDetailsModal = ({ id="StoryDetailsModal__disappears-in" components={[ - {formatRelativeTime(i18n, timeRemaining / 1000, { + {formatRelativeTime(i18n, timeRemaining, { largest: 2, })} , diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index 24fd3324c2e..38e9ed816b0 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -9,6 +9,7 @@ import { action } from '@storybook/addon-actions'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { getRandomColor } from '../../test-both/helpers/getRandomColor'; import { setupI18n } from '../../util/setupI18n'; +import { DurationInSeconds } from '../../util/durations'; import enMessages from '../../../_locales/en/messages.json'; import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext'; import { @@ -152,7 +153,7 @@ export const PrivateConvo = (): JSX.Element => { phoneNumber: '(202) 555-0005', type: 'direct', id: '7', - expireTimer: 10, + expireTimer: DurationInSeconds.fromSeconds(10), acceptedMessageRequest: true, }, }, @@ -165,7 +166,7 @@ export const PrivateConvo = (): JSX.Element => { phoneNumber: '(202) 555-0005', type: 'direct', id: '8', - expireTimer: 300, + expireTimer: DurationInSeconds.fromSeconds(300), acceptedMessageRequest: true, isVerified: true, canChangeTimer: true, @@ -231,7 +232,7 @@ export const Group = (): JSX.Element => { phoneNumber: '', id: '11', type: 'group', - expireTimer: 10, + expireTimer: DurationInSeconds.fromSeconds(10), acceptedMessageRequest: true, outgoingCallButtonStyle: OutgoingCallButtonStyle.JustVideo, }, @@ -247,7 +248,7 @@ export const Group = (): JSX.Element => { id: '12', type: 'group', left: true, - expireTimer: 10, + expireTimer: DurationInSeconds.fromSeconds(10), acceptedMessageRequest: true, outgoingCallButtonStyle: OutgoingCallButtonStyle.JustVideo, }, @@ -262,7 +263,7 @@ export const Group = (): JSX.Element => { phoneNumber: '', id: '13', type: 'group', - expireTimer: 10, + expireTimer: DurationInSeconds.fromSeconds(10), acceptedMessageRequest: true, outgoingCallButtonStyle: OutgoingCallButtonStyle.Join, }, @@ -277,7 +278,7 @@ export const Group = (): JSX.Element => { phoneNumber: '', id: '14', type: 'group', - expireTimer: 10, + expireTimer: DurationInSeconds.fromSeconds(10), acceptedMessageRequest: true, outgoingCallButtonStyle: OutgoingCallButtonStyle.JustVideo, muteExpiresAt: Infinity, diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 5c50a776d78..db9fc0f5163 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -28,6 +28,7 @@ import * as expirationTimer from '../../util/expirationTimer'; import { missingCaseError } from '../../util/missingCaseError'; import { isInSystemContacts } from '../../util/isInSystemContacts'; import { isConversationMuted } from '../../util/isConversationMuted'; +import { DurationInSeconds } from '../../util/durations'; import { useStartCallShortcuts, useKeyboardShortcuts, @@ -79,7 +80,7 @@ export type PropsDataType = { export type PropsActionsType = { onSetMuteNotifications: (seconds: number) => void; - onSetDisappearingMessages: (seconds: number) => void; + onSetDisappearingMessages: (seconds: DurationInSeconds) => void; onDeleteMessages: () => void; onSearchInConversation: () => void; onOutgoingAudioCallInConversation: () => void; @@ -406,8 +407,8 @@ export class ConversationHeader extends React.Component { const expireDurations: ReadonlyArray = [ ...expirationTimer.DEFAULT_DURATIONS_IN_SECONDS, - -1, - ].map((seconds: number) => { + DurationInSeconds.fromSeconds(-1), + ].map(seconds => { let text: string; if (seconds === -1) { diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index 79cab92dbea..c74599d8d22 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -24,6 +24,7 @@ import { SendStatus } from '../../messages/MessageSendState'; import { WidthBreakpoint } from '../_util'; import * as log from '../../logging/log'; import { formatDateTimeLong } from '../../util/timestamp'; +import { DurationInSeconds } from '../../util/durations'; import { format as formatRelativeTime } from '../../util/expirationTimer'; export type Contact = Pick< @@ -302,7 +303,7 @@ export class MessageDetail extends React.Component { } = this.props; const timeRemaining = expirationTimestamp - ? expirationTimestamp - Date.now() + ? DurationInSeconds.fromMillis(expirationTimestamp - Date.now()) : undefined; return ( @@ -422,7 +423,7 @@ export class MessageDetail extends React.Component { {i18n('MessageDetail--disappears-in')} - {formatRelativeTime(i18n, timeRemaining / 1000, { + {formatRelativeTime(i18n, timeRemaining, { largest: 2, })} diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 3a18ddc54ca..a80f3a0b092 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -2,13 +2,13 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; -import * as moment from 'moment'; import { times } from 'lodash'; import { v4 as uuid } from 'uuid'; import { text, boolean, number } from '@storybook/addon-knobs'; import { action } from '@storybook/addon-actions'; import { setupI18n } from '../../util/setupI18n'; +import { DurationInSeconds } from '../../util/durations'; import enMessages from '../../../_locales/en/messages.json'; import type { PropsType } from './Timeline'; import { Timeline } from './Timeline'; @@ -135,7 +135,7 @@ const items: Record = { type: 'timerNotification', data: { disabled: false, - expireTimer: moment.duration(2, 'hours').asSeconds(), + expireTimer: DurationInSeconds.fromHours(2), title: "It's Me", type: 'fromMe', }, @@ -145,7 +145,7 @@ const items: Record = { type: 'timerNotification', data: { disabled: false, - expireTimer: moment.duration(2, 'hours').asSeconds(), + expireTimer: DurationInSeconds.fromHours(2), title: '(202) 555-0000', type: 'fromOther', }, diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 1adc596a8ad..72bece8027a 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -7,6 +7,7 @@ import { action } from '@storybook/addon-actions'; import { EmojiPicker } from '../emoji/EmojiPicker'; import { setupI18n } from '../../util/setupI18n'; +import { DurationInSeconds } from '../../util/durations'; import enMessages from '../../../_locales/en/messages.json'; import type { PropsType as TimelineItemProps } from './TimelineItem'; import { TimelineItem } from './TimelineItem'; @@ -43,7 +44,10 @@ const renderContact = (conversationId: string) => ( ); const renderUniversalTimerNotification = () => ( - + ); const getDefaultProps = () => ({ @@ -138,7 +142,7 @@ export const Notification = (): JSX.Element => { type: 'timerNotification', data: { phoneNumber: '(202) 555-0000', - expireTimer: 60, + expireTimer: DurationInSeconds.MINUTE, ...getDefaultConversation(), type: 'fromOther', }, diff --git a/ts/components/conversation/TimerNotification.stories.tsx b/ts/components/conversation/TimerNotification.stories.tsx index 9596d661048..14f5050bd22 100644 --- a/ts/components/conversation/TimerNotification.stories.tsx +++ b/ts/components/conversation/TimerNotification.stories.tsx @@ -2,10 +2,10 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; -import * as moment from 'moment'; import { boolean, number, select, text } from '@storybook/addon-knobs'; import { setupI18n } from '../../util/setupI18n'; +import { DurationInSeconds } from '../../util/durations'; import enMessages from '../../../_locales/en/messages.json'; import type { Props } from './TimerNotification'; import { TimerNotification } from './TimerNotification'; @@ -34,16 +34,19 @@ const createProps = (overrideProps: Partial = {}): Props => ({ } : { disabled: false, - expireTimer: number( - 'expireTimer', - ('expireTimer' in overrideProps ? overrideProps.expireTimer : 0) || 0 + expireTimer: DurationInSeconds.fromMillis( + number( + 'expireTimer', + ('expireTimer' in overrideProps ? overrideProps.expireTimer : 0) || + 0 + ) ), }), }); export const SetByOther = (): JSX.Element => { const props = createProps({ - expireTimer: moment.duration(1, 'hour').asSeconds(), + expireTimer: DurationInSeconds.fromHours(1), type: 'fromOther', title: 'Mr. Fire', }); @@ -61,7 +64,7 @@ export const SetByOtherWithALongName = (): JSX.Element => { const longName = 'ðŸĶīðŸ§ĐðŸ“ī'.repeat(50); const props = createProps({ - expireTimer: moment.duration(1, 'hour').asSeconds(), + expireTimer: DurationInSeconds.fromHours(1), type: 'fromOther', title: longName, }); @@ -81,7 +84,7 @@ SetByOtherWithALongName.story = { export const SetByYou = (): JSX.Element => { const props = createProps({ - expireTimer: moment.duration(1, 'hour').asSeconds(), + expireTimer: DurationInSeconds.fromHours(1), type: 'fromMe', title: 'Mr. Fire', }); @@ -97,7 +100,7 @@ export const SetByYou = (): JSX.Element => { export const SetBySync = (): JSX.Element => { const props = createProps({ - expireTimer: moment.duration(1, 'hour').asSeconds(), + expireTimer: DurationInSeconds.fromHours(1), type: 'fromSync', title: 'Mr. Fire', }); diff --git a/ts/components/conversation/TimerNotification.tsx b/ts/components/conversation/TimerNotification.tsx index 7ee484391a6..fb44766cff7 100644 --- a/ts/components/conversation/TimerNotification.tsx +++ b/ts/components/conversation/TimerNotification.tsx @@ -9,6 +9,7 @@ import { SystemMessage } from './SystemMessage'; import { Intl } from '../Intl'; import type { LocalizerType } from '../../types/Util'; import * as expirationTimer from '../../util/expirationTimer'; +import type { DurationInSeconds } from '../../util/durations'; import * as log from '../../logging/log'; export type TimerNotificationType = @@ -27,7 +28,7 @@ export type PropsData = { | { disabled: true } | { disabled: false; - expireTimer: number; + expireTimer: DurationInSeconds; } ); diff --git a/ts/components/conversation/UniversalTimerNotification.stories.tsx b/ts/components/conversation/UniversalTimerNotification.stories.tsx index 2d44f6aad2b..558c25314fc 100644 --- a/ts/components/conversation/UniversalTimerNotification.stories.tsx +++ b/ts/components/conversation/UniversalTimerNotification.stories.tsx @@ -18,34 +18,34 @@ const i18n = setupI18n('en', enMessages); export const Seconds = (): JSX.Element => ( ); export const Minutes = (): JSX.Element => ( ); export const Hours = (): JSX.Element => ( ); export const Days = (): JSX.Element => ( ); export const Weeks = (): JSX.Element => ( ); diff --git a/ts/components/conversation/UniversalTimerNotification.tsx b/ts/components/conversation/UniversalTimerNotification.tsx index 70e1e23bb30..11507533050 100644 --- a/ts/components/conversation/UniversalTimerNotification.tsx +++ b/ts/components/conversation/UniversalTimerNotification.tsx @@ -6,10 +6,11 @@ import React from 'react'; import { SystemMessage } from './SystemMessage'; import type { LocalizerType } from '../../types/Util'; import * as expirationTimer from '../../util/expirationTimer'; +import type { DurationInSeconds } from '../../util/durations'; export type Props = { i18n: LocalizerType; - expireTimer: number; + expireTimer: DurationInSeconds; }; export const UniversalTimerNotification: React.FC = props => { diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index bc05c81dae2..9cc8e5a1454 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -16,6 +16,7 @@ import type { ConversationType } from '../../../state/ducks/conversations'; import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; import { makeFakeLookupConversationWithoutUuid } from '../../../test-both/helpers/fakeLookupConversationWithoutUuid'; import { ThemeType } from '../../../types/Util'; +import { DurationInSeconds } from '../../../util/durations'; const i18n = setupI18n('en', enMessages); @@ -35,7 +36,10 @@ const conversation: ConversationType = getDefaultConversation({ const allCandidateContacts = times(10, () => getDefaultConversation()); -const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({ +const createProps = ( + hasGroupLink = false, + expireTimer?: DurationInSeconds +): Props => ({ addMembers: async () => { action('addMembers'); }, @@ -194,7 +198,7 @@ export const GroupEditable = (): JSX.Element => { }; export const GroupEditableWithCustomDisappearingTimeout = (): JSX.Element => { - const props = createProps(false, 3 * 24 * 60 * 60); + const props = createProps(false, DurationInSeconds.fromDays(3)); return ; }; diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index e56c3ea62cb..53b6205e7f9 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -20,6 +20,7 @@ import type { LocalizerType, ThemeType } from '../../../types/Util'; import type { MediaItemType } from '../../../types/MediaItem'; import type { BadgeType } from '../../../badges/types'; import { missingCaseError } from '../../../util/missingCaseError'; +import { DurationInSeconds } from '../../../util/durations'; import { DisappearingTimerSelect } from '../../DisappearingTimerSelect'; @@ -79,7 +80,7 @@ export type StateProps = { memberships: Array; pendingApprovalMemberships: ReadonlyArray; pendingMemberships: ReadonlyArray; - setDisappearingMessages: (seconds: number) => void; + setDisappearingMessages: (seconds: DurationInSeconds) => void; showAllMedia: () => void; showChatColorEditor: () => void; showGroupLinkManagement: () => void; @@ -410,7 +411,7 @@ export const ConversationDetails: React.ComponentType = ({ right={ } diff --git a/ts/components/leftPane/LeftPaneHelper.tsx b/ts/components/leftPane/LeftPaneHelper.tsx index 5cfb9b03060..bc3639c6892 100644 --- a/ts/components/leftPane/LeftPaneHelper.tsx +++ b/ts/components/leftPane/LeftPaneHelper.tsx @@ -10,6 +10,7 @@ import type { ReplaceAvatarActionType, SaveAvatarToDiskActionType, } from '../../types/Avatar'; +import type { DurationInSeconds } from '../../util/durations'; import type { ShowConversationType } from '../../state/ducks/conversations'; export enum FindDirection { @@ -73,7 +74,7 @@ export abstract class LeftPaneHelper { i18n: LocalizerType; removeSelectedContact: (_: string) => unknown; setComposeGroupAvatar: (_: undefined | Uint8Array) => unknown; - setComposeGroupExpireTimer: (_: number) => void; + setComposeGroupExpireTimer: (_: DurationInSeconds) => void; setComposeGroupName: (_: string) => unknown; toggleComposeEditingAvatar: () => unknown; }> diff --git a/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx index 51db4c8ef47..d45d174a93b 100644 --- a/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx +++ b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx @@ -10,6 +10,7 @@ import { RowType } from '../ConversationList'; import type { ContactListItemConversationType } from '../conversationList/ContactListItem'; import { DisappearingTimerSelect } from '../DisappearingTimerSelect'; import type { LocalizerType } from '../../types/Util'; +import type { DurationInSeconds } from '../../util/durations'; import { Alert } from '../Alert'; import { AvatarEditor } from '../AvatarEditor'; import { AvatarPreview } from '../AvatarPreview'; @@ -28,7 +29,7 @@ import { AvatarColors } from '../../types/Colors'; export type LeftPaneSetGroupMetadataPropsType = { groupAvatar: undefined | Uint8Array; groupName: string; - groupExpireTimer: number; + groupExpireTimer: DurationInSeconds; hasError: boolean; isCreating: boolean; isEditingAvatar: boolean; @@ -41,7 +42,7 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper unknown; i18n: LocalizerType; setComposeGroupAvatar: (_: undefined | Uint8Array) => unknown; - setComposeGroupExpireTimer: (_: number) => void; + setComposeGroupExpireTimer: (_: DurationInSeconds) => void; setComposeGroupName: (_: string) => unknown; toggleComposeEditingAvatar: () => unknown; }>): ReactChild { diff --git a/ts/groups.ts b/ts/groups.ts index 4955b2d0e76..afc48a7c547 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -23,7 +23,7 @@ import dataInterface from './sql/Client'; import { toWebSafeBase64, fromWebSafeBase64 } from './util/webSafeBase64'; import { assertDev, strictAssert } from './util/assert'; import { isMoreRecentThan } from './util/timestamp'; -import * as durations from './util/durations'; +import { MINUTE, DurationInSeconds } from './util/durations'; import { normalizeUuid } from './util/normalizeUuid'; import { dropNull } from './util/dropNull'; import type { @@ -854,7 +854,7 @@ export function buildDisappearingMessagesTimerChange({ expireTimer, group, }: { - expireTimer: number; + expireTimer: DurationInSeconds; group: ConversationAttributesType; }): Proto.GroupChange.Actions { const actions = new Proto.GroupChange.Actions(); @@ -1458,7 +1458,7 @@ export async function modifyGroupV2({ } const startTime = Date.now(); - const timeoutTime = startTime + durations.MINUTE; + const timeoutTime = startTime + MINUTE; const MAX_ATTEMPTS = 5; @@ -1725,7 +1725,7 @@ export async function createGroupV2( options: Readonly<{ name: string; avatar: undefined | Uint8Array; - expireTimer: undefined | number; + expireTimer: undefined | DurationInSeconds; conversationIds: Array; avatars?: Array; refreshedCredentials?: boolean; @@ -2904,7 +2904,7 @@ type MaybeUpdatePropsType = Readonly<{ groupChange?: WrappedGroupChangeType; }>; -const FIVE_MINUTES = 5 * durations.MINUTE; +const FIVE_MINUTES = 5 * MINUTE; export async function waitThenMaybeUpdateGroup( options: MaybeUpdatePropsType, @@ -4625,7 +4625,7 @@ function extractDiffs({ Boolean(current.expireTimer) && old.expireTimer !== current.expireTimer) ) { - const expireTimer = current.expireTimer || 0; + const expireTimer = current.expireTimer || DurationInSeconds.ZERO; log.info( `extractDiffs/${logId}: generating change notifcation for new ${expireTimer} timer` ); @@ -4977,9 +4977,9 @@ async function applyGroupChange({ disappearingMessagesTimer && disappearingMessagesTimer.content === 'disappearingMessagesDuration' ) { - result.expireTimer = dropNull( - disappearingMessagesTimer.disappearingMessagesDuration - ); + const duration = disappearingMessagesTimer.disappearingMessagesDuration; + result.expireTimer = + duration == null ? undefined : DurationInSeconds.fromSeconds(duration); } else { log.warn( `applyGroupChange/${logId}: Clearing group expireTimer due to missing data.` @@ -5335,9 +5335,9 @@ async function applyGroupState({ disappearingMessagesTimer && disappearingMessagesTimer.content === 'disappearingMessagesDuration' ) { - result.expireTimer = dropNull( - disappearingMessagesTimer.disappearingMessagesDuration - ); + const duration = disappearingMessagesTimer.disappearingMessagesDuration; + result.expireTimer = + duration == null ? undefined : DurationInSeconds.fromSeconds(duration); } else { result.expireTimer = undefined; } diff --git a/ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts b/ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts index 783a165bff6..6163114f191 100644 --- a/ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts +++ b/ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts @@ -19,6 +19,7 @@ import type { import { handleMessageSend } from '../../util/handleMessageSend'; import { isConversationAccepted } from '../../util/isConversationAccepted'; import { isConversationUnregistered } from '../../util/isConversationUnregistered'; +import { DurationInSeconds } from '../../util/durations'; export async function sendDirectExpirationTimerUpdate( conversation: ConversationModel, @@ -77,7 +78,11 @@ export async function sendDirectExpirationTimerUpdate( const sendType = 'expirationTimerUpdate'; const flags = Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; const proto = await messaging.getContentMessage({ - expireTimer, + // `expireTimer` is already in seconds + expireTimer: + expireTimer === undefined + ? undefined + : DurationInSeconds.fromSeconds(expireTimer), flags, profileKey, recipients: conversation.getRecipients(), diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts index 8c75c2419fd..13c305b3fe4 100644 --- a/ts/jobs/helpers/sendNormalMessage.ts +++ b/ts/jobs/helpers/sendNormalMessage.ts @@ -36,6 +36,7 @@ import { ourProfileKeyService } from '../../services/ourProfileKey'; import { isConversationUnregistered } from '../../util/isConversationUnregistered'; import { isConversationAccepted } from '../../util/isConversationAccepted'; import { sendToGroup } from '../../util/sendToGroup'; +import type { DurationInSeconds } from '../../util/durations'; import type { UUIDStringType } from '../../types/UUID'; export async function sendNormalMessage( @@ -466,7 +467,7 @@ async function getMessageSendData({ body: undefined | string; contact?: Array; deletedForEveryoneTimestamp: undefined | number; - expireTimer: undefined | number; + expireTimer: undefined | DurationInSeconds; mentions: undefined | BodyRangesType; messageTimestamp: number; preview: Array; diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index d10b35f407c..3759b06c01a 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -32,6 +32,7 @@ import type { LinkPreviewType } from './types/message/LinkPreviews'; import type { StickerType } from './types/Stickers'; import type { StorySendMode } from './types/Stories'; import type { MIMEType } from './types/MIME'; +import type { DurationInSeconds } from './util/durations'; import AccessRequiredEnum = Proto.AccessControl.AccessRequired; import MemberRoleEnum = Proto.Member.Role; @@ -130,7 +131,7 @@ export type MessageAttributesType = { deletedForEveryoneTimestamp?: number; errors?: Array; expirationStartTimestamp?: number | null; - expireTimer?: number; + expireTimer?: DurationInSeconds; groupMigration?: GroupMigrationType; group_update?: GroupV1Update; hasAttachments?: boolean | 0 | 1; @@ -198,7 +199,7 @@ export type MessageAttributesType = { }; expirationTimerUpdate?: { - expireTimer: number; + expireTimer?: DurationInSeconds; fromSync?: unknown; source?: string; sourceUuid?: string; @@ -381,7 +382,7 @@ export type ConversationAttributesType = { } | null; avatars?: Array; description?: string; - expireTimer?: number; + expireTimer?: DurationInSeconds; membersV2?: Array; pendingMembersV2?: Array; pendingAdminApprovalV2?: Array; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 868df545b27..96766c0ba74 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -80,7 +80,7 @@ import { updateConversationsWithUuidLookup } from '../updateConversationsWithUui import { ReadStatus } from '../messages/MessageReadStatus'; import { SendStatus } from '../messages/MessageSendState'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; -import * as durations from '../util/durations'; +import { MINUTE, DurationInSeconds } from '../util/durations'; import { concat, filter, @@ -155,7 +155,7 @@ const { getNewerMessagesByConversation, } = window.Signal.Data; -const FIVE_MINUTES = durations.MINUTE * 5; +const FIVE_MINUTES = MINUTE * 5; const JOB_REPORTING_THRESHOLD_MS = 25; const SEND_REPORTING_THRESHOLD_MS = 25; @@ -471,7 +471,7 @@ export class ConversationModel extends window.Backbone } async updateExpirationTimerInGroupV2( - seconds?: number + seconds?: DurationInSeconds ): Promise { const idLog = this.idForLogging(); const current = this.get('expireTimer'); @@ -485,7 +485,7 @@ export class ConversationModel extends window.Backbone } return window.Signal.Groups.buildDisappearingMessagesTimerChange({ - expireTimer: seconds || 0, + expireTimer: seconds || DurationInSeconds.ZERO, group: this.attributes, }); } @@ -1382,7 +1382,7 @@ export class ConversationModel extends window.Backbone if (!this.newMessageQueue) { this.newMessageQueue = new PQueue({ concurrency: 1, - timeout: durations.MINUTE * 30, + timeout: MINUTE * 30, }); } @@ -3944,7 +3944,7 @@ export class ConversationModel extends window.Backbone ); let expirationStartTimestamp: number | undefined; - let expireTimer: number | undefined; + let expireTimer: DurationInSeconds | undefined; // If it's a group story reply then let's match the expiration timers // with the parent story's expiration. @@ -3952,7 +3952,7 @@ export class ConversationModel extends window.Backbone const parentStory = await getMessageById(storyId); expirationStartTimestamp = parentStory?.expirationStartTimestamp || Date.now(); - expireTimer = parentStory?.expireTimer || durations.DAY; + expireTimer = parentStory?.expireTimer || DurationInSeconds.DAY; } else { await this.maybeApplyUniversalTimer(); expireTimer = this.get('expireTimer'); @@ -4431,7 +4431,7 @@ export class ConversationModel extends window.Backbone } async updateExpirationTimer( - providedExpireTimer: number | undefined, + providedExpireTimer: DurationInSeconds | undefined, { reason, receivedAt, @@ -4479,7 +4479,7 @@ export class ConversationModel extends window.Backbone ); } - let expireTimer: number | undefined = providedExpireTimer; + let expireTimer: DurationInSeconds | undefined = providedExpireTimer; let source = providedSource; if (this.get('left')) { return false; diff --git a/ts/models/messages.ts b/ts/models/messages.ts index c6bf43aed7e..416c817348b 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -172,7 +172,7 @@ import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer'; import { GiftBadgeStates } from '../components/conversation/Message'; import { downloadAttachment } from '../util/downloadAttachment'; import type { StickerWithHydratedData } from '../types/Stickers'; -import { SECOND } from '../util/durations'; +import { DurationInSeconds } from '../util/durations'; import dataInterface from '../sql/Client'; function isSameUuid( @@ -566,7 +566,7 @@ export class MessageModel extends window.Backbone.Model { const expireTimer = this.get('expireTimer'); const expirationStartTimestamp = this.get('expirationStartTimestamp'); const expirationLength = isNumber(expireTimer) - ? expireTimer * SECOND + ? DurationInSeconds.toMillis(expireTimer) : undefined; const expirationTimestamp = expirationTimer.calculateExpirationTimestamp({ expireTimer, diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 45e348133ba..703b5ec4a80 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -39,6 +39,7 @@ import { } from '../util/universalExpireTimer'; import { ourProfileKeyService } from './ourProfileKey'; import { isGroupV1, isGroupV2 } from '../util/whatTypeOfConversation'; +import { DurationInSeconds } from '../util/durations'; import { isValidUuid, UUID, UUIDKind } from '../types/UUID'; import * as preferredReactionEmoji from '../reactions/preferredReactionEmoji'; import { SignalService as Proto } from '../protobuf'; @@ -1178,7 +1179,9 @@ export async function mergeAccountRecord( window.storage.put('preferredReactionEmoji', rawPreferredReactionEmoji); } - setUniversalExpireTimer(universalExpireTimer || 0); + setUniversalExpireTimer( + DurationInSeconds.fromSeconds(universalExpireTimer || 0) + ); const PHONE_NUMBER_SHARING_MODE_ENUM = Proto.AccountRecord.PhoneNumberSharingMode; diff --git a/ts/services/storyLoader.ts b/ts/services/storyLoader.ts index 93e5376c2db..887170d4a7d 100644 --- a/ts/services/storyLoader.ts +++ b/ts/services/storyLoader.ts @@ -15,6 +15,7 @@ import type { LinkPreviewType } from '../types/message/LinkPreviews'; import { isNotNil } from '../util/isNotNil'; import { strictAssert } from '../util/assert'; import { dropNull } from '../util/dropNull'; +import { DurationInSeconds } from '../util/durations'; import { isGroup } from '../util/whatTypeOfConversation'; import { SIGNAL_ACI } from '../types/SignalConversation'; @@ -142,7 +143,7 @@ export function getStoriesForRedux(): Array { async function repairUnexpiredStories(): Promise { strictAssert(storyData, 'Could not load stories'); - const DAY_AS_SECONDS = durations.DAY / 1000; + const DAY_AS_SECONDS = DurationInSeconds.fromDays(1); const storiesWithExpiry = storyData .filter( @@ -155,9 +156,11 @@ async function repairUnexpiredStories(): Promise { .map(story => ({ ...story, expirationStartTimestamp: Math.min(story.timestamp, Date.now()), - expireTimer: Math.min( - Math.floor((story.timestamp + durations.DAY - Date.now()) / 1000), - DAY_AS_SECONDS + expireTimer: DurationInSeconds.fromMillis( + Math.min( + Math.floor(story.timestamp + durations.DAY - Date.now()), + durations.DAY + ) ), })); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index c3e15ea677b..c66b346cba3 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -20,6 +20,7 @@ import * as log from '../../logging/log'; import { calling } from '../../services/calling'; import { getOwn } from '../../util/getOwn'; import { assertDev, strictAssert } from '../../util/assert'; +import type { DurationInSeconds } from '../../util/durations'; import * as universalExpireTimer from '../../util/universalExpireTimer'; import type { ShowSendAnywayDialogActionType, @@ -178,7 +179,7 @@ export type ConversationType = { accessControlMembers?: number; announcementsOnly?: boolean; announcementsOnlyReady?: boolean; - expireTimer?: number; + expireTimer?: DurationInSeconds; memberships?: Array<{ uuid: UUIDStringType; isAdmin: boolean; @@ -293,7 +294,7 @@ export type PreJoinConversationType = { type ComposerGroupCreationState = { groupAvatar: undefined | Uint8Array; groupName: string; - groupExpireTimer: number; + groupExpireTimer: DurationInSeconds; maximumGroupSizeModalState: OneTimeModalState; recommendedGroupSizeModalState: OneTimeModalState; selectedConversationIds: Array; @@ -712,7 +713,7 @@ type SetComposeGroupNameActionType = { }; type SetComposeGroupExpireTimerActionType = { type: 'SET_COMPOSE_GROUP_EXPIRE_TIMER'; - payload: { groupExpireTimer: number }; + payload: { groupExpireTimer: DurationInSeconds }; }; type SetComposeSearchTermActionType = { type: 'SET_COMPOSE_SEARCH_TERM'; @@ -1907,7 +1908,7 @@ function setComposeGroupName(groupName: string): SetComposeGroupNameActionType { } function setComposeGroupExpireTimer( - groupExpireTimer: number + groupExpireTimer: DurationInSeconds ): SetComposeGroupExpireTimerActionType { return { type: 'SET_COMPOSE_GROUP_EXPIRE_TIMER', @@ -3509,7 +3510,7 @@ export function reducer( let maximumGroupSizeModalState: OneTimeModalState; let groupName: string; let groupAvatar: undefined | Uint8Array; - let groupExpireTimer: number; + let groupExpireTimer: DurationInSeconds; let userAvatarData = getDefaultAvatars(true); switch (state.composer?.step) { diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index 84840f044c6..48572878e3b 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -36,6 +36,7 @@ import { markViewed } from '../../services/MessageUpdater'; import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads'; import { replaceIndex } from '../../util/replaceIndex'; import { showToast } from '../../util/showToast'; +import type { DurationInSeconds } from '../../util/durations'; import { hasFailed, isDownloaded, isDownloading } from '../../types/Attachment'; import { getConversationSelector, @@ -79,7 +80,7 @@ export type StoryDataType = { | 'type' > & { // don't want the fields to be optional as in MessageAttributesType - expireTimer: number | undefined; + expireTimer: DurationInSeconds | undefined; expirationStartTimestamp: number | undefined; }; diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 0a7d224d8eb..97f72c284c2 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -38,6 +38,7 @@ import type { UUIDStringType } from '../../types/UUID'; import { isInSystemContacts } from '../../util/isInSystemContacts'; import { isSignalConnection } from '../../util/getSignalConnections'; import { sortByTitle } from '../../util/sortByTitle'; +import { DurationInSeconds } from '../../util/durations'; import { isDirectConversation, isGroupV1, @@ -634,7 +635,7 @@ const getGroupCreationComposerState = createSelector( ): { groupName: string; groupAvatar: undefined | Uint8Array; - groupExpireTimer: number; + groupExpireTimer: DurationInSeconds; selectedConversationIds: Array; } => { switch (composerState?.step) { @@ -649,7 +650,7 @@ const getGroupCreationComposerState = createSelector( return { groupName: '', groupAvatar: undefined, - groupExpireTimer: 0, + groupExpireTimer: DurationInSeconds.ZERO, selectedConversationIds: [], }; } @@ -668,7 +669,7 @@ export const getComposeGroupName = createSelector( export const getComposeGroupExpireTimer = createSelector( getGroupCreationComposerState, - (composerState): number => composerState.groupExpireTimer + (composerState): DurationInSeconds => composerState.groupExpireTimer ); export const getComposeSelectedContacts = createSelector( diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index 83536157072..6f6a0b84ceb 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -17,6 +17,7 @@ import type { UUIDStringType } from '../../types/UUID'; import { DEFAULT_CONVERSATION_COLOR } from '../../types/Colors'; import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji'; import { isBeta } from '../../util/version'; +import { DurationInSeconds } from '../../util/durations'; import { getUserNumber, getUserACI } from './user'; const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320; @@ -42,7 +43,8 @@ export const getPinnedConversationIds = createSelector( export const getUniversalExpireTimer = createSelector( getItems, - (state: ItemsStateType): number => state[UNIVERSAL_EXPIRE_TIMER_ITEM] || 0 + (state: ItemsStateType): DurationInSeconds => + DurationInSeconds.fromSeconds(state[UNIVERSAL_EXPIRE_TIMER_ITEM] || 0) ); const isRemoteConfigFlagEnabled = ( diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 514f8d7c94e..80031929646 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -93,7 +93,7 @@ import { } from '../../messages/MessageSendState'; import * as log from '../../logging/log'; import { getConversationColorAttributes } from '../../util/getConversationColorAttributes'; -import { DAY, HOUR, SECOND } from '../../util/durations'; +import { DAY, HOUR, DurationInSeconds } from '../../util/durations'; import { getStoryReplyText } from '../../util/getStoryReplyText'; import { isIncoming, isOutgoing, isStory } from '../../messages/helpers'; import { calculateExpirationTimestamp } from '../../util/expirationTimer'; @@ -628,7 +628,9 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)( }: GetPropsForMessageOptions ): ShallowPropsType => { const { expireTimer, expirationStartTimestamp, conversationId } = message; - const expirationLength = expireTimer ? expireTimer * SECOND : undefined; + const expirationLength = expireTimer + ? DurationInSeconds.toMillis(expireTimer) + : undefined; const conversation = getConversation(message, conversationSelector); const isGroup = conversation.type === 'group'; @@ -1107,10 +1109,26 @@ function getPropsForTimerNotification( const sourceId = sourceUuid || source; const formattedContact = conversationSelector(sourceId); + // Pacify typescript + type MaybeExpireTimerType = + | { disabled: true } + | { + disabled: false; + expireTimer: DurationInSeconds; + }; + + const maybeExpireTimer: MaybeExpireTimerType = disabled + ? { + disabled: true, + } + : { + disabled: false, + expireTimer, + }; + const basicProps = { ...formattedContact, - disabled, - expireTimer, + ...maybeExpireTimer, type: 'fromOther' as const, }; diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index 20892097a33..79656d681b4 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -24,6 +24,7 @@ import { getPreferredBadgeSelector, } from '../selectors/badges'; import { assertDev } from '../../util/assert'; +import type { DurationInSeconds } from '../../util/durations'; import { SignalService as Proto } from '../../protobuf'; import { getConversationColorAttributes } from '../../util/getConversationColorAttributes'; import type { SmartChooseGroupMembersModalPropsType } from './ChooseGroupMembersModal'; @@ -39,7 +40,7 @@ export type SmartConversationDetailsProps = { addMembers: (conversationIds: ReadonlyArray) => Promise; conversationId: string; loadRecentMediaItems: (limit: number) => void; - setDisappearingMessages: (seconds: number) => void; + setDisappearingMessages: (seconds: DurationInSeconds) => void; showAllMedia: () => void; showChatColorEditor: () => void; showGroupLinkManagement: () => void; diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index 5e25227fa4a..10e1afbd7d9 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -25,6 +25,7 @@ import { mapDispatchToProps } from '../actions'; import { missingCaseError } from '../../util/missingCaseError'; import { strictAssert } from '../../util/assert'; import { isSignalConversation } from '../../util/isSignalConversation'; +import type { DurationInSeconds } from '../../util/durations'; export type OwnProps = { id: string; @@ -37,7 +38,7 @@ export type OwnProps = { onOutgoingAudioCallInConversation: () => void; onOutgoingVideoCallInConversation: () => void; onSearchInConversation: () => void; - onSetDisappearingMessages: (seconds: number) => void; + onSetDisappearingMessages: (seconds: DurationInSeconds) => void; onSetMuteNotifications: (seconds: number) => void; onSetPin: (value: boolean) => void; onShowAllMedia: () => void; diff --git a/ts/test-both/helpers/defaultComposerStates.ts b/ts/test-both/helpers/defaultComposerStates.ts index 7fa639ac691..581cfbf97da 100644 --- a/ts/test-both/helpers/defaultComposerStates.ts +++ b/ts/test-both/helpers/defaultComposerStates.ts @@ -3,6 +3,7 @@ import { ComposerStep } from '../../state/ducks/conversationsEnums'; import { OneTimeModalState } from '../../groups/toggleSelectedContactForGroupAddition'; +import { DurationInSeconds } from '../../util/durations'; export const defaultStartDirectConversationComposerState = { step: ComposerStep.StartDirectConversation as const, @@ -16,7 +17,7 @@ export const defaultChooseGroupMembersComposerState = { uuidFetchState: {}, groupAvatar: undefined, groupName: '', - groupExpireTimer: 0, + groupExpireTimer: DurationInSeconds.ZERO, maximumGroupSizeModalState: OneTimeModalState.NeverShown, recommendedGroupSizeModalState: OneTimeModalState.NeverShown, selectedConversationIds: [], @@ -28,7 +29,7 @@ export const defaultSetGroupMetadataComposerState = { isEditingAvatar: false, groupAvatar: undefined, groupName: '', - groupExpireTimer: 0, + groupExpireTimer: DurationInSeconds.ZERO, maximumGroupSizeModalState: OneTimeModalState.NeverShown, recommendedGroupSizeModalState: OneTimeModalState.NeverShown, selectedConversationIds: [], diff --git a/ts/test-both/util/expirationTimer_test.ts b/ts/test-both/util/expirationTimer_test.ts index 6ee61c6e1f4..c1130e4e93f 100644 --- a/ts/test-both/util/expirationTimer_test.ts +++ b/ts/test-both/util/expirationTimer_test.ts @@ -4,6 +4,7 @@ import { assert } from 'chai'; import * as moment from 'moment'; import { setupI18n } from '../../util/setupI18n'; +import { DurationInSeconds } from '../../util/durations'; import enMessages from '../../../_locales/en/messages.json'; import esMessages from '../../../_locales/es/messages.json'; import nbMessages from '../../../_locales/nb/messages.json'; @@ -24,7 +25,7 @@ describe('expiration timer utilities', () => { }); it('includes 1 hour as seconds', () => { - const oneHour = moment.duration(1, 'hour').asSeconds(); + const oneHour = DurationInSeconds.fromHours(1); assert.include(DEFAULT_DURATIONS_IN_SECONDS, oneHour); }); }); @@ -37,7 +38,7 @@ describe('expiration timer utilities', () => { }); it('handles no duration', () => { - assert.strictEqual(format(i18n, 0), 'off'); + assert.strictEqual(format(i18n, DurationInSeconds.ZERO), 'off'); }); it('formats durations', () => { @@ -59,22 +60,31 @@ describe('expiration timer utilities', () => { [moment.duration(3, 'w').asSeconds(), '3 weeks'], [moment.duration(52, 'w').asSeconds(), '52 weeks'], ]).forEach((expected, input) => { - assert.strictEqual(format(i18n, input), expected); + assert.strictEqual( + format(i18n, DurationInSeconds.fromSeconds(input)), + expected + ); }); }); it('formats other languages successfully', () => { const esI18n = setupI18n('es', esMessages); - assert.strictEqual(format(esI18n, 120), '2 minutos'); + assert.strictEqual( + format(esI18n, DurationInSeconds.fromSeconds(120)), + '2 minutos' + ); const zhCnI18n = setupI18n('zh-CN', zhCnMessages); - assert.strictEqual(format(zhCnI18n, 60), '1 分钟'); + assert.strictEqual( + format(zhCnI18n, DurationInSeconds.fromSeconds(60)), + '1 分钟' + ); // The underlying library supports the "pt" locale, not the "pt_BR" locale. That's // what we're testing here. const ptBrI18n = setupI18n('pt_BR', ptBrMessages); assert.strictEqual( - format(ptBrI18n, moment.duration(5, 'days').asSeconds()), + format(ptBrI18n, DurationInSeconds.fromDays(5)), '5 dias' ); @@ -83,7 +93,7 @@ describe('expiration timer utilities', () => { [setupI18n('nb', nbMessages), setupI18n('nn', nlMessages)].forEach( norwegianI18n => { assert.strictEqual( - format(norwegianI18n, moment.duration(6, 'hours').asSeconds()), + format(norwegianI18n, DurationInSeconds.fromHours(6)), '6 timer' ); } @@ -92,41 +102,76 @@ describe('expiration timer utilities', () => { it('falls back to English if the locale is not supported', () => { const badI18n = setupI18n('bogus', {}); - assert.strictEqual(format(badI18n, 120), '2 minutes'); + assert.strictEqual( + format(badI18n, DurationInSeconds.fromSeconds(120)), + '2 minutes' + ); }); it('handles a "mix" of units gracefully', () => { // We don't expect there to be a "mix" of units, but we shouldn't choke if a bad // client gives us an unexpected timestamp. - const mix = moment - .duration(6, 'days') - .add(moment.duration(2, 'hours')) - .asSeconds(); + const mix = DurationInSeconds.fromSeconds( + moment.duration(6, 'days').add(moment.duration(2, 'hours')).asSeconds() + ); assert.strictEqual(format(i18n, mix), '6 days, 2 hours'); }); it('handles negative numbers gracefully', () => { // The proto helps enforce non-negative numbers by specifying a u32, but because // JavaScript lacks such a type, we test it here. - assert.strictEqual(format(i18n, -1), '1 second'); - assert.strictEqual(format(i18n, -120), '2 minutes'); - assert.strictEqual(format(i18n, -0), 'off'); + assert.strictEqual( + format(i18n, DurationInSeconds.fromSeconds(-1)), + '1 second' + ); + assert.strictEqual( + format(i18n, DurationInSeconds.fromSeconds(-120)), + '2 minutes' + ); + assert.strictEqual( + format(i18n, DurationInSeconds.fromSeconds(-0)), + 'off' + ); }); it('handles fractional seconds gracefully', () => { // The proto helps enforce integer numbers by specifying a u32, but this function // shouldn't choke if bad data is passed somehow. - assert.strictEqual(format(i18n, 4.2), '4 seconds'); - assert.strictEqual(format(i18n, 4.8), '4 seconds'); - assert.strictEqual(format(i18n, 0.2), '1 second'); - assert.strictEqual(format(i18n, 0.8), '1 second'); + assert.strictEqual( + format(i18n, DurationInSeconds.fromSeconds(4.2)), + '4 seconds' + ); + assert.strictEqual( + format(i18n, DurationInSeconds.fromSeconds(4.8)), + '4 seconds' + ); + assert.strictEqual( + format(i18n, DurationInSeconds.fromSeconds(0.2)), + '1 second' + ); + assert.strictEqual( + format(i18n, DurationInSeconds.fromSeconds(0.8)), + '1 second' + ); // If multiple things go wrong and we pass a fractional negative number, we still // shouldn't explode. - assert.strictEqual(format(i18n, -4.2), '4 seconds'); - assert.strictEqual(format(i18n, -4.8), '4 seconds'); - assert.strictEqual(format(i18n, -0.2), '1 second'); - assert.strictEqual(format(i18n, -0.8), '1 second'); + assert.strictEqual( + format(i18n, DurationInSeconds.fromSeconds(-4.2)), + '4 seconds' + ); + assert.strictEqual( + format(i18n, DurationInSeconds.fromSeconds(-4.8)), + '4 seconds' + ); + assert.strictEqual( + format(i18n, DurationInSeconds.fromSeconds(-0.2)), + '1 second' + ); + assert.strictEqual( + format(i18n, DurationInSeconds.fromSeconds(-0.8)), + '1 second' + ); }); }); }); diff --git a/ts/test-both/util/expireTimers.ts b/ts/test-both/util/expireTimers.ts index f96ebbef6e4..593dfb12cc2 100644 --- a/ts/test-both/util/expireTimers.ts +++ b/ts/test-both/util/expireTimers.ts @@ -2,8 +2,12 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as durations from '../../util/durations'; +import { DurationInSeconds } from '../../util/durations'; -export type TestExpireTimer = Readonly<{ value: number; label: string }>; +export type TestExpireTimer = Readonly<{ + value: DurationInSeconds; + label: string; +}>; export const EXPIRE_TIMERS: ReadonlyArray = [ { value: 42 * durations.SECOND, label: '42 seconds' }, @@ -11,4 +15,9 @@ export const EXPIRE_TIMERS: ReadonlyArray = [ { value: 1 * durations.HOUR, label: '1 hour' }, { value: 6 * durations.DAY, label: '6 days' }, { value: 3 * durations.WEEK, label: '3 weeks' }, -]; +].map(({ value, label }) => { + return { + value: DurationInSeconds.fromMillis(value), + label, + }; +}); diff --git a/ts/test-electron/sql/conversationSummary_test.ts b/ts/test-electron/sql/conversationSummary_test.ts index 5e43915e2a2..1957e844562 100644 --- a/ts/test-electron/sql/conversationSummary_test.ts +++ b/ts/test-electron/sql/conversationSummary_test.ts @@ -6,6 +6,7 @@ import { assert } from 'chai'; import dataInterface from '../../sql/Client'; import { UUID } from '../../types/UUID'; import type { UUIDStringType } from '../../types/UUID'; +import { DurationInSeconds } from '../../util/durations'; import type { MessageAttributesType } from '../../model-types.d'; @@ -342,7 +343,7 @@ describe('sql/conversationSummary', () => { type: 'outgoing', conversationId, expirationTimerUpdate: { - expireTimer: 10, + expireTimer: DurationInSeconds.fromSeconds(10), source: 'you', }, sent_at: now + 1, @@ -355,7 +356,7 @@ describe('sql/conversationSummary', () => { type: 'outgoing', conversationId, expirationTimerUpdate: { - expireTimer: 10, + expireTimer: DurationInSeconds.fromSeconds(10), fromSync: true, }, sent_at: now + 2, @@ -391,7 +392,7 @@ describe('sql/conversationSummary', () => { type: 'outgoing', conversationId, expirationTimerUpdate: { - expireTimer: 10, + expireTimer: DurationInSeconds.fromSeconds(10), source: 'you', fromSync: false, }, @@ -405,7 +406,7 @@ describe('sql/conversationSummary', () => { type: 'outgoing', conversationId, expirationTimerUpdate: { - expireTimer: 10, + expireTimer: DurationInSeconds.fromSeconds(10), fromSync: true, }, sent_at: now + 2, @@ -450,7 +451,7 @@ describe('sql/conversationSummary', () => { type: 'outgoing', conversationId, expirationStartTimestamp: now - 2 * 1000, - expireTimer: 1, + expireTimer: DurationInSeconds.fromSeconds(1), sent_at: now + 2, received_at: now + 2, timestamp: now + 2, @@ -484,7 +485,7 @@ describe('sql/conversationSummary', () => { type: 'outgoing', conversationId, expirationStartTimestamp: now, - expireTimer: 30, + expireTimer: DurationInSeconds.fromSeconds(30), sent_at: now + 1, received_at: now + 1, timestamp: now + 1, @@ -495,7 +496,7 @@ describe('sql/conversationSummary', () => { type: 'outgoing', conversationId, expirationStartTimestamp: now - 2 * 1000, - expireTimer: 1, + expireTimer: DurationInSeconds.fromSeconds(1), sent_at: now + 2, received_at: now + 2, timestamp: now + 2, diff --git a/ts/test-electron/sql/markRead_test.ts b/ts/test-electron/sql/markRead_test.ts index 405e2400ab7..6de92086b6b 100644 --- a/ts/test-electron/sql/markRead_test.ts +++ b/ts/test-electron/sql/markRead_test.ts @@ -8,6 +8,7 @@ import { UUID } from '../../types/UUID'; import type { UUIDStringType } from '../../types/UUID'; import type { ReactionType } from '../../types/Reactions'; +import { DurationInSeconds } from '../../util/durations'; import type { MessageAttributesType } from '../../model-types.d'; import { ReadStatus } from '../../messages/MessageReadStatus'; @@ -331,7 +332,7 @@ describe('sql/markRead', () => { const start = Date.now(); const readAt = start + 20; const conversationId = getUuid(); - const expireTimer = 15; + const expireTimer = DurationInSeconds.fromSeconds(15); const ourUuid = getUuid(); const message1: MessageAttributesType = { diff --git a/ts/test-electron/state/ducks/stories_test.ts b/ts/test-electron/state/ducks/stories_test.ts index 98b6eaa70a4..1b5e0da24e6 100644 --- a/ts/test-electron/state/ducks/stories_test.ts +++ b/ts/test-electron/state/ducks/stories_test.ts @@ -12,7 +12,7 @@ import type { ConversationType } from '../../../state/ducks/conversations'; import type { MessageAttributesType } from '../../../model-types.d'; import type { StateType as RootStateType } from '../../../state/reducer'; import type { UUIDStringType } from '../../../types/UUID'; -import { DAY } from '../../../util/durations'; +import { DurationInSeconds } from '../../../util/durations'; import { TEXT_ATTACHMENT, IMAGE_JPEG } from '../../../types/MIME'; import { ReadStatus } from '../../../messages/MessageReadStatus'; import { @@ -74,7 +74,7 @@ describe('both/state/ducks/stories', () => { return { conversationId, expirationStartTimestamp: now, - expireTimer: 1 * DAY, + expireTimer: DurationInSeconds.DAY, messageId, readStatus: ReadStatus.Unread, timestamp: now - timestampDelta, @@ -538,7 +538,7 @@ describe('both/state/ducks/stories', () => { ? ourConversationId : groupConversationId, expirationStartTimestamp: now, - expireTimer: 1 * DAY, + expireTimer: DurationInSeconds.DAY, messageId, readStatus: ReadStatus.Unread, sendStateByConversationId: {}, diff --git a/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts index 8b3f9d4ffc2..56ff19a5d7f 100644 --- a/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts +++ b/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts @@ -5,13 +5,14 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; import { RowType } from '../../../components/ConversationList'; import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; +import { DurationInSeconds } from '../../../util/durations'; import { LeftPaneSetGroupMetadataHelper } from '../../../components/leftPane/LeftPaneSetGroupMetadataHelper'; function getComposeState() { return { groupAvatar: undefined, - groupExpireTimer: 0, + groupExpireTimer: DurationInSeconds.ZERO, groupName: '', hasError: false, isCreating: false, diff --git a/ts/textsecure/ContactsParser.ts b/ts/textsecure/ContactsParser.ts index ddb47bf84e4..d03485efd2a 100644 --- a/ts/textsecure/ContactsParser.ts +++ b/ts/textsecure/ContactsParser.ts @@ -7,23 +7,27 @@ import protobuf from '../protobuf/wrap'; import { SignalService as Proto } from '../protobuf'; import { normalizeUuid } from '../util/normalizeUuid'; +import { DurationInSeconds } from '../util/durations'; import * as log from '../logging/log'; import Avatar = Proto.ContactDetails.IAvatar; const { Reader } = protobuf; -type OptionalAvatar = { avatar?: Avatar | null }; +type OptionalFields = { avatar?: Avatar | null; expireTimer?: number | null }; -type DecoderBase = { +type DecoderBase = { decodeDelimited(reader: protobuf.Reader): Message | undefined; }; -export type MessageWithAvatar = Omit< +type HydratedAvatar = Avatar & { data: Uint8Array }; + +type MessageWithAvatar = Omit< Message, 'avatar' > & { - avatar?: (Avatar & { data: Uint8Array }) | null; + avatar?: HydratedAvatar; + expireTimer?: DurationInSeconds; }; export type ModifiedGroupDetails = MessageWithAvatar; @@ -32,7 +36,7 @@ export type ModifiedContactDetails = MessageWithAvatar; /* eslint-disable @typescript-eslint/brace-style -- Prettier conflicts with ESLint */ abstract class ParserBase< - Message extends OptionalAvatar, + Message extends OptionalFields, Decoder extends DecoderBase, Result > implements Iterable @@ -57,28 +61,33 @@ abstract class ParserBase< return undefined; } - if (!proto.avatar) { - return { - ...proto, - avatar: null, + let avatar: HydratedAvatar | undefined; + if (proto.avatar) { + const attachmentLen = proto.avatar.length ?? 0; + const avatarData = this.reader.buf.slice( + this.reader.pos, + this.reader.pos + attachmentLen + ); + this.reader.skip(attachmentLen); + + avatar = { + ...proto.avatar, + + data: avatarData, }; } - const attachmentLen = proto.avatar.length ?? 0; - const avatarData = this.reader.buf.slice( - this.reader.pos, - this.reader.pos + attachmentLen - ); - this.reader.skip(attachmentLen); + let expireTimer: DurationInSeconds | undefined; + + if (proto.expireTimer != null) { + expireTimer = DurationInSeconds.fromSeconds(proto.expireTimer); + } return { ...proto, - avatar: { - ...proto.avatar, - - data: avatarData, - }, + avatar, + expireTimer, }; } catch (error) { log.error( @@ -118,6 +127,7 @@ export class GroupBuffer extends ParserBase< if (!proto.members) { return proto; } + return { ...proto, members: proto.members.map((member, i) => { diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index b9cdcfeca13..01b80c2dbc3 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -45,6 +45,7 @@ import { normalizeUuid } from '../util/normalizeUuid'; import { parseIntOrThrow } from '../util/parseIntOrThrow'; import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; import { Zone } from '../util/Zone'; +import { DurationInSeconds } from '../util/durations'; import { deriveMasterKeyFromGroupV1, bytesToUuid } from '../Crypto'; import type { DownloadedAttachmentType } from '../types/Attachment'; import { Address } from '../types/Address'; @@ -2058,7 +2059,7 @@ export default class MessageReceiver attachments, preview, canReplyToStory: Boolean(msg.allowsReplies), - expireTimer: durations.DAY / 1000, + expireTimer: DurationInSeconds.DAY, flags: 0, groupV2, isStory: true, diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index d4fd566e9aa..b4ed0306d50 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -65,6 +65,7 @@ import { concat, isEmpty, map } from '../util/iterables'; import type { SendTypesType } from '../util/handleMessageSend'; import { shouldSaveProto, sendTypesEnum } from '../util/handleMessageSend'; import { uuidToBytes } from '../util/uuidToBytes'; +import type { DurationInSeconds } from '../util/durations'; import { SignalService as Proto } from '../protobuf'; import * as log from '../logging/log'; import type { Avatar, EmbeddedContactType } from '../types/EmbeddedContact'; @@ -174,7 +175,7 @@ export type MessageOptionsType = { attachments?: ReadonlyArray | null; body?: string; contact?: Array; - expireTimer?: number; + expireTimer?: DurationInSeconds; flags?: number; group?: { id: string; @@ -198,7 +199,7 @@ export type GroupSendOptionsType = { attachments?: Array; contact?: Array; deletedForEveryoneTimestamp?: number; - expireTimer?: number; + expireTimer?: DurationInSeconds; flags?: number; groupCallUpdate?: GroupCallUpdateType; groupV1?: GroupV1InfoType; @@ -221,7 +222,7 @@ class Message { contact?: Array; - expireTimer?: number; + expireTimer?: DurationInSeconds; flags?: number; @@ -1358,7 +1359,7 @@ export default class MessageSender { contact?: Array; contentHint: number; deletedForEveryoneTimestamp: number | undefined; - expireTimer: number | undefined; + expireTimer: DurationInSeconds | undefined; groupId: string | undefined; identifier: string; messageText: string | undefined; diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index c62171937c2..2f498f6b30f 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -7,6 +7,7 @@ import type { UUID, UUIDStringType } from '../types/UUID'; import type { TextAttachmentType } from '../types/Attachment'; import type { GiftBadgeStates } from '../components/conversation/Message'; import type { MIMEType } from '../types/MIME'; +import type { DurationInSeconds } from '../util/durations'; export { IdentityKeyType, @@ -207,7 +208,7 @@ export type ProcessedDataMessage = { group?: ProcessedGroupContext; groupV2?: ProcessedGroupV2Context; flags: number; - expireTimer: number; + expireTimer: DurationInSeconds; profileKey?: string; timestamp: number; quote?: ProcessedQuote; diff --git a/ts/textsecure/processDataMessage.ts b/ts/textsecure/processDataMessage.ts index 673ae5c19f0..0ea1e3847fe 100644 --- a/ts/textsecure/processDataMessage.ts +++ b/ts/textsecure/processDataMessage.ts @@ -28,7 +28,7 @@ import type { import { WarnOnlyError } from './Errors'; import { GiftBadgeStates } from '../components/conversation/Message'; import { APPLICATION_OCTET_STREAM, stringToMIMEType } from '../types/MIME'; -import { SECOND } from '../util/durations'; +import { SECOND, DurationInSeconds } from '../util/durations'; const FLAGS = Proto.DataMessage.Flags; export const ATTACHMENT_MAX = 32; @@ -299,7 +299,7 @@ export function processDataMessage( group: processGroupContext(message.group), groupV2: processGroupV2Context(message.groupV2), flags: message.flags ?? 0, - expireTimer: message.expireTimer ?? 0, + expireTimer: DurationInSeconds.fromSeconds(message.expireTimer ?? 0), profileKey: message.profileKey && message.profileKey.length > 0 ? Bytes.toBase64(message.profileKey) diff --git a/ts/types/Message.ts b/ts/types/Message.ts index 153af072308..32b26a763f7 100644 --- a/ts/types/Message.ts +++ b/ts/types/Message.ts @@ -3,6 +3,7 @@ /* eslint-disable camelcase */ +import type { DurationInSeconds } from '../util/durations'; import type { AttachmentType } from './Attachment'; import type { EmbeddedContactType } from './EmbeddedContact'; import type { IndexableBoolean, IndexablePresence } from './IndexedDB'; @@ -30,7 +31,7 @@ export type IncomingMessage = Readonly< body?: string; decrypted_at?: number; errors?: Array; - expireTimer?: number; + expireTimer?: DurationInSeconds; messageTimer?: number; // deprecated isViewOnce?: number; flags?: number; @@ -54,7 +55,7 @@ export type OutgoingMessage = Readonly< // Optional body?: string; - expireTimer?: number; + expireTimer?: DurationInSeconds; messageTimer?: number; // deprecated isViewOnce?: number; synced: boolean; @@ -88,7 +89,7 @@ export type SharedMessageProperties = Readonly<{ export type ExpirationTimerUpdate = Partial< Readonly<{ expirationTimerUpdate: Readonly<{ - expireTimer: number; + expireTimer: DurationInSeconds; fromSync: boolean; source: string; // PhoneNumber }>; diff --git a/ts/util/createIPCEvents.tsx b/ts/util/createIPCEvents.tsx index 1d48772f3cd..5747c056dc3 100644 --- a/ts/util/createIPCEvents.tsx +++ b/ts/util/createIPCEvents.tsx @@ -34,6 +34,7 @@ import { PhoneNumberDiscoverability } from './phoneNumberDiscoverability'; import { PhoneNumberSharingMode } from './phoneNumberSharingMode'; import { assertDev } from './assert'; import * as durations from './durations'; +import type { DurationInSeconds } from './durations'; import { isPhoneNumberSharingEnabled } from './isPhoneNumberSharingEnabled'; import { parseE164FromSignalDotMeHash, @@ -66,7 +67,7 @@ export type IPCEventsValuesType = { spellCheck: boolean; systemTraySetting: SystemTraySetting; themeSetting: ThemeType; - universalExpireTimer: number; + universalExpireTimer: DurationInSeconds; zoomFactor: ZoomFactorType; storyViewReceiptsEnabled: boolean; diff --git a/ts/util/durations.ts b/ts/util/durations/constants.ts similarity index 100% rename from ts/util/durations.ts rename to ts/util/durations/constants.ts diff --git a/ts/util/durations/duration-in-seconds.ts b/ts/util/durations/duration-in-seconds.ts new file mode 100644 index 00000000000..64d851e4c08 --- /dev/null +++ b/ts/util/durations/duration-in-seconds.ts @@ -0,0 +1,39 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as Constants from './constants'; + +export type DurationInSeconds = number & { + // eslint-disable-next-line camelcase + __time_difference_in_seconds: never; +}; + +/* eslint-disable @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare */ +export namespace DurationInSeconds { + export const fromMillis = (ms: number): DurationInSeconds => + (ms / Constants.SECOND) as DurationInSeconds; + export const fromSeconds = (seconds: number): DurationInSeconds => + seconds as DurationInSeconds; + export const fromMinutes = (m: number): DurationInSeconds => + ((m * Constants.MINUTE) / Constants.SECOND) as DurationInSeconds; + export const fromHours = (h: number): DurationInSeconds => + ((h * Constants.HOUR) / Constants.SECOND) as DurationInSeconds; + export const fromDays = (d: number): DurationInSeconds => + ((d * Constants.DAY) / Constants.SECOND) as DurationInSeconds; + export const fromWeeks = (d: number): DurationInSeconds => + ((d * Constants.WEEK) / Constants.SECOND) as DurationInSeconds; + export const fromMonths = (d: number): DurationInSeconds => + ((d * Constants.MONTH) / Constants.SECOND) as DurationInSeconds; + + export const toSeconds = (d: DurationInSeconds): number => d; + export const toMillis = (d: DurationInSeconds): number => + d * Constants.SECOND; + export const toHours = (d: DurationInSeconds): number => + (d * Constants.SECOND) / Constants.HOUR; + + export const ZERO = DurationInSeconds.fromSeconds(0); + export const HOUR = DurationInSeconds.fromHours(1); + export const MINUTE = DurationInSeconds.fromMinutes(1); + export const DAY = DurationInSeconds.fromDays(1); +} +/* eslint-enable @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare */ diff --git a/ts/util/durations/index.ts b/ts/util/durations/index.ts new file mode 100644 index 00000000000..7730408e6c4 --- /dev/null +++ b/ts/util/durations/index.ts @@ -0,0 +1,5 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export * from './constants'; +export { DurationInSeconds } from './duration-in-seconds'; diff --git a/ts/util/expirationTimer.ts b/ts/util/expirationTimer.ts index 86cff128ff9..205682eeed6 100644 --- a/ts/util/expirationTimer.ts +++ b/ts/util/expirationTimer.ts @@ -1,26 +1,25 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import * as moment from 'moment'; import humanizeDuration from 'humanize-duration'; import type { Unit } from 'humanize-duration'; import { isNumber } from 'lodash'; import type { LocalizerType } from '../types/Util'; -import { SECOND } from './durations'; +import { SECOND, DurationInSeconds } from './durations'; const SECONDS_PER_WEEK = 604800; -export const DEFAULT_DURATIONS_IN_SECONDS: ReadonlyArray = [ - 0, - moment.duration(4, 'weeks').asSeconds(), - moment.duration(1, 'week').asSeconds(), - moment.duration(1, 'day').asSeconds(), - moment.duration(8, 'hours').asSeconds(), - moment.duration(1, 'hour').asSeconds(), - moment.duration(5, 'minutes').asSeconds(), - moment.duration(30, 'seconds').asSeconds(), +export const DEFAULT_DURATIONS_IN_SECONDS: ReadonlyArray = [ + DurationInSeconds.ZERO, + DurationInSeconds.fromWeeks(4), + DurationInSeconds.fromWeeks(1), + DurationInSeconds.fromDays(1), + DurationInSeconds.fromHours(8), + DurationInSeconds.fromHours(1), + DurationInSeconds.fromMinutes(5), + DurationInSeconds.fromSeconds(30), ]; -export const DEFAULT_DURATIONS_SET: ReadonlySet = new Set( +export const DEFAULT_DURATIONS_SET: ReadonlySet = new Set( DEFAULT_DURATIONS_IN_SECONDS ); @@ -31,7 +30,7 @@ export type FormatOptions = { export function format( i18n: LocalizerType, - dirtySeconds?: number, + dirtySeconds?: DurationInSeconds, { capitalizeOff = false, largest }: FormatOptions = {} ): string { let seconds = Math.abs(dirtySeconds || 0); @@ -66,7 +65,7 @@ export function format( const defaultUnits: Array = seconds % SECONDS_PER_WEEK === 0 ? ['w'] : ['d', 'h', 'm', 's']; - return humanizeDuration(seconds * 1000, { + return humanizeDuration(seconds * SECOND, { // if we have an explict `largest` specified, // allow it to pick from all the units units: largest ? allUnits : defaultUnits, @@ -82,10 +81,10 @@ export function calculateExpirationTimestamp({ expireTimer, expirationStartTimestamp, }: { - expireTimer: number | undefined; + expireTimer: DurationInSeconds | undefined; expirationStartTimestamp: number | undefined | null; }): number | undefined { return isNumber(expirationStartTimestamp) && isNumber(expireTimer) - ? expirationStartTimestamp + expireTimer * SECOND + ? expirationStartTimestamp + DurationInSeconds.toMillis(expireTimer) : undefined; } diff --git a/ts/util/markOnboardingStoryAsRead.ts b/ts/util/markOnboardingStoryAsRead.ts index 4d20e0c07f3..57a4dbcb126 100644 --- a/ts/util/markOnboardingStoryAsRead.ts +++ b/ts/util/markOnboardingStoryAsRead.ts @@ -1,9 +1,9 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { DAY } from './durations'; import { getMessageById } from '../messages/getMessageById'; import { isNotNil } from './isNotNil'; +import { DurationInSeconds } from './durations'; import { markViewed } from '../services/MessageUpdater'; import { storageServiceUploadJob } from '../services/storage'; @@ -29,7 +29,7 @@ export async function markOnboardingStoryAsRead(): Promise { } message.set({ - expireTimer: DAY, + expireTimer: DurationInSeconds.DAY, }); message.set(markViewed(message.attributes, storyReadDate)); diff --git a/ts/util/sendStoryMessage.ts b/ts/util/sendStoryMessage.ts index 378c1df0541..a934eec591a 100644 --- a/ts/util/sendStoryMessage.ts +++ b/ts/util/sendStoryMessage.ts @@ -7,7 +7,6 @@ import type { SendStateByConversationId } from '../messages/MessageSendState'; import type { UUIDStringType } from '../types/UUID'; import * as log from '../logging/log'; import dataInterface from '../sql/Client'; -import { DAY, SECOND } from './durations'; import { MY_STORY_ID, StorySendMode } from '../types/Stories'; import { getStoriesBlocked } from './stories'; import { ReadStatus } from '../messages/MessageReadStatus'; @@ -24,6 +23,7 @@ import { incrementMessageCounter } from './incrementMessageCounter'; import { isGroupV2 } from './whatTypeOfConversation'; import { isNotNil } from './isNotNil'; import { collect } from './iterables'; +import { DurationInSeconds } from './durations'; export async function sendStoryMessage( listIds: Array, @@ -158,7 +158,7 @@ export async function sendStoryMessage( return window.Signal.Migrations.upgradeMessageSchema({ attachments, conversationId: ourConversation.id, - expireTimer: DAY / SECOND, + expireTimer: DurationInSeconds.DAY, expirationStartTimestamp: Date.now(), id: UUID.generate().toString(), readStatus: ReadStatus.Read, @@ -262,7 +262,7 @@ export async function sendStoryMessage( attachments, canReplyToStory: true, conversationId: group.id, - expireTimer: DAY / SECOND, + expireTimer: DurationInSeconds.DAY, expirationStartTimestamp: Date.now(), id: UUID.generate().toString(), readStatus: ReadStatus.Read, diff --git a/ts/util/universalExpireTimer.ts b/ts/util/universalExpireTimer.ts index a0d535d9170..c9630a179ea 100644 --- a/ts/util/universalExpireTimer.ts +++ b/ts/util/universalExpireTimer.ts @@ -1,12 +1,14 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { DurationInSeconds } from './durations'; + export const ITEM_NAME = 'universalExpireTimer'; -export function get(): number { - return window.storage.get(ITEM_NAME) || 0; +export function get(): DurationInSeconds { + return DurationInSeconds.fromSeconds(window.storage.get(ITEM_NAME) || 0); } -export function set(newValue: number | undefined): Promise { - return window.storage.put(ITEM_NAME, newValue || 0); +export function set(newValue: DurationInSeconds | undefined): Promise { + return window.storage.put(ITEM_NAME, newValue || DurationInSeconds.ZERO); } diff --git a/ts/views/conversation_view.tsx b/ts/views/conversation_view.tsx index 425361eb231..a327b8cd670 100644 --- a/ts/views/conversation_view.tsx +++ b/ts/views/conversation_view.tsx @@ -35,6 +35,7 @@ import { isGroupV1, } from '../util/whatTypeOfConversation'; import { findAndFormatContact } from '../util/findAndFormatContact'; +import type { DurationInSeconds } from '../util/durations'; import { getPreferredBadgeSelector } from '../state/selectors/badges'; import { canReply, @@ -347,7 +348,7 @@ export class ConversationView extends window.Backbone.View { const conversationHeaderProps = { id: this.model.id, - onSetDisappearingMessages: (seconds: number) => + onSetDisappearingMessages: (seconds: DurationInSeconds) => this.setDisappearingMessages(seconds), onDeleteMessages: () => this.destroyMessages(), onSearchInConversation: () => { @@ -2260,7 +2261,7 @@ export class ConversationView extends window.Backbone.View { ); } - async setDisappearingMessages(seconds: number): Promise { + async setDisappearingMessages(seconds: DurationInSeconds): Promise { const { model }: { model: ConversationModel } = this; const valueToSet = seconds > 0 ? seconds : undefined; diff --git a/ts/windows/settings/preload.ts b/ts/windows/settings/preload.ts index 2326bd6351c..ad4ad7a3cf9 100644 --- a/ts/windows/settings/preload.ts +++ b/ts/windows/settings/preload.ts @@ -14,6 +14,7 @@ import { shouldMinimizeToSystemTray, } from '../../types/SystemTraySetting'; import { awaitObject } from '../../util/awaitObject'; +import { DurationInSeconds } from '../../util/durations'; import { createSetting, createCallback } from '../../util/preload'; import { startInteractionMode } from '../startInteractionMode'; @@ -215,6 +216,10 @@ const renderPreferences = async () => { const { hasMinimizeToAndStartInSystemTray, hasMinimizeToSystemTray } = getSystemTraySettingValues(systemTraySetting); + const onUniversalExpireTimerChange = reRender( + settingUniversalExpireTimer.setValue + ); + const props = { // Settings availableCameras, @@ -250,7 +255,7 @@ const renderPreferences = async () => { selectedMicrophone, selectedSpeaker, themeSetting, - universalExpireTimer, + universalExpireTimer: DurationInSeconds.fromSeconds(universalExpireTimer), whoCanFindMe, whoCanSeeMe, zoomFactor, @@ -347,9 +352,11 @@ const renderPreferences = async () => { onSelectedSpeakerChange: reRender(settingAudioOutput.setValue), onSpellCheckChange: reRender(settingSpellCheck.setValue), onThemeChange: reRender(settingTheme.setValue), - onUniversalExpireTimerChange: reRender( - settingUniversalExpireTimer.setValue - ), + onUniversalExpireTimerChange: (newValue: number): Promise => { + return onUniversalExpireTimerChange( + DurationInSeconds.fromSeconds(newValue) + ); + }, // Zoom factor change doesn't require immediate rerender since it will: // 1. Update the zoom factor in the main window