diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e60672bae5..3b23b757c2 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2745,5 +2745,39 @@ "callingDeviceSelection__select--no-device": { "message": "No devices available", "description": "Message for when there are no available devices to select for input/output audio or video" + }, + "muteNotificationsTitle": { + "message": "Mute notifications", + "description": "Label for the mute notifications drop-down selector" + }, + "muteHour": { + "message": "Mute for one hour", + "description": "Label for muting the conversation" + }, + "muteDay": { + "message": "Mute for one day", + "description": "Label for muting the conversation" + }, + "muteWeek": { + "message": "Mute for one week", + "description": "Label for muting the conversation" + }, + "muteYear": { + "message": "Mute for one year", + "description": "Label for muting the conversation" + }, + "unmute": { + "message": "Unmute", + "description": "Label for unmuting the conversation" + }, + "muteExpirationLabel": { + "message": "Muted until $duration$", + "description": "Shown in the mute notifications submenu whenever a conversation has been muted", + "placeholders": { + "duration": { + "content": "$1", + "example": "10/23/2023, 7:10 PM" + } + } } } diff --git a/images/icons/v2/sound-off-outline-24.svg b/images/icons/v2/sound-off-outline-24.svg new file mode 100644 index 0000000000..67160f53b9 --- /dev/null +++ b/images/icons/v2/sound-off-outline-24.svg @@ -0,0 +1 @@ +sound-off-outline-24 \ No newline at end of file diff --git a/js/models/conversations.js b/js/models/conversations.js index c707a1cd52..c5724bae00 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -499,6 +499,7 @@ ? undefined : (this.get('members') || []).length, messageRequestsEnabled, + muteExpiresAt: this.get('muteExpiresAt'), name: this.get('name'), phoneNumber: this.getNumber(), profileName: this.getProfileName(), @@ -2844,6 +2845,10 @@ }, async notify(message, reaction) { + if (this.get('muteExpiresAt') && Date.now() < this.get('muteExpiresAt')) { + return; + } + if (!message.isIncoming() && !reaction) { return; } diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index aa6f9c2f3c..755bc44c21 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -357,6 +357,24 @@ paste: 'onPaste', }, + getMuteExpirationLabel() { + const muteExpiresAt = this.model.get('muteExpiresAt'); + if (!muteExpiresAt) { + return; + } + + const today = window.moment(Date.now()); + const expires = window.moment(muteExpiresAt); + + if (today.isSame(expires, 'day')) { + // eslint-disable-next-line consistent-return + return expires.format('hh:mm A'); + } + + // eslint-disable-next-line consistent-return + return expires.format('M/D/YY, hh:mm A'); + }, + setupHeader() { const getHeaderProps = () => { const expireTimer = this.model.get('expireTimer'); @@ -376,6 +394,8 @@ value: item.get('seconds'), })), + muteExpirationLabel: this.getMuteExpirationLabel(), + onSetDisappearingMessages: seconds => this.setDisappearingMessages(seconds), onDeleteMessages: () => this.destroyMessages(), @@ -387,6 +407,7 @@ : this.model.getTitle(); searchInConversation(this.model.id, name); }, + onSetMuteNotifications: ms => this.setMuteNotifications(ms), // These are view only and don't update the Conversation model, so they // need a manual update call. @@ -2485,6 +2506,12 @@ } }, + setMuteNotifications(ms) { + this.model.set({ + muteExpiresAt: ms > 0 ? Date.now() + ms : undefined, + }); + }, + async destroyMessages() { try { await this.confirm(i18n('deleteConversationConfirmation')); diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 036351606e..4e6232016e 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3621,6 +3621,27 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', } } +.module-conversation-list-item__muted { + display: inline-block; + height: 14px; + margin-right: 4px; + vertical-align: middle; + width: 14px; + + @include light-theme { + @include color-svg( + '../images/icons/v2/sound-off-outline-24.svg', + $color-gray-60 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/sound-off-outline-24.svg', + $color-gray-25 + ); + } +} + .module-conversation-list-item--has-unread { padding-left: 12px; diff --git a/ts/components/ConversationListItem.stories.tsx b/ts/components/ConversationListItem.stories.tsx index 428f08993e..c891e3a493 100644 --- a/ts/components/ConversationListItem.stories.tsx +++ b/ts/components/ConversationListItem.stories.tsx @@ -246,3 +246,10 @@ story.add('Missing Text', () => { /> ); }); + +story.add('Muted Conversation', () => { + const props = createProps(); + const muteExpiresAt = Date.now() + 1000 * 60 * 60; + + return ; +}); diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index fc5ff66ea3..76c6e46a68 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -33,6 +33,7 @@ export type PropsData = { type: 'group' | 'direct'; avatarPath?: string; isMe?: boolean; + muteExpiresAt?: number; lastUpdated: number; unreadCount?: number; @@ -167,6 +168,7 @@ export class ConversationListItem extends React.PureComponent { i18n, isAccepted, lastMessage, + muteExpiresAt, shouldShowDraft, typingContact, unreadCount, @@ -201,6 +203,9 @@ export class ConversationListItem extends React.PureComponent { : null )} > + {muteExpiresAt && ( + + )} {!isAccepted ? ( {i18n('ConversationListItem--message-request')} diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index c065596741..cf4e1af3be 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -34,6 +34,7 @@ const actionProps: PropsActionsType = { onDeleteMessages: action('onDeleteMessages'), onResetSession: action('onResetSession'), onSearchInConversation: action('onSearchInConversation'), + onSetMuteNotifications: action('onSetMuteNotifications'), onOutgoingAudioCallInConversation: action( 'onOutgoingAudioCallInConversation' ), @@ -172,6 +173,20 @@ const stories: Array = [ ...housekeepingProps, }, }, + { + title: 'Muting Conversation', + props: { + color: 'ultramarine', + title: '(202) 555-0006', + phoneNumber: '(202) 555-0006', + type: 'direct', + id: '6', + muteExpirationLabel: '10/18/3000, 11:11 AM', + isAccepted: true, + ...actionProps, + ...housekeepingProps, + }, + }, ], }, { diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 97d47b5cd9..b1a9d1e73e 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -13,6 +13,7 @@ import { InContactsIcon } from '../InContactsIcon'; import { LocalizerType } from '../../types/Util'; import { ColorType } from '../../types/Colors'; +import { getMuteOptions } from '../../util/getMuteOptions'; interface TimerOption { name: string; @@ -37,11 +38,13 @@ export interface PropsDataType { leftGroup?: boolean; expirationSettingName?: string; + muteExpirationLabel?: string; showBackButton?: boolean; timerOptions?: Array; } export interface PropsActionsType { + onSetMuteNotifications: (seconds: number) => void; onSetDisappearingMessages: (seconds: number) => void; onDeleteMessages: () => void; onResetSession: () => void; @@ -289,9 +292,11 @@ export class ConversationHeader extends React.Component { type, isArchived, leftGroup, + muteExpirationLabel, onDeleteMessages, onResetSession, onSetDisappearingMessages, + onSetMuteNotifications, onShowAllMedia, onShowGroupMembers, onShowSafetyNumber, @@ -300,7 +305,26 @@ export class ConversationHeader extends React.Component { timerOptions, } = this.props; + const muteOptions = []; + if (muteExpirationLabel) { + muteOptions.push( + ...[ + { + name: i18n('muteExpirationLabel', [muteExpirationLabel]), + disabled: true, + value: 0, + }, + { + name: i18n('unmute'), + value: 0, + }, + ] + ); + } + muteOptions.push(...getMuteOptions(i18n)); + const disappearingTitle = i18n('disappearingMessages') as any; + const muteTitle = i18n('muteNotificationsTitle') as any; const isGroup = type === 'group'; return ( @@ -319,6 +343,19 @@ export class ConversationHeader extends React.Component { ))} ) : null} + + {muteOptions.map(item => ( + { + onSetMuteNotifications(item.value); + }} + > + {item.name} + + ))} + {i18n('viewRecentMedia')} {isGroup ? ( diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 6304323f7b..8f9a2ad97d 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -49,6 +49,7 @@ export type ConversationType = { }; phoneNumber?: string; membersCount?: number; + muteExpiresAt?: number; type: 'direct' | 'group'; isMe?: boolean; lastUpdated: number; diff --git a/ts/util/getMuteOptions.ts b/ts/util/getMuteOptions.ts new file mode 100644 index 0000000000..1148adfd14 --- /dev/null +++ b/ts/util/getMuteOptions.ts @@ -0,0 +1,29 @@ +import moment from 'moment'; +import { LocalizerType } from '../types/Util'; + +type MuteOption = { + name: string; + disabled?: boolean; + value: number; +}; + +export function getMuteOptions(i18n: LocalizerType): Array { + return [ + { + name: i18n('muteHour'), + value: moment.duration(1, 'hour').as('milliseconds'), + }, + { + name: i18n('muteDay'), + value: moment.duration(1, 'day').as('milliseconds'), + }, + { + name: i18n('muteWeek'), + value: moment.duration(1, 'week').as('milliseconds'), + }, + { + name: i18n('muteYear'), + value: moment.duration(1, 'year').as('milliseconds'), + }, + ]; +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 1feb1a6586..f9c8d57c96 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -223,7 +223,7 @@ "rule": "jQuery-wrap(", "path": "js/models/conversations.js", "line": " await wrap(", - "lineNumber": 673, + "lineNumber": 674, "reasonCategory": "falseMatch", "updated": "2020-06-09T20:26:46.515Z" }, @@ -10634,7 +10634,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/ConversationHeader.js", "line": " this.menuTriggerRef = react_1.default.createRef();", - "lineNumber": 15, + "lineNumber": 16, "reasonCategory": "usageTrusted", "updated": "2019-07-31T00:19:18.696Z", "reasonDetail": "Used to reference popup menu" @@ -10643,7 +10643,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/ConversationHeader.tsx", "line": " this.menuTriggerRef = React.createRef();", - "lineNumber": 76, + "lineNumber": 79, "reasonCategory": "usageTrusted", "updated": "2020-05-20T20:10:43.540Z", "reasonDetail": "Used to reference popup menu"