Added the time remaining for disappearing messages and stories
This commit is contained in:
parent
134265496b
commit
383a0fd17f
14 changed files with 162 additions and 14 deletions
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
>
|
||||
|
|
|
@ -55,6 +55,7 @@ SomeonesStory.args = {
|
|||
messageId: '123',
|
||||
sender: getDefaultConversation(),
|
||||
timestamp: Date.now(),
|
||||
expirationTimestamp: undefined,
|
||||
},
|
||||
};
|
||||
SomeonesStory.story = {
|
||||
|
|
|
@ -748,6 +748,7 @@ export const StoryViewer = ({
|
|||
sendState={sendState}
|
||||
size={attachment?.size}
|
||||
timestamp={timestamp}
|
||||
expirationTimestamp={story.expirationTimestamp}
|
||||
/>
|
||||
)}
|
||||
{hasStoryViewsNRepliesModal && (
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -52,6 +52,7 @@ export function getFakeStoryView(
|
|||
messageId: UUID.generate().toString(),
|
||||
sender,
|
||||
timestamp: timestamp || Date.now() - 2 * durations.MINUTE,
|
||||
expirationTimestamp: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -90,6 +90,7 @@ export type StoryViewType = {
|
|||
>;
|
||||
sendState?: Array<StorySendStateType>;
|
||||
timestamp: number;
|
||||
expirationTimestamp: number | undefined;
|
||||
views?: number;
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue