From b6920770f19f2a6079f48eb279d5db9dd7ffb78e Mon Sep 17 00:00:00 2001 From: Jyoti Patel <36148363+jp0707@users.noreply.github.com> Date: Fri, 11 Sep 2020 23:53:29 -0400 Subject: [PATCH] [GH-15185][MM-5973] Add Channel Settings > Notification Preferences. (#4730) * Add Channel Settings > Notification Preferences. Fixes #15185 * Make newly added strings mmjstool compatible. mmjstool seems to be looking for specific patterns in AST like called with `formatMessage` and `t()` [1]. Looks like `t()` is just for that purpose. [1]: https://github.com/mattermost/mattermost-utilities/blob/a83581379d0aac3502f412b100e261ab28e2dd38/mmjstool/src/i18n_extract.js#L97:L100 * Pass only required props to NotificationPreference & ChannelNotificationPreference. * Fix style issues/nits from code review. * Use radio button for channel notifications on android, and checkmarks on iOS. Also changes style according to figma spec. * Fix missing prop error. For some reason, `npm run fix` didn't catch this. But `npm run check` did during CircleCI. * Fix missing i18n strings by passing strings through `t()`. mmjstool really needs to be smarter to understand these types of cases. * Address comments from UX code review. * Remove remnant from merge conflict. * Fix UI in iOS landscape mode. * Use SafeArea view for ios landscape view * Use paddingHorizontal for SafeArea view for iOS landscape mode --- .../__snapshots__/channel_info.test.js.snap | 63 ++++++++++++ app/screens/channel_info/channel_info.js | 6 ++ app/screens/channel_info/channel_info_row.js | 20 +++- .../notification_preference/index.js | 19 ++++ .../notification_preference.tsx | 88 +++++++++++++++++ ...channel_notification_preference.android.js | 80 ++++++++++++++++ .../channel_notification_preference.ios.js | 95 +++++++++++++++++++ .../channel_notification_preference.test.js | 69 ++++++++++++++ .../channel_notification_preference_base.js | 95 +++++++++++++++++++ .../channel_notification_preference/index.js | 26 +++++ app/screens/index.js | 1 + assets/base/i18n/en.json | 11 +++ 12 files changed, 569 insertions(+), 4 deletions(-) create mode 100644 app/screens/channel_info/notification_preference/index.js create mode 100644 app/screens/channel_info/notification_preference/notification_preference.tsx create mode 100644 app/screens/channel_notification_preference/channel_notification_preference.android.js create mode 100644 app/screens/channel_notification_preference/channel_notification_preference.ios.js create mode 100644 app/screens/channel_notification_preference/channel_notification_preference.test.js create mode 100644 app/screens/channel_notification_preference/channel_notification_preference_base.js create mode 100644 app/screens/channel_notification_preference/index.js diff --git a/app/screens/channel_info/__snapshots__/channel_info.test.js.snap b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap index 899c6c738..dfb411f45 100644 --- a/app/screens/channel_info/__snapshots__/channel_info.test.js.snap +++ b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap @@ -268,6 +268,69 @@ exports[`channelInfo should match snapshot 1`] = ` } } /> + + + + ); } else { @@ -72,7 +75,9 @@ function channelInfoRow(props) { const RowComponent = ( - {iconElement} + + {iconElement} + { fontSize: 15, paddingVertical: 15, }, + iconContainer: { + width: 17, + height: 17, + justifyContent: 'center', + alignItems: 'center', + }, leftIcon: { width: 17, }, diff --git a/app/screens/channel_info/notification_preference/index.js b/app/screens/channel_info/notification_preference/index.js new file mode 100644 index 000000000..a9b5f75df --- /dev/null +++ b/app/screens/channel_info/notification_preference/index.js @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {getMyCurrentChannelMembership} from '@mm-redux/selectors/entities/channels'; + +import NotificationPreference from './notification_preference'; + +function mapStateToProps(state) { + const channelMember = getMyCurrentChannelMembership(state); + + return { + channelId: channelMember?.channel_id, + userId: channelMember?.user_id, + notifyProps: channelMember?.notify_props, + }; +} + +export default connect(mapStateToProps)(NotificationPreference); diff --git a/app/screens/channel_info/notification_preference/notification_preference.tsx b/app/screens/channel_info/notification_preference/notification_preference.tsx new file mode 100644 index 000000000..bf5920919 --- /dev/null +++ b/app/screens/channel_info/notification_preference/notification_preference.tsx @@ -0,0 +1,88 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import {intlShape} from 'react-intl'; + +import {goToScreen} from '@actions/navigation'; +import {ViewTypes} from '@constants'; +import {ChannelNotifyProps} from '@mm-redux/types/channels'; +import {Theme} from '@mm-redux/types/preferences'; +import ChannelInfoRow from '@screens/channel_info/channel_info_row'; +import {t} from '@utils/i18n'; +import {preventDoubleTap} from '@utils/tap'; + +interface NotificationPreferenceProps { + channelId: string; + userId: string; + notifyProps: ChannelNotifyProps; + isLandscape: boolean; + theme: Theme; +} + +export default class NotificationPreference extends PureComponent { + static contextTypes = { + intl: intlShape.isRequired, + }; + + goToChannelNotificationPreference = preventDoubleTap(() => { + const {intl} = this.context; + const screen = 'ChannelNotificationPreference'; + const title = intl.formatMessage({id: 'channel_header.notificationPreference', defaultMessage: 'Mobile Notifications'}); + + goToScreen(screen, title, this.props); + }); + + notificationLevelToText = (notifyLevel: string) => { + const {intl} = this.context; + + let textId = ''; + let defaultMsg = ''; + switch (notifyLevel) { + case ViewTypes.NotificationLevels.DEFAULT: { + textId = t('channel_header.notificationPreference.default'); + defaultMsg = 'Default'; + break; + } + case ViewTypes.NotificationLevels.ALL: { + textId = t('channel_header.notificationPreference.all'); + defaultMsg = 'All'; + break; + } + case ViewTypes.NotificationLevels.MENTION: { + textId = t('channel_header.notificationPreference.mention'); + defaultMsg = 'Mentions'; + break; + } + case ViewTypes.NotificationLevels.NONE: { + textId = t('channel_header.notificationPreference.none'); + defaultMsg = 'Never'; + break; + } + } + + return intl.formatMessage({id: textId, defaultMessage: defaultMsg}); + } + + render() { + const {isLandscape, theme, notifyProps, userId, channelId} = this.props; + const pushNotifyLevel = notifyProps.push || ViewTypes.NotificationLevels.DEFAULT; + + if (!userId || !channelId) { + return null; + } + + return ( + + ); + } +} diff --git a/app/screens/channel_notification_preference/channel_notification_preference.android.js b/app/screens/channel_notification_preference/channel_notification_preference.android.js new file mode 100644 index 000000000..b5407a2b4 --- /dev/null +++ b/app/screens/channel_notification_preference/channel_notification_preference.android.js @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import { + ScrollView, + View, +} from 'react-native'; + +import FormattedText from 'app/components/formatted_text'; +import RadioButtonGroup from 'app/components/radio_button'; +import StatusBar from 'app/components/status_bar'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; +import ChannelNotificationPreferenceBase from './channel_notification_preference_base'; + +export default class ChannelNotificationPreferenceAndroid extends ChannelNotificationPreferenceBase { + getRadioItems = () => { + const {intl} = this.context; + const items = this.getItems(); + const radioItems = []; + items.forEach((element) => { + const e = { + label: intl.formatMessage({ + id: element.id, + defaultMessage: element.defaultMessage, + }), + value: element.value, + checked: element.checked, + }; + radioItems.push(e); + }); + return radioItems; + } + + render() { + const {theme} = this.props; + const style = getStyleSheet(theme); + + const options = this.getRadioItems(); + + return ( + + + + + + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + container: { + flex: 1, + backgroundColor: changeOpacity(theme.centerChannelBg, 0.03), + }, + wrapper: { + marginLeft: 16, + }, + header: { + color: changeOpacity(theme.centerChannelColor, 0.56), + fontSize: 13, + marginTop: 10, + padding: 16, + }, + }; +}); diff --git a/app/screens/channel_notification_preference/channel_notification_preference.ios.js b/app/screens/channel_notification_preference/channel_notification_preference.ios.js new file mode 100644 index 000000000..8f8ffb1f3 --- /dev/null +++ b/app/screens/channel_notification_preference/channel_notification_preference.ios.js @@ -0,0 +1,95 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import { + ScrollView, + View, +} from 'react-native'; +import SafeAreaView from 'app/components/safe_area_view'; +import FormattedText from 'app/components/formatted_text'; +import StatusBar from 'app/components/status_bar'; +import SectionItem from 'app/screens/settings/section_item'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; +import {paddingHorizontal as padding} from 'app/components/safe_area_view/iphone_x_spacing'; +import ChannelNotificationPreferenceBase from './channel_notification_preference_base'; + +export default class ChannelNotificationPreferenceIos extends ChannelNotificationPreferenceBase { + render() { + const {theme, isLandscape} = this.props; + const style = getStyleSheet(theme); + + const items = this.getItems(); + + return ( + + + + + + + {items.map((item) => ( + + + + )} + action={this.handlePress} + actionType='select' + actionValue={item.value} + selected={item.checked} + theme={theme} + isLandscape={isLandscape} + /> + ), + )} + + + + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + container: { + flex: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.03), + }, + header: { + color: changeOpacity(theme.centerChannelColor, 0.56), + fontSize: 13, + textTransform: 'uppercase', + marginTop: 10, + padding: 16, + }, + divider: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + height: 1, + width: '100%', + marginLeft: 16, + }, + scrollView: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.03), + }, + contentContainer: { + backgroundColor: changeOpacity(theme.centerChannelBg, 1), + }, + }; +}); diff --git a/app/screens/channel_notification_preference/channel_notification_preference.test.js b/app/screens/channel_notification_preference/channel_notification_preference.test.js new file mode 100644 index 000000000..dc57735ee --- /dev/null +++ b/app/screens/channel_notification_preference/channel_notification_preference.test.js @@ -0,0 +1,69 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; + +import {ViewTypes} from '@constants'; +import Preferences from '@mm-redux/constants/preferences'; +import SectionItem from '@screens/settings/section_item'; + +import {shallowWithIntl} from 'test/intl-test-helper'; +import ChannelNotificationPreference from './channel_notification_preference'; + +function makeProps(pushNotificationLevel) { + return { + actions: { + updateChannelNotifyProps: jest.fn(), + }, + channelId: 'channel_id', + userId: 'user_id', + notifyProps: { + push: pushNotificationLevel, + }, + theme: Preferences.THEMES.default, + isLandscape: false, + }; +} + +function checkNotificationSelected(pushNotificationLevel, trueIdx) { + const wrapper = shallowWithIntl( + , + ); + + const sectionItems = wrapper.find(SectionItem); + + expect(sectionItems.exists()).toBe(true); + expect(sectionItems.length).toBe(4); + + sectionItems.forEach((sectionItem, idx) => { + expect(sectionItem.prop('selected')).toBe(idx === trueIdx); + }); +} + +describe('ChannelNotificationPreference', () => { + test('should have correct setting selected', () => { + checkNotificationSelected(null, 0); + checkNotificationSelected(ViewTypes.NotificationLevels.DEFAULT, 0); + checkNotificationSelected(ViewTypes.NotificationLevels.ALL, 1); + checkNotificationSelected(ViewTypes.NotificationLevels.MENTION, 2); + checkNotificationSelected(ViewTypes.NotificationLevels.NONE, 3); + }); + + test('should save on click', () => { + const props = makeProps('default'); + const wrapper = shallowWithIntl( + , + ); + + // click on 'Never' -- last item + wrapper.find(SectionItem).at(3).dive().simulate('press'); + + expect(props.actions.updateChannelNotifyProps).toHaveBeenCalledTimes(1); + expect(props.actions.updateChannelNotifyProps).toBeCalledWith( + props.userId, + props.channelId, + {push: ViewTypes.NotificationLevels.NONE}, + ); + }); +}); diff --git a/app/screens/channel_notification_preference/channel_notification_preference_base.js b/app/screens/channel_notification_preference/channel_notification_preference_base.js new file mode 100644 index 000000000..f36c25c22 --- /dev/null +++ b/app/screens/channel_notification_preference/channel_notification_preference_base.js @@ -0,0 +1,95 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {intlShape} from 'react-intl'; + +import {ViewTypes} from '@constants'; +import {alertErrorWithFallback} from 'app/utils/general'; +import {t} from '@utils/i18n'; +import {preventDoubleTap} from 'app/utils/tap'; + +export default class ChannelNotificationPreferenceBase extends PureComponent { + static propTypes = { + actions: PropTypes.shape({ + updateChannelNotifyProps: PropTypes.func.isRequired, + }), + channelId: PropTypes.string.isRequired, + isLandscape: PropTypes.bool.isRequired, + notifyProps: PropTypes.object.isRequired, + theme: PropTypes.object.isRequired, + userId: PropTypes.string.isRequired, + }; + + static contextTypes = { + intl: intlShape.isRequired, + }; + + constructor(props) { + super(props); + + this.state = { + notificationLevel: props.notifyProps?.push || ViewTypes.NotificationLevels.DEFAULT, + }; + } + + getItems = () => { + const {notificationLevel} = this.state; + return [{ + id: t('channel_notifications.preference.global_default'), + defaultMessage: 'Global default (Mentions)', + value: ViewTypes.NotificationLevels.DEFAULT, + checked: notificationLevel === ViewTypes.NotificationLevels.DEFAULT, + }, { + id: t('channel_notifications.preference.all_activity'), + defaultMessage: 'For all activity', + value: ViewTypes.NotificationLevels.ALL, + checked: notificationLevel === ViewTypes.NotificationLevels.ALL, + }, { + id: t('channel_notifications.preference.only_mentions'), + defaultMessage: 'Only mentions and direct messages', + value: ViewTypes.NotificationLevels.MENTION, + checked: notificationLevel === ViewTypes.NotificationLevels.MENTION, + }, { + id: t('channel_notifications.preference.never'), + defaultMessage: 'Never', + value: ViewTypes.NotificationLevels.NONE, + checked: notificationLevel === ViewTypes.NotificationLevels.NONE, + }]; + } + + handlePress = preventDoubleTap(async (newNotificationLevel) => { + const {actions, channelId, userId} = this.props; + const {notificationLevel} = this.state; + + if (newNotificationLevel === notificationLevel) { + // tapped on current selection. + return; + } + + this.setState({ + notificationLevel: newNotificationLevel, + }); + + const props = {push: newNotificationLevel}; + + const {error} = await actions.updateChannelNotifyProps(userId, channelId, props); + if (error) { + const {intl} = this.context; + alertErrorWithFallback( + intl, + error, + { + id: t('channel_notifications.preference.save_error'), + defaultMessage: "We couldn't save notification preference. Please check your connection and try again.", + }, + ); + + // restore old value. + this.setState({ + notificationLevel, + }); + } + }); +} diff --git a/app/screens/channel_notification_preference/index.js b/app/screens/channel_notification_preference/index.js new file mode 100644 index 000000000..d8ac34547 --- /dev/null +++ b/app/screens/channel_notification_preference/index.js @@ -0,0 +1,26 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; +import {updateChannelNotifyProps} from '@mm-redux/actions/channels'; +import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import {isLandscape} from 'app/selectors/device'; + +import ChannelNotificationPreference from './channel_notification_preference'; + +function mapStateToProps(state) { + const theme = getTheme(state); + return { + theme, + isLandscape: isLandscape(state), + }; +} + +const mapDispatchToProps = (dispatch) => ({ + actions: bindActionCreators({ + updateChannelNotifyProps, + }, dispatch), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ChannelNotificationPreference); diff --git a/app/screens/index.js b/app/screens/index.js index 735102747..7157a2411 100644 --- a/app/screens/index.js +++ b/app/screens/index.js @@ -33,6 +33,7 @@ export function registerScreens(store, Provider) { Navigation.registerComponent('ChannelAddMembers', () => wrapper(require('app/screens/channel_add_members').default), () => require('app/screens/channel_add_members').default); Navigation.registerComponent('ChannelInfo', () => wrapper(require('app/screens/channel_info').default), () => require('app/screens/channel_info').default); Navigation.registerComponent('ChannelMembers', () => wrapper(require('app/screens/channel_members').default), () => require('app/screens/channel_members').default); + Navigation.registerComponent('ChannelNotificationPreference', () => wrapper(require('app/screens/channel_notification_preference').default), () => require('app/screens/channel_notification_preference').default); Navigation.registerComponent('ClientUpgrade', () => wrapper(require('app/screens/client_upgrade').default), () => require('app/screens/client_upgrade').default); Navigation.registerComponent('ClockDisplaySettings', () => wrapper(require('app/screens/settings/clock_display').default), () => require('app/screens/settings/clock_display').default); Navigation.registerComponent('Code', () => wrapper(require('app/screens/code').default), () => require('app/screens/code').default); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 096dcbe5e..7051bc052 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -17,6 +17,11 @@ "channel_header.addMembers": "Add Members", "channel_header.directchannel.you": "{displayname} (you) ", "channel_header.manageMembers": "Manage Members", + "channel_header.notificationPreference": "Mobile Notifications", + "channel_header.notificationPreference.all": "All", + "channel_header.notificationPreference.default": "Default", + "channel_header.notificationPreference.mention": "Mentions", + "channel_header.notificationPreference.none": "Never", "channel_header.pinnedPosts": "Pinned Messages", "channel_header.viewMembers": "View Members", "channel_info.header": "Header:", @@ -35,6 +40,12 @@ "channel_modal.purposeEx": "E.g.: \"A channel to file bugs and improvements\"", "channel_notifications.ignoreChannelMentions.settings": "Ignore @channel, @here, @all", "channel_notifications.muteChannel.settings": "Mute channel", + "channel_notifications.preference.all_activity": "For all activity", + "channel_notifications.preference.global_default": "Global default (Mentions)", + "channel_notifications.preference.header": "Send Notifications", + "channel_notifications.preference.never": "Never", + "channel_notifications.preference.only_mentions": "Only mentions and direct messages", + "channel_notifications.preference.save_error": "We couldn't save notification preference. Please check your connection and try again.", "channel.channelHasGuests": "This channel has guests", "channel.hasGuests": "This group message has guests", "channel.isGuest": "This person is a guest",