diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 536472d64..79b6f6c12 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1022,6 +1022,22 @@ "description": "Conversation menu option to enable disappearing messages", "androidKey": "conversation_expiring_off__disappearing_messages" }, + "notificationForConvo": { + "message": "Notifications", + "description": "Conversation menu option to change the notification setting for this conversation" + }, + "notificationForConvo_all": { + "message": "All", + "description": "Menu item to allow notification for this conversation for all messages" + }, + "notificationForConvo_disabled": { + "message": "Disabled", + "description": "Menu item to deny notification for this conversation for all messages" + }, + "notificationForConvo_mentions_only": { + "message": "Mentions only", + "description": "Menu item to allow notification for this conversation for all messages mentioning us" + }, "changeNickname": { "message": "Change Nickname", "description": "Conversation menu option to change user nickname" diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 7e0f433cf..d06b06e18 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -15,12 +15,18 @@ import { } from '../session/menu/ConversationHeaderMenu'; import { contextMenu } from 'react-contexify'; import { DefaultTheme, withTheme } from 'styled-components'; +import { ConversationNotificationSettingType } from '../../models/conversation'; export interface TimerOption { name: string; value: number; } +export interface NotificationForConvoOption { + name: string; + value: ConversationNotificationSettingType; +} + interface Props { id: string; name?: string; @@ -46,6 +52,8 @@ interface Props { expirationSettingName?: string; showBackButton: boolean; timerOptions: Array; + notificationForConvo: Array; + currentNotificationSetting: ConversationNotificationSettingType; hasNickname?: boolean; isBlocked: boolean; @@ -56,6 +64,7 @@ interface Props { onInviteContacts: () => void; onSetDisappearingMessages: (seconds: number) => void; + onSetNotificationForConvo: (selected: ConversationNotificationSettingType) => void; onDeleteMessages: () => void; onDeleteContact: () => void; onChangeNickname?: () => void; diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 6223f6c43..cb8b7ab52 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -724,21 +724,10 @@ class MessageInner extends React.PureComponent { const width = this.getWidth(); const isShowingImage = this.isShowingImage(); - // We parse the message later, but we still need to do an early check - // to see if the message mentions us, so we can display the entire - // message differently - const regex = new RegExp(`@${PubKey.regexForPubkeys}`, 'g'); - const mentions = (text ? text.match(regex) : []) as Array; - const mentionMe = mentions && mentions.some(m => UserUtils.isUsFromCache(m.slice(1))); - const isIncoming = direction === 'incoming'; - const shouldHightlight = mentionMe && isIncoming && isPublic; const shouldMarkReadWhenVisible = isIncoming && isUnread; const divClasses = ['session-message-wrapper']; - if (shouldHightlight) { - //divClasses.push('message-highlighted'); - } if (selected) { divClasses.push('message-selected'); } diff --git a/ts/components/session/conversation/SessionConversation.tsx b/ts/components/session/conversation/SessionConversation.tsx index 61c6efcce..b791a060b 100644 --- a/ts/components/session/conversation/SessionConversation.tsx +++ b/ts/components/session/conversation/SessionConversation.tsx @@ -30,7 +30,11 @@ import { getMessageById, getPubkeysInPublicConversation } from '../../../data/da import autoBind from 'auto-bind'; import { getDecryptedMediaUrl } from '../../../session/crypto/DecryptedAttachmentsManager'; import { deleteOpenGroupMessages } from '../../../interactions/conversation'; -import { ConversationTypeEnum } from '../../../models/conversation'; +import { + ConversationNotificationSetting, + ConversationNotificationSettingType, + ConversationTypeEnum, +} from '../../../models/conversation'; import { updateMentionsMembers } from '../../../state/ducks/mentionsInput'; import { sendDataExtractionNotification } from '../../../session/messages/outgoing/controlMessage/DataExtractionNotificationMessage'; @@ -347,6 +351,14 @@ export class SessionConversation extends React.Component { const members = conversation.get('members') || []; + // exclude mentions_only settings for private chats as this does not make much sense + const notificationForConvo = ConversationNotificationSetting.filter(n => + conversation.isPrivate() ? n !== 'mentions_only' : true + ).map((n: ConversationNotificationSettingType) => { + // this link to the notificationForConvo_all, notificationForConvo_mentions_only, ... + return { value: n, name: window.i18n(`notificationForConvo_${n}`) }; + }); + const headerProps = { id: conversation.id, name: conversation.getName(), @@ -369,10 +381,13 @@ export class SessionConversation extends React.Component { name: item.getName(), value: item.get('seconds'), })), + notificationForConvo, + currentNotificationSetting: conversation.get('triggerNotificationsFor'), hasNickname: !!conversation.getNickname(), selectionMode: !!selectedMessages.length, onSetDisappearingMessages: conversation.updateExpirationTimer, + onSetNotificationForConvo: conversation.setNotificationOption, onDeleteMessages: conversation.deleteMessages, onDeleteSelectedMessages: this.deleteSelectedMessages, onChangeNickname: conversation.changeNickname, diff --git a/ts/components/session/menu/ConversationHeaderMenu.tsx b/ts/components/session/menu/ConversationHeaderMenu.tsx index 8e3dd71a5..fbe7d1f8c 100644 --- a/ts/components/session/menu/ConversationHeaderMenu.tsx +++ b/ts/components/session/menu/ConversationHeaderMenu.tsx @@ -12,10 +12,12 @@ import { getInviteContactMenuItem, getLeaveGroupMenuItem, getMarkAllReadMenuItem, + getNotificationForConvoMenuItem, getRemoveModeratorsMenuItem, getUpdateGroupNameMenuItem, } from './Menu'; -import { TimerOption } from '../../conversation/ConversationHeader'; +import { NotificationForConvoOption, TimerOption } from '../../conversation/ConversationHeader'; +import { ConversationNotificationSettingType } from '../../../models/conversation'; export type PropsConversationHeaderMenu = { triggerId: string; @@ -26,6 +28,8 @@ export type PropsConversationHeaderMenu = { isGroup: boolean; isAdmin: boolean; timerOptions: Array; + notificationForConvo: Array; + currentNotificationSetting: ConversationNotificationSettingType; isPrivate: boolean; isBlocked: boolean; hasNickname?: boolean; @@ -45,6 +49,7 @@ export type PropsConversationHeaderMenu = { onBlockUser: () => void; onUnblockUser: () => void; onSetDisappearingMessages: (seconds: number) => void; + onSetNotificationForConvo: (selected: ConversationNotificationSettingType) => void; }; export const ConversationHeaderMenu = (props: PropsConversationHeaderMenu) => { @@ -60,6 +65,8 @@ export const ConversationHeaderMenu = (props: PropsConversationHeaderMenu) => { isPrivate, left, hasNickname, + notificationForConvo, + currentNotificationSetting, onClearNickname, onChangeNickname, @@ -75,6 +82,7 @@ export const ConversationHeaderMenu = (props: PropsConversationHeaderMenu) => { onBlockUser, onUnblockUser, onSetDisappearingMessages, + onSetNotificationForConvo, } = props; return ( @@ -88,6 +96,15 @@ export const ConversationHeaderMenu = (props: PropsConversationHeaderMenu) => { onSetDisappearingMessages, window.i18n )} + {getNotificationForConvoMenuItem( + isKickedFromGroup, + left, + isBlocked, + notificationForConvo, + currentNotificationSetting, + onSetNotificationForConvo, + window.i18n + )} {getBlockMenuItem(isMe, isPrivate, isBlocked, onBlockUser, onUnblockUser, window.i18n)} {getCopyMenuItem(isPublic, isGroup, onCopyPublicKey, window.i18n)} diff --git a/ts/components/session/menu/Menu.tsx b/ts/components/session/menu/Menu.tsx index 99bc6a4ba..064fae02b 100644 --- a/ts/components/session/menu/Menu.tsx +++ b/ts/components/session/menu/Menu.tsx @@ -1,7 +1,11 @@ import React from 'react'; import { LocalizerType } from '../../../types/Util'; -import { TimerOption } from '../../conversation/ConversationHeader'; +import { NotificationForConvoOption, TimerOption } from '../../conversation/ConversationHeader'; import { Item, Submenu } from 'react-contexify'; +import { + ConversationNotificationSetting, + ConversationNotificationSettingType, +} from '../../../models/conversation'; function showTimerOptions( isPublic: boolean, @@ -12,6 +16,14 @@ function showTimerOptions( return !isPublic && !left && !isKickedFromGroup && !isBlocked; } +function showNotificationConvo( + isKickedFromGroup: boolean, + left: boolean, + isBlocked: boolean +): boolean { + return !left && !isKickedFromGroup && !isBlocked; +} + function showMemberMenu(isPublic: boolean, isGroup: boolean): boolean { return !isPublic && isGroup; } @@ -223,6 +235,41 @@ export function getDisappearingMenuItem( return null; } +export function getNotificationForConvoMenuItem( + isKickedFromGroup: boolean | undefined, + left: boolean | undefined, + isBlocked: boolean | undefined, + notificationForConvoOptions: Array, + currentNotificationSetting: ConversationNotificationSettingType, + action: (selected: ConversationNotificationSettingType) => any, + i18n: LocalizerType +): JSX.Element | null { + if (showNotificationConvo(Boolean(isKickedFromGroup), Boolean(left), Boolean(isBlocked))) { + // const isRtlMode = isRtlBody(); + return ( + // Remove the && false to make context menu work with RTL support + + {(notificationForConvoOptions || []).map(item => ( + // tslint:disable-next-line: use-simple-attributes + { + action(item.value); + }} + disabled={item.value === currentNotificationSetting} + > + {item.name} + + ))} + + ); + } + return null; +} + export function isRtlBody(): boolean { return ($('body') as any).hasClass('rtl'); } diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 1233801d3..f94f2db90 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -40,12 +40,21 @@ import { ConversationInteraction } from '../interactions'; import { OpenGroupVisibleMessage } from '../session/messages/outgoing/visibleMessage/OpenGroupVisibleMessage'; import { OpenGroupRequestCommonType } from '../opengroup/opengroupV2/ApiUtil'; import { getOpenGroupV2FromConversationId } from '../opengroup/utils/OpenGroupUtils'; +import { NotificationForConvoOption } from '../components/conversation/ConversationHeader'; export enum ConversationTypeEnum { GROUP = 'group', PRIVATE = 'private', } +/** + * all: all notifications enabled, the default + * disabled: no notifications at all + * mentions_only: trigger a notification only on mentions of ourself + */ +export const ConversationNotificationSetting = ['all', 'disabled', 'mentions_only'] as const; +export type ConversationNotificationSettingType = typeof ConversationNotificationSetting[number]; + export interface ConversationAttributes { profileName?: string; id: string; @@ -81,6 +90,7 @@ export interface ConversationAttributes { profileAvatar?: any; profileKey?: string; accessKey?: any; + triggerNotificationsFor: ConversationNotificationSettingType; } export interface ConversationAttributesOptionals { @@ -116,6 +126,7 @@ export interface ConversationAttributesOptionals { profileAvatar?: any; profileKey?: string; accessKey?: any; + triggerNotificationsFor?: ConversationNotificationSettingType; } /** @@ -143,6 +154,7 @@ export const fillConvoAttributesWithDefaults = ( expireTimer: 0, mentionedUs: false, active_at: 0, + triggerNotificationsFor: 'all', // if the settings is not set in the db, this is the default }); }; @@ -185,6 +197,10 @@ export class ConversationModel extends Backbone.Model { return this.id; } + if (this.isPublic()) { + return `opengroup(${this.id})`; + } + return `group(${this.id})`; } @@ -777,6 +793,14 @@ export class ConversationModel extends Backbone.Model { } } + public async setNotificationOption(selected: ConversationNotificationSettingType) { + const existingSettings = this.get('triggerNotificationsFor'); + if (existingSettings !== selected) { + this.set({ triggerNotificationsFor: selected }); + await this.commit(); + } + } + public async updateExpirationTimer( providedExpireTimer: any, providedSource?: string, @@ -1402,6 +1426,25 @@ export class ConversationModel extends Backbone.Model { } const conversationId = this.id; + // make sure the notifications are not muted for this convo (and not the source convo) + const convNotif = this.get('triggerNotificationsFor'); + if (convNotif === 'disabled') { + window?.log?.info('notifications disabled for convo', this.idForLogging()); + return; + } + if (convNotif === 'mentions_only') { + // check if the message has ourselves as mentions + const regex = new RegExp(`@${PubKey.regexForPubkeys}`, 'g'); + const text = message.get('body'); + const mentions = text?.match(regex) || ([] as Array); + const mentionMe = mentions && mentions.some(m => UserUtils.isUsFromCache(m.slice(1))); + if (!mentionMe) { + window?.log?.info('notifications disabled for non mentions for convo', conversationId); + + return; + } + } + const convo = await ConversationController.getInstance().getOrCreateAndWait( message.get('source'), ConversationTypeEnum.PRIVATE diff --git a/ts/test/test-utils/utils/message.ts b/ts/test/test-utils/utils/message.ts index 26905f778..96f4e3d0d 100644 --- a/ts/test/test-utils/utils/message.ts +++ b/ts/test/test-utils/utils/message.ts @@ -86,6 +86,7 @@ export class MockConversation { lastMessageStatus: null, lastMessage: null, zombies: [], + triggerNotificationsFor: 'all', }; }