Repository: busyorg/busy (js)

Issue:
Allow user to enable or disable NSFW content with a setting
#### Expected behavior

If a post is tagged NSFW it should not be shown by default. This can be configured by a setting in settings.

#### Actual behavior

Hard core porn is displayed on my work screen. 😮

#### How to reproduce

Go to the photography topic and scroll until your colleagues see hard core porn over your shoulder and report you to HR.

* Browser: Any
* Operating system: Any


Pull request description:
Add show NSFW Post option to Settings and hide posts tagged NSFW as default

![screen shot 2017-11-21 at 12 19 11 pm](https://user-images.githubusercontent.com/5067716/33087033-72e6d9e4-ceb7-11e7-8592-bd5a54dd5be3.png)
![screen shot 2017-11-21 at 12 19 20 pm](https://user-images.githubusercontent.com/5067716/33087034-7337531a-ceb7-11e7-8ea9-2398cecaef09.png)


Fixes #589.

Changes:
- Add showNSFWPosts to current user settings
- Hide NSFW post preview by default for all users
- Update Tests


Patch:
diff --git a/src/components/Story/NSFWStoryPreviewMessage.js b/src/components/Story/NSFWStoryPreviewMessage.js
new file mode 100644
index 00000000..370546fd
--- /dev/null
+++ b/src/components/Story/NSFWStoryPreviewMessage.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+const NSFWStoryPreviewMessage = ({ onClick }) => (
+  <div className="Story__warning__message">
+    <h4>
+      <FormattedMessage
+        id="post_preview_hidden_for_nsfw"
+        defaultMessage="This post preview is currently hidden since it is tagged NSFW."
+      />
+      <br />
+      <a role="presentation" onClick={onClick}>
+        <FormattedMessage id="display_post_preview" defaultMessage="Display post preview" />
+      </a>
+    </h4>
+  </div>
+);
+
+NSFWStoryPreviewMessage.propTypes = {
+  onClick: PropTypes.func.isRequired,
+};
+
+export default NSFWStoryPreviewMessage;
diff --git a/src/components/Story/Story.js b/src/components/Story/Story.js
index 4d2a3190..fe9eaa9d 100644
--- a/src/components/Story/Story.js
+++ b/src/components/Story/Story.js
@@ -10,10 +10,12 @@ import {
 import { Link } from 'react-router-dom';
 import { Tag, Icon, Popover, Tooltip } from 'antd';
 import { formatter } from 'steem';
+import { isPostTaggedNSFW } from '../../helpers/postHelpers';
 import StoryPreview from './StoryPreview';
 import StoryFooter from '../StoryFooter/StoryFooter';
 import Avatar from '../Avatar';
 import Topic from '../Button/Topic';
+import NSFWStoryPreviewMessage from './NSFWStoryPreviewMessage';
 import PopoverMenu, { PopoverMenuItem } from '../PopoverMenu/PopoverMenu';
 import HiddenStoryPreviewMessage from './HiddenStoryPreviewMessage';
 import './Story.less';
@@ -26,6 +28,7 @@ import './Story.less';
     postState: PropTypes.shape().isRequired,
     rewardFund: PropTypes.shape().isRequired,
     defaultVotePercent: PropTypes.number.isRequired,
+    showNSFWPosts: PropTypes.bool.isRequired,
     pendingLike: PropTypes.bool,
     pendingFollow: PropTypes.bool,
     pendingBookmark: PropTypes.bool,
@@ -60,6 +63,22 @@ import './Story.less';
     showHiddenStoryPreview: false,
   };
 
+  getDisplayStoryPreview = () => {
+    const { post, showNSFWPosts } = this.props;
+    const { showHiddenStoryPreview } = this.state;
+    const postAuthorReputation = formatter.reputation(post.author_reputation);
+
+    if (showHiddenStoryPreview) return true;
+
+    if (postAuthorReputation >= 0 && isPostTaggedNSFW(post)) {
+      return showNSFWPosts;
+    } else if (postAuthorReputation < 0) {
+      return false;
+    }
+
+    return true;
+  };
+
   handleClick = (key) => {
     const { post } = this.props;
 
@@ -104,9 +123,11 @@ import './Story.less';
       onShareClick,
       onEditClick,
     } = this.props;
-    const { showHiddenStoryPreview } = this.state;
     const postAuthorReputation = formatter.reputation(post.author_reputation);
-    const showStoryPreview = postAuthorReputation >= 0 || showHiddenStoryPreview;
+    const showStoryPreview = this.getDisplayStoryPreview();
+    const hiddenStoryPreviewMessage = isPostTaggedNSFW(post)
+      ? <NSFWStoryPreviewMessage onClick={this.handleShowStoryPreview} />
+      : <HiddenStoryPreviewMessage onClick={this.handleShowStoryPreview} />;
 
     let followText = '';
 
@@ -252,7 +273,7 @@ import './Story.less';
               ? <Link to={post.url} className="Story__content__preview">
                 <StoryPreview post={post} />
               </Link>
-              : <HiddenStoryPreviewMessage onClick={this.handleShowStoryPreview} />}
+              : hiddenStoryPreviewMessage}
           </div>
           <div className="Story__footer">
             <StoryFooter
diff --git a/src/feed/Feed.js b/src/feed/Feed.js
index dbb131ad..425dcf87 100644
--- a/src/feed/Feed.js
+++ b/src/feed/Feed.js
@@ -24,6 +24,7 @@ import {
   getVotingPower,
   getRewardFund,
   getVotePercent,
+  getShowNSFWPosts,
 } from '../reducers';
 
 import Story from '../components/Story/Story';
@@ -44,6 +45,7 @@ import './Feed.less';
     sliderMode: getVotingPower(state),
     rewardFund: getRewardFund(state),
     defaultVotePercent: getVotePercent(state),
+    showNSFWPosts: getShowNSFWPosts(state),
   }),
   {
     editPost,
@@ -68,6 +70,7 @@ export default class Feed extends React.Component {
     saving: PropTypes.bool.isRequired,
     rewardFund: PropTypes.shape().isRequired,
     defaultVotePercent: PropTypes.number.isRequired,
+    showNSFWPosts: PropTypes.bool.isRequired,
     isFetching: PropTypes.bool,
     hasMore: PropTypes.bool,
     sliderMode: PropTypes.oneOf(['on', 'off', 'auto']),
@@ -138,6 +141,7 @@ export default class Feed extends React.Component {
       sliderMode,
       rewardFund,
       defaultVotePercent,
+      showNSFWPosts,
     } = this.props;
 
     return (
@@ -184,6 +188,7 @@ export default class Feed extends React.Component {
               onSaveClick={this.handleSaveClick}
               onFollowClick={this.handleFollowClick}
               onEditClick={this.handleEditClick}
+              showNSFWPosts={showNSFWPosts}
             />
           );
         })}
diff --git a/src/helpers/postHelpers.js b/src/helpers/postHelpers.js
index 61c00cf8..298425ca 100644
--- a/src/helpers/postHelpers.js
+++ b/src/helpers/postHelpers.js
@@ -1,3 +1,15 @@
+import _ from 'lodash';
+
 export const isPostDeleted = post => post.title === 'deleted' && post.body === 'deleted';
 
+export const isPostTaggedNSFW = (post) => {
+  if (post.parent_permlink === 'nsfw') return true;
+
+  const postJSONMetaData = _.attempt(JSON.parse, post.json_metadata);
+
+  if (_.isError(postJSONMetaData)) return false;
+
+  return _.includes(postJSONMetaData.tags, 'nsfw');
+};
+
 export default null;
diff --git a/src/locales/en.json b/src/locales/en.json
index 67f0ffda..44978e88 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -254,5 +254,10 @@
   "search_activity_placeholder": "upvoted, downvoted, etc...",
   "deleted_comment": "Deleted comment ({link})",
   "general": "general",
-  "finance": "finance"
+  "finance": "finance",
+  "nsfw_posts": "NSFW Posts",
+  "display_nsfw_posts_details": "You can enable all posts tagged with NSFW to be shown as default.",
+  "display_nsfw_posts": "Display NSFW Posts",
+  "post_preview_hidden_for_nsfw": "This post preview is currently hidden since it is tagged NSFW.",
+  "display_post_preview": "Display post preview"
 }
diff --git a/src/reducers.js b/src/reducers.js
index 5fa93b5c..4538dbe3 100644
--- a/src/reducers.js
+++ b/src/reducers.js
@@ -93,6 +93,7 @@ export const getIsSettingsLoading = state => fromSettings.getIsLoading(state.set
 export const getLocale = state => fromSettings.getLocale(state.settings);
 export const getVotingPower = state => fromSettings.getVotingPower(state.settings);
 export const getVotePercent = state => fromSettings.getVotePercent(state.settings);
+export const getShowNSFWPosts = state => fromSettings.getShowNSFWPosts(state.settings);
 
 export const getTotalVestingShares = state => fromWallet.getTotalVestingShares(state.wallet);
 export const getTotalVestingFundSteem = state => fromWallet.getTotalVestingFundSteem(state.wallet);
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
index a21bb8e8..09e3151f 100644
--- a/src/settings/Settings.js
+++ b/src/settings/Settings.js
@@ -1,14 +1,16 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { connect } from 'react-redux';
+import _ from 'lodash';
 import { injectIntl, FormattedMessage } from 'react-intl';
-import { Select, Radio } from 'antd';
+import { Select, Radio, Checkbox } from 'antd';
 import {
   getIsReloading,
   getLocale,
   getVotingPower,
   getIsSettingsLoading,
   getVotePercent,
+  getShowNSFWPosts,
 } from '../reducers';
 import { saveSettings } from './settingsActions';
 import { reload } from '../auth/authActions';
@@ -27,6 +29,7 @@ import './Settings.less';
     locale: getLocale(state),
     votingPower: getVotingPower(state),
     votePercent: getVotePercent(state),
+    showNSFWPosts: getShowNSFWPosts(state),
     loading: getIsSettingsLoading(state),
   }),
   { reload, saveSettings, notify },
@@ -39,6 +42,7 @@ export default class Settings extends React.Component {
     votingPower: PropTypes.string,
     votePercent: PropTypes.number,
     loading: PropTypes.bool,
+    showNSFWPosts: PropTypes.bool,
     reload: PropTypes.func,
     saveSettings: PropTypes.func,
     notify: PropTypes.func,
@@ -50,6 +54,7 @@ export default class Settings extends React.Component {
     votingPower: 'auto',
     votePercent: 10000,
     loading: false,
+    showNSFWPosts: false,
     reload: () => {},
     saveSettings: () => {},
     notify: () => {},
@@ -59,6 +64,7 @@ export default class Settings extends React.Component {
     locale: 'auto',
     votingPower: 'auto',
     votePercent: 10000,
+    showNSFWPosts: null,
   };
 
   componentWillMount() {
@@ -85,6 +91,10 @@ export default class Settings extends React.Component {
     if (nextProps.votePercent !== this.props.votePercent) {
       this.setState({ votePercent: nextProps.votePercent / 100 });
     }
+
+    if (nextProps.showNSFWPosts !== this.props.showNSFWPosts) {
+      this.setState({ showNSFWPosts: nextProps.showNSFWPosts });
+    }
   }
 
   languages = {
@@ -137,6 +147,7 @@ export default class Settings extends React.Component {
         locale: this.state.locale,
         votingPower: this.state.votingPower,
         votePercent: this.state.votePercent * 100,
+        showNSFWPosts: this.state.showNSFWPosts,
       })
       .then(() =>
         this.props.notify(
@@ -149,15 +160,17 @@ export default class Settings extends React.Component {
   handleLocaleChange = locale => this.setState({ locale });
   handleVotingPowerChange = event => this.setState({ votingPower: event.target.value });
   handleVotePercentChange = value => this.setState({ votePercent: value });
+  handleShowNSFWPosts = event => this.setState({ showNSFWPosts: event.target.checked });
 
   render() {
     const {
       reloading,
       locale: initialLocale,
       votingPower: initialVotingPower,
+      showNSFWPosts: initialShowNSFWPosts,
       loading,
     } = this.props;
-    const { votingPower, locale } = this.state;
+    const { votingPower, locale, showNSFWPosts } = this.state;
 
     const languageOptions = [];
 
@@ -252,6 +265,30 @@ export default class Settings extends React.Component {
                     {languageOptions}
                   </Select>
                 </div>
+                <div className="Settings__section">
+                  <h3>
+                    <FormattedMessage id="nsfw_posts" defaultMessage="NSFW Posts" />
+                  </h3>
+                  <p>
+                    <FormattedMessage
+                      id="display_nsfw_posts_details"
+                      defaultMessage="You can enable all posts tagged with NSFW to be shown as default."
+                    />
+                  </p>
+                  <div className="Settings__section__nsfw">
+                    <Checkbox
+                      name="nsfw_posts"
+                      defaultChecked={initialShowNSFWPosts}
+                      checked={_.isBoolean(showNSFWPosts) ? showNSFWPosts : initialShowNSFWPosts}
+                      onChange={this.handleShowNSFWPosts}
+                    >
+                      <FormattedMessage
+                        id="display_nsfw_posts"
+                        defaultMessage="Display NSFW Posts"
+                      />
+                    </Checkbox>
+                  </div>
+                </div>
                 <Action
                   primary
                   loading={loading}
diff --git a/src/settings/Settings.less b/src/settings/Settings.less
index 52ede5ed..fc0c170c 100644
--- a/src/settings/Settings.less
+++ b/src/settings/Settings.less
@@ -26,6 +26,13 @@
     &__component {
       margin-top: 16px;
     }
+
+    &__nsfw {
+      .ant-checkbox-wrapper {
+        display: flex;
+        align-items: center;
+      }
+    }
   }
 
   .Action {
diff --git a/src/settings/settingsReducer.js b/src/settings/settingsReducer.js
index 9eb7c0f3..2b3f661f 100644
--- a/src/settings/settingsReducer.js
+++ b/src/settings/settingsReducer.js
@@ -5,6 +5,7 @@ const initialState = {
   locale: 'auto',
   votingPower: 'auto',
   votePercent: 10000,
+  showNSFWPosts: false,
   loading: false,
 };
 
@@ -16,10 +17,12 @@ const settings = (state = initialState, action) => {
         return {
           ...state,
           locale: action.payload.user_metadata.settings.locale || initialState.locale,
-          votingPower:
-            action.payload.user_metadata.settings.votingPower || initialState.votingPower,
-          votePercent:
-            action.payload.user_metadata.settings.votePercent || initialState.votePercent,
+          votingPower: action.payload.user_metadata.settings.votingPower ||
+            initialState.votingPower,
+          votePercent: action.payload.user_metadata.settings.votePercent ||
+            initialState.votePercent,
+          showNSFWPosts: action.payload.user_metadata.settings.showNSFWPosts ||
+            initialState.showNSFWPosts,
         };
       }
       return state;
@@ -36,6 +39,7 @@ const settings = (state = initialState, action) => {
         locale: action.payload.locale,
         votingPower: action.payload.votingPower,
         votePercent: action.payload.votePercent,
+        showNSFWPosts: action.payload.showNSFWPosts,
       };
     case settingsTypes.SAVE_SETTINGS_ERROR:
       return {
@@ -53,3 +57,4 @@ export const getIsLoading = state => state.loading;
 export const getLocale = state => state.locale;
 export const getVotingPower = state => state.votingPower;
 export const getVotePercent = state => state.votePercent;
+export const getShowNSFWPosts = state => state.showNSFWPosts;


Test patch:
diff --git a/src/settings/__tests__/settingsReducer.js b/src/settings/__tests__/settingsReducer.js
index 0e0bf07e..5e427f13 100644
--- a/src/settings/__tests__/settingsReducer.js
+++ b/src/settings/__tests__/settingsReducer.js
@@ -9,6 +9,7 @@ describe('settingsReducer', () => {
     votingPower: 'auto',
     votePercent: 10000,
     loading: false,
+    showNSFWPosts: false,
   };
 
   it('should return initial state', () => {
@@ -56,7 +57,7 @@ describe('settingsReducer', () => {
     expect(settingsReducer(stateBefore, action)).to.eql(stateAfter);
   });
 
-  it('should set locale, voting power, vote percent and loading after saving succeeded', () => {
+  it('should set locale, voting power, vote percent, loading, and showNSFWPosts after saving succeeded', () => {
     const stateBefore = {
       ...initialState,
       loading: true,
@@ -67,6 +68,7 @@ describe('settingsReducer', () => {
       locale: 'pl',
       votePercent: 10000,
       votingPower: 'on',
+      showNSFWPosts: true,
     };
     const action = {
       type: settingsTypes.SAVE_SETTINGS_SUCCESS,
@@ -74,6 +76,7 @@ describe('settingsReducer', () => {
         locale: 'pl',
         votingPower: 'on',
         votePercent: 10000,
+        showNSFWPosts: true,
       },
     };