diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 8d52d0391..3f3b0bbec 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -4855,6 +4855,10 @@ "message": "Viewed by", "description": "In the message details screen, shown above contacts who have viewed this message" }, + "MessageDetail--disappears-in": { + "message": "Disappears in", + "description": "In the message details screen, shown as a label of how long it will be before the message disappears" + }, "ProfileEditor--about": { "message": "About", "description": "Default text for about field" @@ -5575,6 +5579,16 @@ "message": "File size $size$", "description": "File size description" }, + "StoryDetailsModal__disappears-in": { + "message": "Disappears in $countdown$", + "description": "File size description", + "placeholders": { + "countdown": { + "content": "$1", + "example": "2 weeks, 3 days" + } + } + }, "StoryDetailsModal__copy-timestamp": { "message": "Copy timestamp", "description": "Context menu item to help debugging" diff --git a/ts/components/StoryDetailsModal.tsx b/ts/components/StoryDetailsModal.tsx index ac5ed3634..7c7bd705b 100644 --- a/ts/components/StoryDetailsModal.tsx +++ b/ts/components/StoryDetailsModal.tsx @@ -17,6 +17,7 @@ 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'; export type PropsType = { getPreferredBadge: PreferredBadgeSelectorType; @@ -25,6 +26,7 @@ export type PropsType = { sender: StoryViewType['sender']; sendState?: Array; size?: number; + expirationTimestamp: number | undefined; timestamp: number; }; @@ -66,6 +68,7 @@ export const StoryDetailsModal = ({ sendState, size, timestamp, + expirationTimestamp, }: PropsType): JSX.Element => { const contactsBySendStatus = sendState ? groupBy(sendState, contact => contact.status) @@ -181,6 +184,10 @@ export const StoryDetailsModal = ({ ); } + const timeRemaining = expirationTimestamp + ? expirationTimestamp - Date.now() + : undefined; + return ( )} + {timeRemaining && timeRemaining > 0 && ( +
+ + {formatRelativeTime(i18n, timeRemaining / 1000, { + largest: 2, + })} + , + ]} + /> +
+ )} } > diff --git a/ts/components/StoryListItem.stories.tsx b/ts/components/StoryListItem.stories.tsx index dd90fc0b2..1e4df0fef 100644 --- a/ts/components/StoryListItem.stories.tsx +++ b/ts/components/StoryListItem.stories.tsx @@ -55,6 +55,7 @@ SomeonesStory.args = { messageId: '123', sender: getDefaultConversation(), timestamp: Date.now(), + expirationTimestamp: undefined, }, }; SomeonesStory.story = { diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index aef896565..0da21931e 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -748,6 +748,7 @@ export const StoryViewer = ({ sendState={sendState} size={attachment?.size} timestamp={timestamp} + expirationTimestamp={story.expirationTimestamp} /> )} {hasStoryViewsNRepliesModal && ( diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index 9d8113691..c7fbaceef 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 { format as formatRelativeTime } from '../../util/expirationTimer'; export type Contact = Pick< ConversationType, @@ -65,7 +66,13 @@ export type PropsData = { i18n: LocalizerType; theme: ThemeType; getPreferredBadge: PreferredBadgeSelectorType; -} & Pick; +} & Pick< + MessagePropsType, + | 'getPreferredBadge' + | 'interactionMode' + | 'expirationLength' + | 'expirationTimestamp' +>; export type PropsBackboneActions = Pick< MessagePropsType, @@ -280,6 +287,7 @@ export class MessageDetail extends React.Component { contactNameColor, displayTapToViewMessage, doubleCheckMissingQuoteReference, + expirationTimestamp, getPreferredBadge, i18n, interactionMode, @@ -307,6 +315,10 @@ export class MessageDetail extends React.Component { viewStory, } = this.props; + const timeRemaining = expirationTimestamp + ? expirationTimestamp - Date.now() + : undefined; + return ( // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
@@ -431,6 +443,18 @@ export class MessageDetail extends React.Component { ) : null} + {timeRemaining && timeRemaining > 0 && ( + + + {i18n('MessageDetail--disappears-in')} + + + {formatRelativeTime(i18n, timeRemaining / 1000, { + largest: 2, + })} + + + )} {this.renderContacts()} diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 48019a971..56b057be9 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -1,7 +1,16 @@ // Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { isEmpty, isEqual, mapValues, maxBy, noop, omit, union } from 'lodash'; +import { + isEmpty, + isEqual, + isNumber, + mapValues, + maxBy, + noop, + omit, + union, +} from 'lodash'; import type { CustomError, GroupV1Update, @@ -163,12 +172,19 @@ 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'; /* eslint-disable more/no-then */ type PropsForMessageDetail = Pick< SmartMessageDetailPropsType, - 'sentAt' | 'receivedAt' | 'message' | 'errors' | 'contacts' + | 'sentAt' + | 'receivedAt' + | 'message' + | 'errors' + | 'contacts' + | 'expirationLength' + | 'expirationTimestamp' >; declare const _: typeof window._; @@ -465,9 +481,21 @@ export class MessageModel extends window.Backbone.Model { }; }); + const expireTimer = this.get('expireTimer'); + const expirationStartTimestamp = this.get('expirationStartTimestamp'); + const expirationLength = isNumber(expireTimer) + ? expireTimer * SECOND + : undefined; + const expirationTimestamp = expirationTimer.calculateExpirationTimestamp({ + expireTimer, + expirationStartTimestamp, + }); + return { sentAt: this.get('sent_at'), receivedAt: this.getReceivedAt(), + expirationLength, + expirationTimestamp, message: getPropsForMessage(this.attributes, { conversationSelector: findAndFormatContact, ourConversationId, diff --git a/ts/services/storyLoader.ts b/ts/services/storyLoader.ts index 00056f485..297c9ec2b 100644 --- a/ts/services/storyLoader.ts +++ b/ts/services/storyLoader.ts @@ -10,6 +10,7 @@ import dataInterface from '../sql/Client'; import { getAttachmentsForMessage } from '../state/selectors/message'; import { isNotNil } from '../util/isNotNil'; import { strictAssert } from '../util/assert'; +import { dropNull } from '../util/dropNull'; let storyData: Array | undefined; @@ -51,6 +52,8 @@ export function getStoryDataFromMessageAttributes( 'timestamp', 'type', ]), + expireTimer: message.expireTimer, + expirationStartTimestamp: dropNull(message.expirationStartTimestamp), }; } diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index d3536719d..77645662d 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -71,7 +71,11 @@ export type StoryDataType = { | 'storyDistributionListId' | 'timestamp' | 'type' ->; +> & { + // don't want the fields to be optional as in MessageAttributesType + expireTimer: number | undefined; + expirationStartTimestamp: number | undefined; + }; export type SelectedStoryDataType = { currentIndex: number; @@ -1149,6 +1153,8 @@ export function reducer( 'canReplyToStory', 'conversationId', 'deletedForEveryone', + 'expirationStartTimestamp', + 'expireTimer', 'messageId', 'reactions', 'readStatus', diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index c32951f0e..1b8cfccc3 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -92,9 +92,10 @@ import { } from '../../messages/MessageSendState'; import * as log from '../../logging/log'; import { getConversationColorAttributes } from '../../util/getConversationColorAttributes'; -import { DAY, HOUR } from '../../util/durations'; +import { DAY, HOUR, SECOND } from '../../util/durations'; import { getStoryReplyText } from '../../util/getStoryReplyText'; import { isIncoming, isOutgoing, isStory } from '../../messages/helpers'; +import { calculateExpirationTimestamp } from '../../util/expirationTimer'; export { isIncoming, isOutgoing, isStory }; @@ -625,11 +626,7 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)( }: GetPropsForMessageOptions ): ShallowPropsType => { const { expireTimer, expirationStartTimestamp, conversationId } = message; - const expirationLength = expireTimer ? expireTimer * 1000 : undefined; - const expirationTimestamp = - expirationStartTimestamp && expirationLength - ? expirationStartTimestamp + expirationLength - : undefined; + const expirationLength = expireTimer ? expireTimer * SECOND : undefined; const conversation = getConversation(message, conversationSelector); const isGroup = conversation.type === 'group'; @@ -673,7 +670,10 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)( direction: isIncoming(message) ? 'incoming' : 'outgoing', displayLimit: message.displayLimit, expirationLength, - expirationTimestamp, + expirationTimestamp: calculateExpirationTimestamp({ + expireTimer, + expirationStartTimestamp, + }), giftBadge: message.giftBadge, id: message.id, isBlocked: conversation.isBlocked || false, diff --git a/ts/state/selectors/stories.ts b/ts/state/selectors/stories.ts index 6e0d15b34..5a18e8cb1 100644 --- a/ts/state/selectors/stories.ts +++ b/ts/state/selectors/stories.ts @@ -31,6 +31,7 @@ import { } from './conversations'; import { getDistributionListSelector } from './storyDistributionLists'; import { getStoriesEnabled } from './items'; +import { calculateExpirationTimestamp } from '../../util/expirationTimer'; export const getStoriesState = (state: StateType): StoriesStateType => state.stories; @@ -142,7 +143,10 @@ export function getStoryView( 'title', ]); - const { attachment, timestamp } = pick(story, ['attachment', 'timestamp']); + const { attachment, timestamp, expirationStartTimestamp, expireTimer } = pick( + story, + ['attachment', 'timestamp', 'expirationStartTimestamp', 'expireTimer'] + ); const { sendStateByConversationId } = story; let sendState: Array | undefined; @@ -179,6 +183,10 @@ export function getStoryView( sender, sendState, timestamp, + expirationTimestamp: calculateExpirationTimestamp({ + expireTimer, + expirationStartTimestamp, + }), views, }; } diff --git a/ts/test-both/helpers/getFakeStory.tsx b/ts/test-both/helpers/getFakeStory.tsx index b30b8f25d..aa16f6bba 100644 --- a/ts/test-both/helpers/getFakeStory.tsx +++ b/ts/test-both/helpers/getFakeStory.tsx @@ -52,6 +52,7 @@ export function getFakeStoryView( messageId: UUID.generate().toString(), sender, timestamp: timestamp || Date.now() - 2 * durations.MINUTE, + expirationTimestamp: undefined, }; } diff --git a/ts/test-electron/state/ducks/stories_test.ts b/ts/test-electron/state/ducks/stories_test.ts index d3a15852a..9d4832020 100644 --- a/ts/test-electron/state/ducks/stories_test.ts +++ b/ts/test-electron/state/ducks/stories_test.ts @@ -17,6 +17,7 @@ import { } from '../../../state/ducks/stories'; import { noopAction } from '../../../state/ducks/noop'; import { reducer as rootReducer } from '../../../state/reducer'; +import { dropNull } from '../../../util/dropNull'; describe('both/state/ducks/stories', () => { const getEmptyRootState = () => ({ @@ -119,6 +120,10 @@ describe('both/state/ducks/stories', () => { ...messageAttributes, attachment: messageAttributes.attachments[0], messageId: messageAttributes.id, + expireTimer: messageAttributes.expireTimer, + expirationStartTimestamp: dropNull( + messageAttributes.expirationStartTimestamp + ), }, ], }, @@ -150,6 +155,10 @@ describe('both/state/ducks/stories', () => { ...messageAttributes, messageId: storyId, attachment, + expireTimer: messageAttributes.expireTimer, + expirationStartTimestamp: dropNull( + messageAttributes.expirationStartTimestamp + ), }, ], }; @@ -191,6 +200,10 @@ describe('both/state/ducks/stories', () => { ...messageAttributes, attachment: messageAttributes.attachments[0], messageId: messageAttributes.id, + expireTimer: messageAttributes.expireTimer, + expirationStartTimestamp: dropNull( + messageAttributes.expirationStartTimestamp + ), }, ], }, diff --git a/ts/types/Stories.ts b/ts/types/Stories.ts index 0690d2122..f9acf652b 100644 --- a/ts/types/Stories.ts +++ b/ts/types/Stories.ts @@ -90,6 +90,7 @@ export type StoryViewType = { >; sendState?: Array; timestamp: number; + expirationTimestamp: number | undefined; views?: number; }; diff --git a/ts/util/expirationTimer.ts b/ts/util/expirationTimer.ts index c31975fc0..3a6f14bd5 100644 --- a/ts/util/expirationTimer.ts +++ b/ts/util/expirationTimer.ts @@ -3,7 +3,10 @@ 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'; const SECONDS_PER_WEEK = 604800; export const DEFAULT_DURATIONS_IN_SECONDS: ReadonlyArray = [ @@ -23,12 +26,13 @@ export const DEFAULT_DURATIONS_SET: ReadonlySet = new Set( export type FormatOptions = { capitalizeOff?: boolean; + largest?: number; // how many units to show (the largest n) }; export function format( i18n: LocalizerType, dirtySeconds?: number, - { capitalizeOff = false }: FormatOptions = {} + { capitalizeOff = false, largest }: FormatOptions = {} ): string { let seconds = Math.abs(dirtySeconds || 0); if (!seconds) { @@ -49,9 +53,31 @@ export function format( fallbacks.push('en'); } + const allUnits: Array = ['y', 'mo', 'w', 'd', 'h', 'm', 's']; + + const defaultUnits: Array = + seconds % SECONDS_PER_WEEK === 0 ? ['w'] : ['d', 'h', 'm', 's']; + return humanizeDuration(seconds * 1000, { - units: seconds % SECONDS_PER_WEEK === 0 ? ['w'] : ['d', 'h', 'm', 's'], + // if we have an explict `largest` specified, + // allow it to pick from all the units + units: largest ? allUnits : defaultUnits, + largest, language: locale, ...(fallbacks.length ? { fallbacks } : {}), }); } + +// normally we would not have undefineds all over, +// but most use-cases start out with undefineds +export function calculateExpirationTimestamp({ + expireTimer, + expirationStartTimestamp, +}: { + expireTimer: number | undefined; + expirationStartTimestamp: number | undefined | null; +}): number | undefined { + return isNumber(expirationStartTimestamp) && isNumber(expireTimer) + ? expirationStartTimestamp + expireTimer * SECOND + : undefined; +}