@mentions notifications
This commit is contained in:
parent
3bbe859452
commit
6b290a0f0c
29 changed files with 627 additions and 51 deletions
|
@ -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"
|
||||
|
|
1
images/icons/v2/at-24.svg
Normal file
1
images/icons/v2/at-24.svg
Normal 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 |
1
images/icons/v2/bell-disabled-outline-24.svg
Normal file
1
images/icons/v2/bell-disabled-outline-24.svg
Normal 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 |
1
images/icons/v2/bell-disabled-solid-24.svg
Normal file
1
images/icons/v2/bell-disabled-solid-24.svg
Normal 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 |
1
images/icons/v2/sound-outline-24.svg
Normal file
1
images/icons/v2/sound-outline-24.svg
Normal 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 |
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
));
|
|
@ -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
1
ts/model-types.d.ts
vendored
|
@ -219,6 +219,7 @@ export type ConversationAttributesType = {
|
|||
messageCountBeforeMessageRequests?: number | null;
|
||||
messageRequestResponseType?: number;
|
||||
muteExpiresAt?: number;
|
||||
dontNotifyForMentionsIfMuted?: boolean;
|
||||
profileAvatar?: null | {
|
||||
hash: string;
|
||||
path: string;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -137,6 +137,7 @@ export type ConversationType = {
|
|||
conversationId: string;
|
||||
}>;
|
||||
muteExpiresAt?: number;
|
||||
dontNotifyForMentionsIfMuted?: boolean;
|
||||
type: ConversationTypeType;
|
||||
isMe: boolean;
|
||||
lastUpdated?: number;
|
||||
|
|
21
ts/state/roots/createConversationNotificationsSettings.tsx
Normal file
21
ts/state/roots/createConversationNotificationsSettings.tsx
Normal 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>
|
||||
);
|
|
@ -28,6 +28,7 @@ export type SmartConversationDetailsProps = {
|
|||
showGroupChatColorEditor: () => void;
|
||||
showGroupLinkManagement: () => void;
|
||||
showGroupV2Permissions: () => void;
|
||||
showConversationNotificationsSettings: () => void;
|
||||
showPendingInvites: () => void;
|
||||
showLightboxForMedia: (
|
||||
selectedMediaItem: MediaItemType,
|
||||
|
|
46
ts/state/smart/ConversationNotificationsSettings.tsx
Normal file
46
ts/state/smart/ConversationNotificationsSettings.tsx
Normal 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
|
||||
);
|
92
ts/test-both/util/getMuteOptions_test.ts
Normal file
92
ts/test-both/util/getMuteOptions_test.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
48
ts/test-both/util/getMutedUntilText_test.ts
Normal file
48
ts/test-both/util/getMutedUntilText_test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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'),
|
||||
|
|
26
ts/util/getMutedUntilText.ts
Normal file
26
ts/util/getMutedUntilText.ts
Normal 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]);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
2
ts/window.d.ts
vendored
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue