diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 6afb7c1257..0883861d1f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3451,6 +3451,10 @@ "message": "Mute notifications", "description": "Label for the mute notifications drop-down selector" }, + "notMuted": { + "message": "Not muted", + "description": "Label when the conversation is not muted" + }, "muteHour": { "message": "Mute for one hour", "description": "Label for muting the conversation" @@ -4964,6 +4968,10 @@ "message": "When enabled, messages sent and received in this group will disappear after they've been seen.", "description": "This is the info about the disappearing messages setting" }, + "ConversationDetails--notifications": { + "message": "Notifications", + "description": "This is the label for notifications in the conversation details screen" + }, "ConversationDetails--group-info-label": { "message": "Who can edit group info", "description": "This is the label for the 'who can edit the group' panel" @@ -5070,6 +5078,22 @@ "message": "See all", "description": "This is a button on the conversation details to show all members" }, + "ConversationNotificationsSettings__mentions__label": { + "message": "Mentions", + "description": "In the conversation notifications settings, this is the label for the mentions option" + }, + "ConversationNotificationsSettings__mentions__info": { + "message": "Receive notifications when you're mentioned in muted chats", + "description": "In the conversation notifications settings, this is the sub-label for the mentions option" + }, + "ConversationNotificationsSettings__mentions__select__always-notify": { + "message": "Always notify", + "description": "In the conversation notifications settings, this is the option that always notifies you for @mentions" + }, + "ConversationNotificationsSettings__mentions__select__dont-notify-for-mentions-if-muted": { + "message": "Don't notify if muted", + "description": "In the conversation notifications settings, this is the option that doesn't notify you for @mentions if the conversation is muted" + }, "GroupLinkManagement--clipboard": { "message": "Group link copied.", "description": "Shown in a toast when a user selects to copy group link" diff --git a/images/icons/v2/at-24.svg b/images/icons/v2/at-24.svg new file mode 100644 index 0000000000..acbdfcb5f8 --- /dev/null +++ b/images/icons/v2/at-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/bell-disabled-outline-24.svg b/images/icons/v2/bell-disabled-outline-24.svg new file mode 100644 index 0000000000..0529c8fa08 --- /dev/null +++ b/images/icons/v2/bell-disabled-outline-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/bell-disabled-solid-24.svg b/images/icons/v2/bell-disabled-solid-24.svg new file mode 100644 index 0000000000..b0c7fa717e --- /dev/null +++ b/images/icons/v2/bell-disabled-solid-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/sound-outline-24.svg b/images/icons/v2/sound-outline-24.svg new file mode 100644 index 0000000000..be5040701f --- /dev/null +++ b/images/icons/v2/sound-outline-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/signal.js b/js/modules/signal.js index 3a68d77fdb..5d45993cf5 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -94,6 +94,9 @@ const { createLeftPane } = require('../../ts/state/roots/createLeftPane'); const { createMessageDetail, } = require('../../ts/state/roots/createMessageDetail'); +const { + createConversationNotificationsSettings, +} = require('../../ts/state/roots/createConversationNotificationsSettings'); const { createGroupV2Permissions, } = require('../../ts/state/roots/createGroupV2Permissions'); @@ -363,6 +366,7 @@ exports.setup = (options = {}) => { createGroupV2Permissions, createLeftPane, createMessageDetail, + createConversationNotificationsSettings, createPendingInvites, createSafetyNumberViewer, createShortcutGuideModal, diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index eb8f9586f3..131d3f8850 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -90,12 +90,13 @@ message GroupV1Record { } message GroupV2Record { - optional bytes masterKey = 1; - optional bool blocked = 2; - optional bool whitelisted = 3; - optional bool archived = 4; - optional bool markedUnread = 5; - optional uint64 mutedUntilTimestamp = 6; + optional bytes masterKey = 1; + optional bool blocked = 2; + optional bool whitelisted = 3; + optional bool archived = 4; + optional bool markedUnread = 5; + optional uint64 mutedUntilTimestamp = 6; + optional bool dontNotifyForMentionsIfMuted = 7; } message AccountRecord { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 4f5b9d0d64..5757d9e3fb 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2862,6 +2862,51 @@ button.module-conversation-details__action-button { } } + &--notifications { + &::after { + -webkit-mask: url('../images/icons/v2/sound-outline-24.svg') no-repeat + center; + + @include light-theme { + background-color: $color-gray-75; + } + + @include dark-theme { + background-color: $color-gray-15; + } + } + } + + &--mute { + &::after { + @include light-theme { + -webkit-mask: url('../images/icons/v2/bell-disabled-outline-24.svg') + no-repeat center; + background-color: $color-gray-75; + } + + @include dark-theme { + -webkit-mask: url('../images/icons/v2/bell-disabled-solid-24.svg') + no-repeat center; + background-color: $color-gray-15; + } + } + } + + &--mention { + &::after { + -webkit-mask: url('../images/icons/v2/at-24.svg') no-repeat center; + + @include light-theme { + background-color: $color-gray-75; + } + + @include dark-theme { + background-color: $color-gray-15; + } + } + } + &--lock { &::after { -webkit-mask: url(../images/icons/v2/lock-outline-24.svg) no-repeat diff --git a/ts/components/Select.stories.tsx b/ts/components/Select.stories.tsx index 84d7eb6f1e..97ac048d3e 100644 --- a/ts/components/Select.stories.tsx +++ b/ts/components/Select.stories.tsx @@ -29,3 +29,16 @@ story.add('Normal', () => { /> ); }); + +story.add('With disabled options', () => ( + - {options.map(({ text, value: optionValue }) => { + {options.map(({ disabled, text, value: optionValue }) => { return ( - ); diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index d4a6244fec..ef52024e5e 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -3,7 +3,6 @@ import React, { ReactNode } from 'react'; import Measure from 'react-measure'; -import moment from 'moment'; import classNames from 'classnames'; import { ContextMenu, @@ -19,9 +18,8 @@ import { InContactsIcon } from '../InContactsIcon'; import { LocalizerType } from '../../types/Util'; import { ConversationType } from '../../state/ducks/conversations'; -import { MuteOption, getMuteOptions } from '../../util/getMuteOptions'; +import { getMuteOptions } from '../../util/getMuteOptions'; import * as expirationTimer from '../../util/expirationTimer'; -import { isMuted } from '../../util/isMuted'; import { missingCaseError } from '../../util/missingCaseError'; import { isInSystemContacts } from '../../util/isInSystemContacts'; @@ -395,38 +393,7 @@ export class ConversationHeader extends React.Component { onMoveToInbox, } = this.props; - const muteOptions: Array = []; - if (isMuted(muteExpiresAt)) { - const expires = moment(muteExpiresAt); - - let muteExpirationLabel: string; - if (Number(muteExpiresAt) >= Number.MAX_SAFE_INTEGER) { - muteExpirationLabel = i18n('muteExpirationLabelAlways'); - } else { - const muteExpirationUntil = moment().isSame(expires, 'day') - ? expires.format('hh:mm A') - : expires.format('M/D/YY, hh:mm A'); - - muteExpirationLabel = i18n('muteExpirationLabel', [ - muteExpirationUntil, - ]); - } - - muteOptions.push( - ...[ - { - name: muteExpirationLabel, - disabled: true, - value: 0, - }, - { - name: i18n('unmute'), - value: 0, - }, - ] - ); - } - muteOptions.push(...getMuteOptions(i18n)); + const muteOptions = getMuteOptions(muteExpiresAt, i18n); // eslint-disable-next-line @typescript-eslint/no-explicit-any const disappearingTitle = i18n('disappearingMessages') as any; diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index 8b742891a0..268bfdca79 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -65,6 +65,9 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({ showGroupChatColorEditor: action('showGroupChatColorEditor'), showGroupLinkManagement: action('showGroupLinkManagement'), showGroupV2Permissions: action('showGroupV2Permissions'), + showConversationNotificationsSettings: action( + 'showConversationNotificationsSettings' + ), showPendingInvites: action('showPendingInvites'), showLightboxForMedia: action('showLightboxForMedia'), updateGroupAttributes: async () => { diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index ca1f60b7cf..2a5ab2cf7d 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -5,6 +5,7 @@ import React, { useState, ReactNode } from 'react'; import { ConversationType } from '../../../state/ducks/conversations'; import { assert } from '../../../util/assert'; +import { getMutedUntilText } from '../../../util/getMutedUntilText'; import { LocalizerType } from '../../../types/Util'; import { MediaItemType } from '../../LightboxGallery'; @@ -62,6 +63,7 @@ export type StateProps = { selectedMediaItem: MediaItemType, media: Array ) => void; + showConversationNotificationsSettings: () => void; updateGroupAttributes: ( _: Readonly<{ avatar?: undefined | ArrayBuffer; @@ -95,6 +97,7 @@ export const ConversationDetails: React.ComponentType = ({ showGroupV2Permissions, showPendingInvites, showLightboxForMedia, + showConversationNotificationsSettings, updateGroupAttributes, onBlock, onLeave, @@ -284,6 +287,21 @@ export const ConversationDetails: React.ComponentType = ({ /> } /> + + } + label={i18n('ConversationDetails--notifications')} + onClick={showConversationNotificationsSettings} + right={ + conversation.muteExpiresAt + ? getMutedUntilText(conversation.muteExpiresAt, i18n) + : undefined + } + /> ({ + muteExpiresAt: undefined, + conversationType: 'group' as const, + dontNotifyForMentionsIfMuted: false, + i18n, + setDontNotifyForMentionsIfMuted: action('setDontNotifyForMentionsIfMuted'), + setMuteExpiration: action('setMuteExpiration'), +}); + +story.add('Group conversation, all default', () => ( + +)); + +story.add('Group conversation, muted', () => ( + +)); + +story.add('Group conversation, @mentions muted', () => ( + +)); diff --git a/ts/components/conversation/conversation-details/ConversationNotificationsSettings.tsx b/ts/components/conversation/conversation-details/ConversationNotificationsSettings.tsx new file mode 100644 index 0000000000..02d51702e5 --- /dev/null +++ b/ts/components/conversation/conversation-details/ConversationNotificationsSettings.tsx @@ -0,0 +1,129 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { FunctionComponent, useMemo } from 'react'; + +import { ConversationTypeType } from '../../../state/ducks/conversations'; +import { LocalizerType } from '../../../types/Util'; +import { PanelSection } from './PanelSection'; +import { PanelRow } from './PanelRow'; +import { ConversationDetailsIcon } from './ConversationDetailsIcon'; +import { Select } from '../../Select'; +import { isMuted } from '../../../util/isMuted'; +import { assert } from '../../../util/assert'; +import { getMuteOptions } from '../../../util/getMuteOptions'; +import { parseIntOrThrow } from '../../../util/parseIntOrThrow'; + +type PropsType = { + conversationType: ConversationTypeType; + dontNotifyForMentionsIfMuted: boolean; + i18n: LocalizerType; + muteExpiresAt: undefined | number; + setDontNotifyForMentionsIfMuted: ( + dontNotifyForMentionsIfMuted: boolean + ) => unknown; + setMuteExpiration: (muteExpiresAt: undefined | number) => unknown; +}; + +export const ConversationNotificationsSettings: FunctionComponent = ({ + conversationType, + dontNotifyForMentionsIfMuted, + i18n, + muteExpiresAt, + setMuteExpiration, + setDontNotifyForMentionsIfMuted, +}) => { + // This assertion is here to prevent accidental usage of this component in an untested + // context. + assert( + conversationType === 'group', + ' SHOULD work for non-group conversations, but it has not been tested there' + ); + + const muteOptions = useMemo( + () => [ + ...(isMuted(muteExpiresAt) + ? [] + : [ + { + disabled: true, + text: i18n('notMuted'), + value: -1, + }, + ]), + ...getMuteOptions(muteExpiresAt, i18n).map( + ({ disabled, name, value }) => ({ + disabled, + text: name, + value, + }) + ), + ], + [i18n, muteExpiresAt] + ); + + const onMuteChange = (rawValue: string) => { + const ms = parseIntOrThrow( + rawValue, + 'NotificationSettings: mute ms was not an integer' + ); + setMuteExpiration(ms); + }; + + const onChangeDontNotifyForMentionsIfMuted = (rawValue: string) => { + setDontNotifyForMentionsIfMuted(rawValue === 'yes'); + }; + + return ( +
+ + + } + label={i18n('muteNotificationsTitle')} + right={ + + } + /> + )} + +
+ ); +}; diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 29904ab511..67331006ae 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -219,6 +219,7 @@ export type ConversationAttributesType = { messageCountBeforeMessageRequests?: number | null; messageRequestResponseType?: number; muteExpiresAt?: number; + dontNotifyForMentionsIfMuted?: boolean; profileAvatar?: null | { hash: string; path: string; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index eabc68cfdd..3794543fe0 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1452,6 +1452,7 @@ export class ConversationModel extends window.Backbone announcementsOnlyReady: this.canBeAnnouncementGroup(), expireTimer: this.get('expireTimer'), muteExpiresAt: this.get('muteExpiresAt')!, + dontNotifyForMentionsIfMuted: this.get('dontNotifyForMentionsIfMuted'), name: this.get('name')!, phoneNumber: this.getNumber()!, profileName: this.getProfileName()!, @@ -4787,6 +4788,7 @@ export class ConversationModel extends window.Backbone // [X] whitelisted // [X] archived // [X] markedUnread + // [X] dontNotifyForMentionsIfMuted captureChange(logMessage: string): void { if (!window.Signal.RemoteConfig.isEnabled('desktop.storageWrite3')) { window.log.info( @@ -4863,7 +4865,17 @@ export class ConversationModel extends window.Backbone } if (this.isMuted()) { - return; + if (this.get('dontNotifyForMentionsIfMuted')) { + return; + } + + const ourUuid = window.textsecure.storage.user.getUuid(); + const mentionsMe = (message.get('bodyRanges') || []).some( + range => range.mentionUuid && range.mentionUuid === ourUuid + ); + if (!mentionsMe) { + return; + } } if (!isIncoming(message.attributes) && !reaction) { @@ -5054,6 +5066,17 @@ export class ConversationModel extends window.Backbone } } + setDontNotifyForMentionsIfMuted(newValue: boolean): void { + const previousValue = Boolean(this.get('dontNotifyForMentionsIfMuted')); + if (previousValue === newValue) { + return; + } + + this.set({ dontNotifyForMentionsIfMuted: newValue }); + window.Signal.Data.updateConversation(this.attributes); + this.captureChange('dontNotifyForMentionsIfMuted'); + } + acknowledgeGroupMemberNameCollisions( groupNameCollisions: Readonly ): void { diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 15cdbde642..c777e7b8d9 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -319,6 +319,9 @@ export async function toGroupV2Record( groupV2Record.mutedUntilTimestamp = getSafeLongFromTimestamp( conversation.get('muteExpiresAt') ); + groupV2Record.dontNotifyForMentionsIfMuted = Boolean( + conversation.get('dontNotifyForMentionsIfMuted') + ); applyUnknownFields(groupV2Record, conversation); @@ -655,6 +658,9 @@ export async function mergeGroupV2Record( conversation.set({ isArchived: Boolean(groupV2Record.archived), markedUnread: Boolean(groupV2Record.markedUnread), + dontNotifyForMentionsIfMuted: Boolean( + groupV2Record.dontNotifyForMentionsIfMuted + ), storageID, }); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 661f8d0b0e..9a29439e54 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -137,6 +137,7 @@ export type ConversationType = { conversationId: string; }>; muteExpiresAt?: number; + dontNotifyForMentionsIfMuted?: boolean; type: ConversationTypeType; isMe: boolean; lastUpdated?: number; diff --git a/ts/state/roots/createConversationNotificationsSettings.tsx b/ts/state/roots/createConversationNotificationsSettings.tsx new file mode 100644 index 0000000000..4274241f68 --- /dev/null +++ b/ts/state/roots/createConversationNotificationsSettings.tsx @@ -0,0 +1,21 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { Provider } from 'react-redux'; + +import { Store } from 'redux'; + +import { + SmartConversationNotificationsSettings, + OwnProps, +} from '../smart/ConversationNotificationsSettings'; + +export const createConversationNotificationsSettings = ( + store: Store, + props: OwnProps +): React.ReactElement => ( + + + +); diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index b21edda03e..222cbf3269 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -28,6 +28,7 @@ export type SmartConversationDetailsProps = { showGroupChatColorEditor: () => void; showGroupLinkManagement: () => void; showGroupV2Permissions: () => void; + showConversationNotificationsSettings: () => void; showPendingInvites: () => void; showLightboxForMedia: ( selectedMediaItem: MediaItemType, diff --git a/ts/state/smart/ConversationNotificationsSettings.tsx b/ts/state/smart/ConversationNotificationsSettings.tsx new file mode 100644 index 0000000000..d29adf38e1 --- /dev/null +++ b/ts/state/smart/ConversationNotificationsSettings.tsx @@ -0,0 +1,46 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { connect } from 'react-redux'; +import { ConversationNotificationsSettings } from '../../components/conversation/conversation-details/ConversationNotificationsSettings'; +import { StateType } from '../reducer'; +import { getIntl } from '../selectors/user'; +import { getConversationByIdSelector } from '../selectors/conversations'; +import { strictAssert } from '../../util/assert'; + +export type OwnProps = { + conversationId: string; + setDontNotifyForMentionsIfMuted: ( + dontNotifyForMentionsIfMuted: boolean + ) => unknown; + setMuteExpiration: (muteExpiresAt: undefined | number) => unknown; +}; + +const mapStateToProps = (state: StateType, props: OwnProps) => { + const { + conversationId, + setDontNotifyForMentionsIfMuted, + setMuteExpiration, + } = props; + + const conversationSelector = getConversationByIdSelector(state); + const conversation = conversationSelector(conversationId); + strictAssert(conversation, 'Expected a conversation to be found'); + + return { + conversationType: conversation.type, + dontNotifyForMentionsIfMuted: Boolean( + conversation.dontNotifyForMentionsIfMuted + ), + i18n: getIntl(state), + muteExpiresAt: conversation.muteExpiresAt, + setDontNotifyForMentionsIfMuted, + setMuteExpiration, + }; +}; + +const smart = connect(mapStateToProps, {}); + +export const SmartConversationNotificationsSettings = smart( + ConversationNotificationsSettings +); diff --git a/ts/test-both/util/getMuteOptions_test.ts b/ts/test-both/util/getMuteOptions_test.ts new file mode 100644 index 0000000000..88205ffe50 --- /dev/null +++ b/ts/test-both/util/getMuteOptions_test.ts @@ -0,0 +1,92 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { setup as setupI18n } from '../../../js/modules/i18n'; +import enMessages from '../../../_locales/en/messages.json'; + +import { getMuteOptions } from '../../util/getMuteOptions'; + +describe('getMuteOptions', () => { + const HOUR = 3600000; + const DAY = HOUR * 24; + const WEEK = DAY * 7; + const EXPECTED_DEFAULT_OPTIONS = [ + { + name: 'Mute for one hour', + value: HOUR, + }, + { + name: 'Mute for eight hours', + value: HOUR * 8, + }, + { + name: 'Mute for one day', + value: DAY, + }, + { + name: 'Mute for one week', + value: WEEK, + }, + { + name: 'Mute always', + value: Number.MAX_SAFE_INTEGER, + }, + ]; + + const i18n = setupI18n('en', enMessages); + + describe('when not muted', () => { + it('returns the 5 default options', () => { + assert.deepStrictEqual( + getMuteOptions(undefined, i18n), + EXPECTED_DEFAULT_OPTIONS + ); + }); + }); + + describe('when muted', () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.useFakeTimers({ + now: new Date(2000, 3, 20, 12, 0, 0), + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('returns a current mute label, an "Unmute" option, and then the 5 default options', () => { + assert.deepStrictEqual( + getMuteOptions(new Date(2000, 3, 20, 18, 30, 0).valueOf(), i18n), + [ + { + disabled: true, + name: 'Muted until 6:30 PM', + value: -1, + }, + { + name: 'Unmute', + value: 0, + }, + ...EXPECTED_DEFAULT_OPTIONS, + ] + ); + }); + + it("renders the current mute label with a date if it's on a different day", () => { + assert.deepStrictEqual( + getMuteOptions(new Date(2000, 3, 21, 18, 30, 0).valueOf(), i18n)[0], + { + disabled: true, + name: 'Muted until 04/21/2000, 6:30 PM', + value: -1, + } + ); + }); + }); +}); diff --git a/ts/test-both/util/getMutedUntilText_test.ts b/ts/test-both/util/getMutedUntilText_test.ts new file mode 100644 index 0000000000..89c8dac988 --- /dev/null +++ b/ts/test-both/util/getMutedUntilText_test.ts @@ -0,0 +1,48 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { setup as setupI18n } from '../../../js/modules/i18n'; +import enMessages from '../../../_locales/en/messages.json'; + +import { getMutedUntilText } from '../../util/getMutedUntilText'; + +describe('getMutedUntilText', () => { + const i18n = setupI18n('en', enMessages); + + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.useFakeTimers({ + now: new Date(2000, 3, 20, 12, 0, 0), + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('returns an "always" label if passed a large number', () => { + assert.strictEqual( + getMutedUntilText(Number.MAX_SAFE_INTEGER, i18n), + 'Muted always' + ); + assert.strictEqual(getMutedUntilText(Infinity, i18n), 'Muted always'); + }); + + it('returns the time if the mute expires later today', () => { + assert.strictEqual( + getMutedUntilText(new Date(2000, 3, 20, 18, 30, 0).valueOf(), i18n), + 'Muted until 6:30 PM' + ); + }); + + it('returns the date and time if the mute expires on another day', () => { + assert.strictEqual( + getMutedUntilText(new Date(2000, 3, 21, 18, 30, 0).valueOf(), i18n), + 'Muted until 04/21/2000, 6:30 PM' + ); + }); +}); diff --git a/ts/util/getMuteOptions.ts b/ts/util/getMuteOptions.ts index 3ce5398fcd..5a250e71fd 100644 --- a/ts/util/getMuteOptions.ts +++ b/ts/util/getMuteOptions.ts @@ -3,6 +3,8 @@ import moment from 'moment'; import { LocalizerType } from '../types/Util'; +import { getMutedUntilText } from './getMutedUntilText'; +import { isMuted } from './isMuted'; export type MuteOption = { name: string; @@ -10,8 +12,24 @@ export type MuteOption = { value: number; }; -export function getMuteOptions(i18n: LocalizerType): Array { +export function getMuteOptions( + muteExpiresAt: undefined | number, + i18n: LocalizerType +): Array { return [ + ...(isMuted(muteExpiresAt) + ? [ + { + name: getMutedUntilText(muteExpiresAt, i18n), + disabled: true, + value: -1, + }, + { + name: i18n('unmute'), + value: 0, + }, + ] + : []), { name: i18n('muteHour'), value: moment.duration(1, 'hour').as('milliseconds'), diff --git a/ts/util/getMutedUntilText.ts b/ts/util/getMutedUntilText.ts new file mode 100644 index 0000000000..ad7002f47c --- /dev/null +++ b/ts/util/getMutedUntilText.ts @@ -0,0 +1,26 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import moment from 'moment'; +import { LocalizerType } from '../types/Util'; + +/** + * Returns something like "Muted until 6:09 PM", localized. + * + * Shouldn't be called with `0`. + */ +export function getMutedUntilText( + muteExpiresAt: number, + i18n: LocalizerType +): string { + if (Number(muteExpiresAt) >= Number.MAX_SAFE_INTEGER) { + return i18n('muteExpirationLabelAlways'); + } + + const expires = moment(muteExpiresAt); + const muteExpirationUntil = moment().isSame(expires, 'day') + ? expires.format('LT') + : expires.format('L, LT'); + + return i18n('muteExpirationLabel', [muteExpirationUntil]); +} diff --git a/ts/util/isMuted.ts b/ts/util/isMuted.ts index 3405edfe58..ecdf8f8543 100644 --- a/ts/util/isMuted.ts +++ b/ts/util/isMuted.ts @@ -1,6 +1,8 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -export function isMuted(muteExpiresAt: undefined | number): boolean { +export function isMuted( + muteExpiresAt: undefined | number +): muteExpiresAt is number { return Boolean(muteExpiresAt && Date.now() < muteExpiresAt); } diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 6df4638502..e39eab87a4 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -515,6 +515,14 @@ Whisper.ConversationView = Whisper.View.extend({ return expires.format('M/D/YY, hh:mm A'); }, + setMuteExpiration(ms = 0): void { + const { model }: { model: ConversationModel } = this; + + model.setMuteExpiration( + ms >= Number.MAX_SAFE_INTEGER ? ms : Date.now() + ms + ); + }, + setPin(value: boolean) { const { model }: { model: ConversationModel } = this; @@ -556,10 +564,7 @@ Whisper.ConversationView = Whisper.View.extend({ : model.getTitle(); searchInConversation(model.id, name); }, - onSetMuteNotifications: (ms: number) => - model.setMuteExpiration( - ms >= Number.MAX_SAFE_INTEGER ? ms : Date.now() + ms - ), + onSetMuteNotifications: this.setMuteExpiration.bind(this), onSetPin: this.setPin.bind(this), // These are view only and don't update the Conversation model, so they // need a manual update call. @@ -3206,6 +3211,28 @@ Whisper.ConversationView = Whisper.View.extend({ view.render(); }, + showConversationNotificationsSettings() { + const { model }: { model: ConversationModel } = this; + + const view = new Whisper.ReactWrapperView({ + className: 'panel', + JSX: window.Signal.State.Roots.createConversationNotificationsSettings( + window.reduxStore, + { + conversationId: model.id, + setDontNotifyForMentionsIfMuted: model.setDontNotifyForMentionsIfMuted.bind( + model + ), + setMuteExpiration: this.setMuteExpiration.bind(this), + } + ), + }); + view.headerTitle = window.i18n('ConversationDetails--notifications'); + + this.listenBack(view); + view.render(); + }, + showChatColorEditor() { const { model }: { model: ConversationModel } = this; @@ -3268,6 +3295,9 @@ Whisper.ConversationView = Whisper.View.extend({ showGroupChatColorEditor: this.showChatColorEditor.bind(this), showGroupLinkManagement: this.showGroupLinkManagement.bind(this), showGroupV2Permissions: this.showGroupV2Permissions.bind(this), + showConversationNotificationsSettings: this.showConversationNotificationsSettings.bind( + this + ), showPendingInvites: this.showPendingInvites.bind(this), showLightboxForMedia: this.showLightboxForMedia.bind(this), updateGroupAttributes: model.updateGroupAttributesV2.bind(model), diff --git a/ts/window.d.ts b/ts/window.d.ts index d31e8eb7ea..c249a981e0 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -54,6 +54,7 @@ import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal'; import { createGroupV2Permissions } from './state/roots/createGroupV2Permissions'; import { createLeftPane } from './state/roots/createLeftPane'; import { createMessageDetail } from './state/roots/createMessageDetail'; +import { createConversationNotificationsSettings } from './state/roots/createConversationNotificationsSettings'; import { createPendingInvites } from './state/roots/createPendingInvites'; import { createSafetyNumberViewer } from './state/roots/createSafetyNumberViewer'; import { createShortcutGuideModal } from './state/roots/createShortcutGuideModal'; @@ -437,6 +438,7 @@ declare global { createGroupV2Permissions: typeof createGroupV2Permissions; createLeftPane: typeof createLeftPane; createMessageDetail: typeof createMessageDetail; + createConversationNotificationsSettings: typeof createConversationNotificationsSettings; createPendingInvites: typeof createPendingInvites; createSafetyNumberViewer: typeof createSafetyNumberViewer; createShortcutGuideModal: typeof createShortcutGuideModal;