@mentions notifications

This commit is contained in:
Evan Hahn 2021-08-05 07:35:33 -05:00 committed by GitHub
parent 3bbe859452
commit 6b290a0f0c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 627 additions and 51 deletions

View File

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

View File

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m15.37 6.36a.87.87 0 0 0 -.87.87v.87a4.1 4.1 0 0 0 -3.24-1.74c-2.74 0-5 2.53-5 5.64s2.24 5.64 5 5.64a4.19 4.19 0 0 0 3.56-2.21 3.4 3.4 0 0 0 3.46 2.21c2.45 0 4.23-2.5 4.23-5.94 0-6.2-4.42-10.7-10.51-10.7a11 11 0 1 0 5.93 20.26.87.87 0 0 0 .27-1.2.87.87 0 0 0 -1.2-.27 9.25 9.25 0 1 1 -5-17c5.16 0 8.76 3.68 8.76 9 0 2.39-1.07 4.19-2.48 4.19-1 0-2-.27-2-2.4v-6.35a.88.88 0 0 0 -.91-.87zm-4.11 9.53c-1.78 0-3.26-1.74-3.26-3.89s1.45-3.89 3.23-3.89 3.27 1.74 3.27 3.89-1.45 3.89-3.24 3.89z"/></svg>

After

Width:  |  Height:  |  Size: 563 B

View File

@ -0,0 +1 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m21.057 4.1-.957-1.156-2.786 2.318a5.981 5.981 0 0 0 -11.176 1.465l-1.427 6.843a7.284 7.284 0 0 1 -1.749 3.651l-2.019 1.679.957 1.156zm-14.877 9.776 1.42-6.832a4.5 4.5 0 0 1 8.533-.8l-10.323 8.605a3.552 3.552 0 0 0 .37-.973zm15.82 3.624a1.5 1.5 0 0 1 -1.5 1.5h-14.983l1.8-1.5h13.175a5.511 5.511 0 0 1 -2.664-3.606l-.915-4.387 1.306-1.088 1.074 5.151a4.033 4.033 0 0 0 1.975 2.646 1.486 1.486 0 0 1 .732 1.284zm-12.45 3h4.9a2.5 2.5 0 0 1 -4.9 0z"/></svg>

After

Width:  |  Height:  |  Size: 545 B

View File

@ -0,0 +1 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m1.9 20.056-.957-1.156 2.122-1.769a6.1 6.1 0 0 0 1.646-3.561l1.427-6.843a5.981 5.981 0 0 1 11.176-1.465l2.786-2.318.957 1.156zm10.1 2.444a2.5 2.5 0 0 0 2.45-2h-4.9a2.5 2.5 0 0 0 2.45 2zm9.264-6.284a4.033 4.033 0 0 1 -1.975-2.646l-1.074-5.151-12.698 10.581h14.983a1.5 1.5 0 0 0 .764-2.784z"/></svg>

After

Width:  |  Height:  |  Size: 389 B

View File

@ -0,0 +1 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m19.778 19.778-1.06-1.06a9.512 9.512 0 0 0 0-13.436l1.06-1.06a11.012 11.012 0 0 1 0 15.556zm-.778-7.778a6.957 6.957 0 0 0 -2.051-4.95l-1.06 1.061a5.5 5.5 0 0 1 0 7.778l1.06 1.06a6.953 6.953 0 0 0 2.051-4.949zm-5-8.863v17.726a.5.5 0 0 1 -.5.5.494.494 0 0 1 -.335-.132l-5.165-4.731h-4a2 2 0 0 1 -2-2v-5a2 2 0 0 1 2-2h4l5.162-4.732a.494.494 0 0 1 .335-.132.5.5 0 0 1 .503.501zm-1.5 13.363v-9l.25-2.75-1.41 1.723-2.757 2.527h-4.583a.5.5 0 0 0 -.5.5v5a.5.5 0 0 0 .5.5h4.583l2.757 2.527 1.41 1.723z"/></svg>

After

Width:  |  Height:  |  Size: 593 B

View File

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

View File

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

View File

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

View File

@ -29,3 +29,16 @@ story.add('Normal', () => {
/>
);
});
story.add('With disabled options', () => (
<Select
options={[
{ value: 'a', text: 'Apples' },
{ value: 'b', text: 'Bananas', disabled: true },
{ value: 'c', text: 'Cabbage' },
{ value: 'd', text: 'Durian', disabled: true },
]}
onChange={action('onChange')}
value="c"
/>
));

View File

@ -5,6 +5,7 @@ import React, { ChangeEvent } from 'react';
import classNames from 'classnames';
export type Option = Readonly<{
disabled?: boolean;
text: string;
value: string | number;
}>;
@ -26,9 +27,14 @@ export function Select(props: PropsType): JSX.Element {
return (
<div className={classNames(['module-select', moduleClassName])}>
<select value={value} onChange={onSelectChange}>
{options.map(({ text, value: optionValue }) => {
{options.map(({ disabled, text, value: optionValue }) => {
return (
<option value={optionValue} key={optionValue} aria-label={text}>
<option
disabled={disabled}
value={optionValue}
key={optionValue}
aria-label={text}
>
{text}
</option>
);

View File

@ -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<PropsType, StateType> {
onMoveToInbox,
} = this.props;
const muteOptions: Array<MuteOption> = [];
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;

View File

@ -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 () => {

View File

@ -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<MediaItemType>
) => void;
showConversationNotificationsSettings: () => void;
updateGroupAttributes: (
_: Readonly<{
avatar?: undefined | ArrayBuffer;
@ -95,6 +97,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
showGroupV2Permissions,
showPendingInvites,
showLightboxForMedia,
showConversationNotificationsSettings,
updateGroupAttributes,
onBlock,
onLeave,
@ -284,6 +287,21 @@ export const ConversationDetails: React.ComponentType<Props> = ({
/>
}
/>
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('ConversationDetails--notifications')}
icon="notifications"
/>
}
label={i18n('ConversationDetails--notifications')}
onClick={showConversationNotificationsSettings}
right={
conversation.muteExpiresAt
? getMutedUntilText(conversation.muteExpiresAt, i18n)
: undefined
}
/>
</PanelSection>
<ConversationDetailsMembershipList

View File

@ -0,0 +1,45 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import { ConversationNotificationsSettings } from './ConversationNotificationsSettings';
const i18n = setupI18n('en', enMessages);
const story = storiesOf(
'Components/Conversation/ConversationDetails/ConversationNotificationsSettings',
module
);
const getCommonProps = () => ({
muteExpiresAt: undefined,
conversationType: 'group' as const,
dontNotifyForMentionsIfMuted: false,
i18n,
setDontNotifyForMentionsIfMuted: action('setDontNotifyForMentionsIfMuted'),
setMuteExpiration: action('setMuteExpiration'),
});
story.add('Group conversation, all default', () => (
<ConversationNotificationsSettings {...getCommonProps()} />
));
story.add('Group conversation, muted', () => (
<ConversationNotificationsSettings
{...getCommonProps()}
muteExpiresAt={Date.UTC(2099, 5, 9)}
/>
));
story.add('Group conversation, @mentions muted', () => (
<ConversationNotificationsSettings
{...getCommonProps()}
dontNotifyForMentionsIfMuted
/>
));

View File

@ -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<PropsType> = ({
conversationType,
dontNotifyForMentionsIfMuted,
i18n,
muteExpiresAt,
setMuteExpiration,
setDontNotifyForMentionsIfMuted,
}) => {
// This assertion is here to prevent accidental usage of this component in an untested
// context.
assert(
conversationType === 'group',
'<ConversationNotificationsSettings> 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 (
<div className="conversation-details-panel">
<PanelSection>
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('muteNotificationsTitle')}
icon="mute"
/>
}
label={i18n('muteNotificationsTitle')}
right={
<Select options={muteOptions} onChange={onMuteChange} value={-1} />
}
/>
{conversationType === 'group' && (
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n(
'ConversationNotificationsSettings__mentions__label'
)}
icon="mention"
/>
}
label={i18n('ConversationNotificationsSettings__mentions__label')}
info={i18n('ConversationNotificationsSettings__mentions__info')}
right={
<Select
options={[
{
text: i18n(
'ConversationNotificationsSettings__mentions__select__always-notify'
),
value: 'no',
},
{
text: i18n(
'ConversationNotificationsSettings__mentions__select__dont-notify-for-mentions-if-muted'
),
value: 'yes',
},
]}
onChange={onChangeDontNotifyForMentionsIfMuted}
value={dontNotifyForMentionsIfMuted ? 'yes' : 'no'}
/>
}
/>
)}
</PanelSection>
</div>
);
};

1
ts/model-types.d.ts vendored
View File

@ -219,6 +219,7 @@ export type ConversationAttributesType = {
messageCountBeforeMessageRequests?: number | null;
messageRequestResponseType?: number;
muteExpiresAt?: number;
dontNotifyForMentionsIfMuted?: boolean;
profileAvatar?: null | {
hash: string;
path: string;

View File

@ -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<GroupNameCollisionsWithIdsByTitle>
): void {

View File

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

View File

@ -137,6 +137,7 @@ export type ConversationType = {
conversationId: string;
}>;
muteExpiresAt?: number;
dontNotifyForMentionsIfMuted?: boolean;
type: ConversationTypeType;
isMe: boolean;
lastUpdated?: number;

View File

@ -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 => (
<Provider store={store}>
<SmartConversationNotificationsSettings {...props} />
</Provider>
);

View File

@ -28,6 +28,7 @@ export type SmartConversationDetailsProps = {
showGroupChatColorEditor: () => void;
showGroupLinkManagement: () => void;
showGroupV2Permissions: () => void;
showConversationNotificationsSettings: () => void;
showPendingInvites: () => void;
showLightboxForMedia: (
selectedMediaItem: MediaItemType,

View File

@ -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
);

View File

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

View File

@ -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'
);
});
});

View File

@ -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<MuteOption> {
export function getMuteOptions(
muteExpiresAt: undefined | number,
i18n: LocalizerType
): Array<MuteOption> {
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'),

View File

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

View File

@ -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);
}

View File

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

2
ts/window.d.ts vendored
View File

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