Added the time remaining for disappearing messages and stories

This commit is contained in:
Alvaro 2022-09-09 12:35:00 -06:00 committed by GitHub
parent 134265496b
commit 383a0fd17f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 162 additions and 14 deletions

View File

@ -4855,6 +4855,10 @@
"message": "Viewed by", "message": "Viewed by",
"description": "In the message details screen, shown above contacts who have viewed this message" "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": { "ProfileEditor--about": {
"message": "About", "message": "About",
"description": "Default text for about field" "description": "Default text for about field"
@ -5575,6 +5579,16 @@
"message": "File size $size$", "message": "File size $size$",
"description": "File size description" "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": { "StoryDetailsModal__copy-timestamp": {
"message": "Copy timestamp", "message": "Copy timestamp",
"description": "Context menu item to help debugging" "description": "Context menu item to help debugging"

View File

@ -17,6 +17,7 @@ import { ThemeType } from '../types/Util';
import { Time } from './Time'; import { Time } from './Time';
import { formatDateTimeLong } from '../util/timestamp'; import { formatDateTimeLong } from '../util/timestamp';
import { groupBy } from '../util/mapUtil'; import { groupBy } from '../util/mapUtil';
import { format as formatRelativeTime } from '../util/expirationTimer';
export type PropsType = { export type PropsType = {
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
@ -25,6 +26,7 @@ export type PropsType = {
sender: StoryViewType['sender']; sender: StoryViewType['sender'];
sendState?: Array<StorySendStateType>; sendState?: Array<StorySendStateType>;
size?: number; size?: number;
expirationTimestamp: number | undefined;
timestamp: number; timestamp: number;
}; };
@ -66,6 +68,7 @@ export const StoryDetailsModal = ({
sendState, sendState,
size, size,
timestamp, timestamp,
expirationTimestamp,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
const contactsBySendStatus = sendState const contactsBySendStatus = sendState
? groupBy(sendState, contact => contact.status) ? groupBy(sendState, contact => contact.status)
@ -181,6 +184,10 @@ export const StoryDetailsModal = ({
); );
} }
const timeRemaining = expirationTimestamp
? expirationTimestamp - Date.now()
: undefined;
return ( return (
<Modal <Modal
hasXButton hasXButton
@ -235,6 +242,21 @@ export const StoryDetailsModal = ({
/> />
</div> </div>
)} )}
{timeRemaining && timeRemaining > 0 && (
<div>
<Intl
i18n={i18n}
id="StoryDetailsModal__disappears-in"
components={[
<span className="StoryDetailsModal__debugger__button__text">
{formatRelativeTime(i18n, timeRemaining / 1000, {
largest: 2,
})}
</span>,
]}
/>
</div>
)}
</ContextMenu> </ContextMenu>
} }
> >

View File

@ -55,6 +55,7 @@ SomeonesStory.args = {
messageId: '123', messageId: '123',
sender: getDefaultConversation(), sender: getDefaultConversation(),
timestamp: Date.now(), timestamp: Date.now(),
expirationTimestamp: undefined,
}, },
}; };
SomeonesStory.story = { SomeonesStory.story = {

View File

@ -748,6 +748,7 @@ export const StoryViewer = ({
sendState={sendState} sendState={sendState}
size={attachment?.size} size={attachment?.size}
timestamp={timestamp} timestamp={timestamp}
expirationTimestamp={story.expirationTimestamp}
/> />
)} )}
{hasStoryViewsNRepliesModal && ( {hasStoryViewsNRepliesModal && (

View File

@ -24,6 +24,7 @@ import { SendStatus } from '../../messages/MessageSendState';
import { WidthBreakpoint } from '../_util'; import { WidthBreakpoint } from '../_util';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { formatDateTimeLong } from '../../util/timestamp'; import { formatDateTimeLong } from '../../util/timestamp';
import { format as formatRelativeTime } from '../../util/expirationTimer';
export type Contact = Pick< export type Contact = Pick<
ConversationType, ConversationType,
@ -65,7 +66,13 @@ export type PropsData = {
i18n: LocalizerType; i18n: LocalizerType;
theme: ThemeType; theme: ThemeType;
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
} & Pick<MessagePropsType, 'getPreferredBadge' | 'interactionMode'>; } & Pick<
MessagePropsType,
| 'getPreferredBadge'
| 'interactionMode'
| 'expirationLength'
| 'expirationTimestamp'
>;
export type PropsBackboneActions = Pick< export type PropsBackboneActions = Pick<
MessagePropsType, MessagePropsType,
@ -280,6 +287,7 @@ export class MessageDetail extends React.Component<Props> {
contactNameColor, contactNameColor,
displayTapToViewMessage, displayTapToViewMessage,
doubleCheckMissingQuoteReference, doubleCheckMissingQuoteReference,
expirationTimestamp,
getPreferredBadge, getPreferredBadge,
i18n, i18n,
interactionMode, interactionMode,
@ -307,6 +315,10 @@ export class MessageDetail extends React.Component<Props> {
viewStory, viewStory,
} = this.props; } = this.props;
const timeRemaining = expirationTimestamp
? expirationTimestamp - Date.now()
: undefined;
return ( return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
<div className="module-message-detail" tabIndex={0} ref={this.focusRef}> <div className="module-message-detail" tabIndex={0} ref={this.focusRef}>
@ -431,6 +443,18 @@ export class MessageDetail extends React.Component<Props> {
</td> </td>
</tr> </tr>
) : null} ) : null}
{timeRemaining && timeRemaining > 0 && (
<tr>
<td className="module-message-detail__label">
{i18n('MessageDetail--disappears-in')}
</td>
<td>
{formatRelativeTime(i18n, timeRemaining / 1000, {
largest: 2,
})}
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
{this.renderContacts()} {this.renderContacts()}

View File

@ -1,7 +1,16 @@
// Copyright 2020-2022 Signal Messenger, LLC // Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 { import type {
CustomError, CustomError,
GroupV1Update, GroupV1Update,
@ -163,12 +172,19 @@ import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer';
import { GiftBadgeStates } from '../components/conversation/Message'; import { GiftBadgeStates } from '../components/conversation/Message';
import { downloadAttachment } from '../util/downloadAttachment'; import { downloadAttachment } from '../util/downloadAttachment';
import type { StickerWithHydratedData } from '../types/Stickers'; import type { StickerWithHydratedData } from '../types/Stickers';
import { SECOND } from '../util/durations';
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
type PropsForMessageDetail = Pick< type PropsForMessageDetail = Pick<
SmartMessageDetailPropsType, SmartMessageDetailPropsType,
'sentAt' | 'receivedAt' | 'message' | 'errors' | 'contacts' | 'sentAt'
| 'receivedAt'
| 'message'
| 'errors'
| 'contacts'
| 'expirationLength'
| 'expirationTimestamp'
>; >;
declare const _: typeof window._; declare const _: typeof window._;
@ -465,9 +481,21 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}; };
}); });
const expireTimer = this.get('expireTimer');
const expirationStartTimestamp = this.get('expirationStartTimestamp');
const expirationLength = isNumber(expireTimer)
? expireTimer * SECOND
: undefined;
const expirationTimestamp = expirationTimer.calculateExpirationTimestamp({
expireTimer,
expirationStartTimestamp,
});
return { return {
sentAt: this.get('sent_at'), sentAt: this.get('sent_at'),
receivedAt: this.getReceivedAt(), receivedAt: this.getReceivedAt(),
expirationLength,
expirationTimestamp,
message: getPropsForMessage(this.attributes, { message: getPropsForMessage(this.attributes, {
conversationSelector: findAndFormatContact, conversationSelector: findAndFormatContact,
ourConversationId, ourConversationId,

View File

@ -10,6 +10,7 @@ import dataInterface from '../sql/Client';
import { getAttachmentsForMessage } from '../state/selectors/message'; import { getAttachmentsForMessage } from '../state/selectors/message';
import { isNotNil } from '../util/isNotNil'; import { isNotNil } from '../util/isNotNil';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { dropNull } from '../util/dropNull';
let storyData: Array<MessageAttributesType> | undefined; let storyData: Array<MessageAttributesType> | undefined;
@ -51,6 +52,8 @@ export function getStoryDataFromMessageAttributes(
'timestamp', 'timestamp',
'type', 'type',
]), ]),
expireTimer: message.expireTimer,
expirationStartTimestamp: dropNull(message.expirationStartTimestamp),
}; };
} }

View File

@ -71,7 +71,11 @@ export type StoryDataType = {
| 'storyDistributionListId' | 'storyDistributionListId'
| 'timestamp' | 'timestamp'
| 'type' | 'type'
>; > & {
// don't want the fields to be optional as in MessageAttributesType
expireTimer: number | undefined;
expirationStartTimestamp: number | undefined;
};
export type SelectedStoryDataType = { export type SelectedStoryDataType = {
currentIndex: number; currentIndex: number;
@ -1149,6 +1153,8 @@ export function reducer(
'canReplyToStory', 'canReplyToStory',
'conversationId', 'conversationId',
'deletedForEveryone', 'deletedForEveryone',
'expirationStartTimestamp',
'expireTimer',
'messageId', 'messageId',
'reactions', 'reactions',
'readStatus', 'readStatus',

View File

@ -92,9 +92,10 @@ import {
} from '../../messages/MessageSendState'; } from '../../messages/MessageSendState';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { getConversationColorAttributes } from '../../util/getConversationColorAttributes'; import { getConversationColorAttributes } from '../../util/getConversationColorAttributes';
import { DAY, HOUR } from '../../util/durations'; import { DAY, HOUR, SECOND } from '../../util/durations';
import { getStoryReplyText } from '../../util/getStoryReplyText'; import { getStoryReplyText } from '../../util/getStoryReplyText';
import { isIncoming, isOutgoing, isStory } from '../../messages/helpers'; import { isIncoming, isOutgoing, isStory } from '../../messages/helpers';
import { calculateExpirationTimestamp } from '../../util/expirationTimer';
export { isIncoming, isOutgoing, isStory }; export { isIncoming, isOutgoing, isStory };
@ -625,11 +626,7 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)(
}: GetPropsForMessageOptions }: GetPropsForMessageOptions
): ShallowPropsType => { ): ShallowPropsType => {
const { expireTimer, expirationStartTimestamp, conversationId } = message; const { expireTimer, expirationStartTimestamp, conversationId } = message;
const expirationLength = expireTimer ? expireTimer * 1000 : undefined; const expirationLength = expireTimer ? expireTimer * SECOND : undefined;
const expirationTimestamp =
expirationStartTimestamp && expirationLength
? expirationStartTimestamp + expirationLength
: undefined;
const conversation = getConversation(message, conversationSelector); const conversation = getConversation(message, conversationSelector);
const isGroup = conversation.type === 'group'; const isGroup = conversation.type === 'group';
@ -673,7 +670,10 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)(
direction: isIncoming(message) ? 'incoming' : 'outgoing', direction: isIncoming(message) ? 'incoming' : 'outgoing',
displayLimit: message.displayLimit, displayLimit: message.displayLimit,
expirationLength, expirationLength,
expirationTimestamp, expirationTimestamp: calculateExpirationTimestamp({
expireTimer,
expirationStartTimestamp,
}),
giftBadge: message.giftBadge, giftBadge: message.giftBadge,
id: message.id, id: message.id,
isBlocked: conversation.isBlocked || false, isBlocked: conversation.isBlocked || false,

View File

@ -31,6 +31,7 @@ import {
} from './conversations'; } from './conversations';
import { getDistributionListSelector } from './storyDistributionLists'; import { getDistributionListSelector } from './storyDistributionLists';
import { getStoriesEnabled } from './items'; import { getStoriesEnabled } from './items';
import { calculateExpirationTimestamp } from '../../util/expirationTimer';
export const getStoriesState = (state: StateType): StoriesStateType => export const getStoriesState = (state: StateType): StoriesStateType =>
state.stories; state.stories;
@ -142,7 +143,10 @@ export function getStoryView(
'title', 'title',
]); ]);
const { attachment, timestamp } = pick(story, ['attachment', 'timestamp']); const { attachment, timestamp, expirationStartTimestamp, expireTimer } = pick(
story,
['attachment', 'timestamp', 'expirationStartTimestamp', 'expireTimer']
);
const { sendStateByConversationId } = story; const { sendStateByConversationId } = story;
let sendState: Array<StorySendStateType> | undefined; let sendState: Array<StorySendStateType> | undefined;
@ -179,6 +183,10 @@ export function getStoryView(
sender, sender,
sendState, sendState,
timestamp, timestamp,
expirationTimestamp: calculateExpirationTimestamp({
expireTimer,
expirationStartTimestamp,
}),
views, views,
}; };
} }

View File

@ -52,6 +52,7 @@ export function getFakeStoryView(
messageId: UUID.generate().toString(), messageId: UUID.generate().toString(),
sender, sender,
timestamp: timestamp || Date.now() - 2 * durations.MINUTE, timestamp: timestamp || Date.now() - 2 * durations.MINUTE,
expirationTimestamp: undefined,
}; };
} }

View File

@ -17,6 +17,7 @@ import {
} from '../../../state/ducks/stories'; } from '../../../state/ducks/stories';
import { noopAction } from '../../../state/ducks/noop'; import { noopAction } from '../../../state/ducks/noop';
import { reducer as rootReducer } from '../../../state/reducer'; import { reducer as rootReducer } from '../../../state/reducer';
import { dropNull } from '../../../util/dropNull';
describe('both/state/ducks/stories', () => { describe('both/state/ducks/stories', () => {
const getEmptyRootState = () => ({ const getEmptyRootState = () => ({
@ -119,6 +120,10 @@ describe('both/state/ducks/stories', () => {
...messageAttributes, ...messageAttributes,
attachment: messageAttributes.attachments[0], attachment: messageAttributes.attachments[0],
messageId: messageAttributes.id, messageId: messageAttributes.id,
expireTimer: messageAttributes.expireTimer,
expirationStartTimestamp: dropNull(
messageAttributes.expirationStartTimestamp
),
}, },
], ],
}, },
@ -150,6 +155,10 @@ describe('both/state/ducks/stories', () => {
...messageAttributes, ...messageAttributes,
messageId: storyId, messageId: storyId,
attachment, attachment,
expireTimer: messageAttributes.expireTimer,
expirationStartTimestamp: dropNull(
messageAttributes.expirationStartTimestamp
),
}, },
], ],
}; };
@ -191,6 +200,10 @@ describe('both/state/ducks/stories', () => {
...messageAttributes, ...messageAttributes,
attachment: messageAttributes.attachments[0], attachment: messageAttributes.attachments[0],
messageId: messageAttributes.id, messageId: messageAttributes.id,
expireTimer: messageAttributes.expireTimer,
expirationStartTimestamp: dropNull(
messageAttributes.expirationStartTimestamp
),
}, },
], ],
}, },

View File

@ -90,6 +90,7 @@ export type StoryViewType = {
>; >;
sendState?: Array<StorySendStateType>; sendState?: Array<StorySendStateType>;
timestamp: number; timestamp: number;
expirationTimestamp: number | undefined;
views?: number; views?: number;
}; };

View File

@ -3,7 +3,10 @@
import * as moment from 'moment'; import * as moment from 'moment';
import humanizeDuration from 'humanize-duration'; import humanizeDuration from 'humanize-duration';
import type { Unit } from 'humanize-duration';
import { isNumber } from 'lodash';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { SECOND } from './durations';
const SECONDS_PER_WEEK = 604800; const SECONDS_PER_WEEK = 604800;
export const DEFAULT_DURATIONS_IN_SECONDS: ReadonlyArray<number> = [ export const DEFAULT_DURATIONS_IN_SECONDS: ReadonlyArray<number> = [
@ -23,12 +26,13 @@ export const DEFAULT_DURATIONS_SET: ReadonlySet<number> = new Set<number>(
export type FormatOptions = { export type FormatOptions = {
capitalizeOff?: boolean; capitalizeOff?: boolean;
largest?: number; // how many units to show (the largest n)
}; };
export function format( export function format(
i18n: LocalizerType, i18n: LocalizerType,
dirtySeconds?: number, dirtySeconds?: number,
{ capitalizeOff = false }: FormatOptions = {} { capitalizeOff = false, largest }: FormatOptions = {}
): string { ): string {
let seconds = Math.abs(dirtySeconds || 0); let seconds = Math.abs(dirtySeconds || 0);
if (!seconds) { if (!seconds) {
@ -49,9 +53,31 @@ export function format(
fallbacks.push('en'); fallbacks.push('en');
} }
const allUnits: Array<Unit> = ['y', 'mo', 'w', 'd', 'h', 'm', 's'];
const defaultUnits: Array<Unit> =
seconds % SECONDS_PER_WEEK === 0 ? ['w'] : ['d', 'h', 'm', 's'];
return humanizeDuration(seconds * 1000, { 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, language: locale,
...(fallbacks.length ? { fallbacks } : {}), ...(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;
}