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