[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]: a83581379d/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
This commit is contained in:
Jyoti Patel 2020-09-11 23:53:29 -04:00 committed by GitHub
parent 93b8680714
commit b6920770f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 569 additions and 4 deletions

View file

@ -268,6 +268,69 @@ exports[`channelInfo should match snapshot 1`] = `
}
}
/>
<Connect(NotificationPreference)
isLandscape={false}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
<Separator
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
<Connect(Pinned)
channelId="1234"
isLandscape={false}

View file

@ -15,6 +15,7 @@ import StatusBar from '@components/status_bar';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import AddMembers from './add_members';
import NotificationPreference from './notification_preference';
import Archive from './archive';
import ChannelInfoHeader from './channel_info_header';
import ConvertPrivate from './convert_private';
@ -141,6 +142,11 @@ export default class ChannelInfo extends PureComponent {
theme={theme}
/>
<Separator theme={theme}/>
<NotificationPreference
isLandscape={isLandscape}
theme={theme}
/>
<Separator theme={theme}/>
<Pinned
channelId={currentChannel.id}
isLandscape={isLandscape}

View file

@ -25,7 +25,7 @@ function createTouchableComponent(children, action) {
}
function channelInfoRow(props) {
const {action, defaultMessage, detail, icon, iconColor, image, imageTintColor, rightArrow, textColor, textId, togglable, theme, shouldRender, isLandscape} = props;
const {action, defaultMessage, detail, icon, iconSize, iconColor, image, imageTintColor, rightArrow, textColor, textId, togglable, theme, shouldRender, isLandscape} = props;
if (!shouldRender) {
return null;
@ -34,13 +34,16 @@ function channelInfoRow(props) {
const style = getStyleSheet(theme);
let iconElement = null;
let iSize = iconSize;
if (!iSize) {
iSize = 15;
}
if (image == null) {
iconElement = (
<Icon
name={icon}
size={15}
size={iSize}
color={iconColor || changeOpacity(theme.centerChannelColor, 0.5)}
style={style.leftIcon}
/>
);
} else {
@ -72,7 +75,9 @@ function channelInfoRow(props) {
const RowComponent = (
<View style={[style.container, padding(isLandscape)]}>
{iconElement}
<View style={style.iconContainer}>
{iconElement}
</View>
<FormattedText
style={[style.label, {color: textColor || theme.centerChannelColor}]}
id={textId}
@ -100,6 +105,7 @@ channelInfoRow.propTypes = {
]),
icon: PropTypes.string,
iconColor: PropTypes.string,
iconSize: PropTypes.number,
image: PropTypes.number,
imageTintColor: PropTypes.string,
isLandscape: PropTypes.bool,
@ -135,6 +141,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
fontSize: 15,
paddingVertical: 15,
},
iconContainer: {
width: 17,
height: 17,
justifyContent: 'center',
alignItems: 'center',
},
leftIcon: {
width: 17,
},

View file

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

View file

@ -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<NotificationPreferenceProps> {
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 (
<ChannelInfoRow
action={this.goToChannelNotificationPreference}
defaultMessage='Mobile Notifications'
detail={this.notificationLevelToText(pushNotifyLevel)}
icon='mobile'
iconSize={20}
textId={t('channel_header.notificationPreference')}
theme={theme}
isLandscape={isLandscape}
/>
);
}
}

View file

@ -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 (
<View style={style.container}>
<StatusBar/>
<FormattedText
id='channel_notifications.preference.header'
defaultMessage='Send Notifications'
style={style.header}
/>
<ScrollView
contentContainerStyle={style.wrapper}
alwaysBounceVertical={false}
>
<RadioButtonGroup
name='pushSettings'
onSelect={this.handlePress}
options={options}
/>
</ScrollView>
</View>
);
}
}
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,
},
};
});

View file

@ -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 (
<SafeAreaView
excludeHeader={true}
excludeFooter={true}
>
<View style={style.container}>
<StatusBar/>
<FormattedText
id='channel_notifications.preference.header'
defaultMessage='Send Notifications'
style={[style.header, padding(isLandscape)]}
/>
<ScrollView
contentContainerStyle={style.scrollView}
alwaysBounceVertical={false}
>
<View style={style.contentContainer}>
{items.map((item) => (
<View key={item.id}>
<View style={style.divider}/>
<SectionItem
label={(
<FormattedText
id={item.id}
defaultMessage={item.defaultMessage}
/>
)}
action={this.handlePress}
actionType='select'
actionValue={item.value}
selected={item.checked}
theme={theme}
isLandscape={isLandscape}
/>
</View>),
)}
<View style={style.divider}/>
</View>
</ScrollView>
</View>
</SafeAreaView>
);
}
}
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),
},
};
});

View file

@ -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(
<ChannelNotificationPreference
{...makeProps(pushNotificationLevel)}
/>,
);
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(
<ChannelNotificationPreference {...props}/>,
);
// 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},
);
});
});

View file

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

View file

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

View file

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

View file

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