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",
"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"

View file

@ -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<StorySendStateType>;
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 (
<Modal
hasXButton
@ -235,6 +242,21 @@ export const StoryDetailsModal = ({
/>
</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>
}
>

View file

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

View file

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

View file

@ -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<MessagePropsType, 'getPreferredBadge' | 'interactionMode'>;
} & Pick<
MessagePropsType,
| 'getPreferredBadge'
| 'interactionMode'
| 'expirationLength'
| 'expirationTimestamp'
>;
export type PropsBackboneActions = Pick<
MessagePropsType,
@ -280,6 +287,7 @@ export class MessageDetail extends React.Component<Props> {
contactNameColor,
displayTapToViewMessage,
doubleCheckMissingQuoteReference,
expirationTimestamp,
getPreferredBadge,
i18n,
interactionMode,
@ -307,6 +315,10 @@ export class MessageDetail extends React.Component<Props> {
viewStory,
} = this.props;
const timeRemaining = expirationTimestamp
? expirationTimestamp - Date.now()
: undefined;
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
<div className="module-message-detail" tabIndex={0} ref={this.focusRef}>
@ -431,6 +443,18 @@ export class MessageDetail extends React.Component<Props> {
</td>
</tr>
) : 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>
</table>
{this.renderContacts()}

View file

@ -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<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 {
sentAt: this.get('sent_at'),
receivedAt: this.getReceivedAt(),
expirationLength,
expirationTimestamp,
message: getPropsForMessage(this.attributes, {
conversationSelector: findAndFormatContact,
ourConversationId,

View file

@ -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<MessageAttributesType> | undefined;
@ -51,6 +52,8 @@ export function getStoryDataFromMessageAttributes(
'timestamp',
'type',
]),
expireTimer: message.expireTimer,
expirationStartTimestamp: dropNull(message.expirationStartTimestamp),
};
}

View file

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

View file

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

View file

@ -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<StorySendStateType> | undefined;
@ -179,6 +183,10 @@ export function getStoryView(
sender,
sendState,
timestamp,
expirationTimestamp: calculateExpirationTimestamp({
expireTimer,
expirationStartTimestamp,
}),
views,
};
}

View file

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

View file

@ -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
),
},
],
},

View file

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

View file

@ -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<number> = [
@ -23,12 +26,13 @@ export const DEFAULT_DURATIONS_SET: ReadonlySet<number> = new Set<number>(
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<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, {
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;
}