about summary refs log tree commit diff
path: root/app/assets
diff options
context:
space:
mode:
authorblackle <isabelle@blackle-mori.com>2017-01-12 23:54:26 -0500
committerblackle <isabelle@blackle-mori.com>2017-01-23 21:07:40 -0500
commitbf0f6eb62d0f5bd1f0d8e4e2a6e9e8fd3b297b6c (patch)
treec06ebcba34c5971d564beb98aa81d5d9784ec2c7 /app/assets
parent1761d3f9c33f3e2e98a09906fae1a03783b54b10 (diff)
Implement a click-to-view spoiler system
Diffstat (limited to 'app/assets')
-rw-r--r--app/assets/javascripts/components/actions/compose.jsx18
-rw-r--r--app/assets/javascripts/components/components/status_content.jsx18
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx29
-rw-r--r--app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx12
-rw-r--r--app/assets/javascripts/components/reducers/compose.jsx10
-rw-r--r--app/assets/javascripts/extras.jsx10
-rw-r--r--app/assets/stylesheets/components.scss44
7 files changed, 136 insertions, 5 deletions
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx
index 05674ba89..948ccf872 100644
--- a/app/assets/javascripts/components/actions/compose.jsx
+++ b/app/assets/javascripts/components/actions/compose.jsx
@@ -23,6 +23,8 @@ export const COMPOSE_MOUNT   = 'COMPOSE_MOUNT';
 export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
 
 export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
+export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
+export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
 export const COMPOSE_VISIBILITY_CHANGE  = 'COMPOSE_VISIBILITY_CHANGE';
 export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
 
@@ -68,6 +70,8 @@ export function submitCompose() {
       in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
       media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
       sensitive: getState().getIn(['compose', 'sensitive']),
+      spoiler: getState().getIn(['compose', 'spoiler']),
+      spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
       visibility: getState().getIn(['compose', 'private']) ? 'private' : (getState().getIn(['compose', 'unlisted']) ? 'unlisted' : 'public')
     }).then(function (response) {
       dispatch(submitComposeSuccess({ ...response.data }));
@@ -218,6 +222,20 @@ export function changeComposeSensitivity(checked) {
   };
 };
 
+export function changeComposeSpoilerness(checked) {
+  return {
+    type: COMPOSE_SPOILERNESS_CHANGE,
+    checked
+  };
+};
+
+export function changeComposeSpoilerText(text) {
+  return {
+    type: COMPOSE_SPOILER_TEXT_CHANGE,
+    text
+  };
+};
+
 export function changeComposeVisibility(checked) {
   return {
     type: COMPOSE_VISIBILITY_CHANGE,
diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx
index f2c88cee0..7287aa836 100644
--- a/app/assets/javascripts/components/components/status_content.jsx
+++ b/app/assets/javascripts/components/components/status_content.jsx
@@ -18,6 +18,12 @@ const StatusContent = React.createClass({
   componentDidMount () {
     const node  = ReactDOM.findDOMNode(this);
     const links = node.querySelectorAll('a');
+    const spoilers = node.querySelectorAll('.spoiler');
+
+    for (var i = 0; i < spoilers.length; ++i) {
+      let spoiler    = spoilers[i];
+      spoiler.addEventListener('click', this.onSpoilerClick.bind(this, spoiler), true);
+    }
 
     for (var i = 0; i < links.length; ++i) {
       let link    = links[i];
@@ -52,6 +58,18 @@ const StatusContent = React.createClass({
     }
   },
 
+  onSpoilerClick (spoiler, e) {
+    if (e.button === 0) {
+      //only toggle if we're not clicking a visible link
+      var hasClass = $(spoiler).hasClass('spoiler-on');
+      if (hasClass || e.target === spoiler) {
+        e.stopPropagation();
+        e.preventDefault();
+        $(spoiler).siblings(".spoiler").andSelf().toggleClass('spoiler-on', !hasClass);
+      }
+    }
+  },
+
   onNormalClick (e) {
     e.stopPropagation();
   },
diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
index 80cb38e16..84d273299 100644
--- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
@@ -14,6 +14,7 @@ import { Motion, spring } from 'react-motion';
 
 const messages = defineMessages({
   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
+  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning' },
   publish: { id: 'compose_form.publish', defaultMessage: 'Publish' }
 });
 
@@ -25,6 +26,8 @@ const ComposeForm = React.createClass({
     suggestion_token: React.PropTypes.string,
     suggestions: ImmutablePropTypes.list,
     sensitive: React.PropTypes.bool,
+    spoiler: React.PropTypes.bool,
+    spoiler_text: React.PropTypes.string,
     unlisted: React.PropTypes.bool,
     private: React.PropTypes.bool,
     fileDropDate: React.PropTypes.instanceOf(Date),
@@ -40,6 +43,8 @@ const ComposeForm = React.createClass({
     onFetchSuggestions: React.PropTypes.func.isRequired,
     onSuggestionSelected: React.PropTypes.func.isRequired,
     onChangeSensitivity: React.PropTypes.func.isRequired,
+    onChangeSpoilerness: React.PropTypes.func.isRequired,
+    onChangeSpoilerText: React.PropTypes.func.isRequired,
     onChangeVisibility: React.PropTypes.func.isRequired,
     onChangeListability: React.PropTypes.func.isRequired,
   },
@@ -77,6 +82,15 @@ const ComposeForm = React.createClass({
     this.props.onChangeSensitivity(e.target.checked);
   },
 
+  handleChangeSpoilerness (e) {
+    this.props.onChangeSpoilerness(e.target.checked);
+    this.props.onChangeSpoilerText('');
+  },
+
+  handleChangeSpoilerText (e) {
+    this.props.onChangeSpoilerText(e.target.value);
+  },
+
   handleChangeVisibility (e) {
     this.props.onChangeVisibility(e.target.checked);
   },
@@ -115,6 +129,14 @@ const ComposeForm = React.createClass({
 
     return (
       <div style={{ padding: '10px' }}>
+        <Motion defaultStyle={{ opacity: !this.props.spoiler ? 0 : 100, height: !this.props.spoiler ? 50 : 0 }} style={{ opacity: spring(!this.props.spoiler ? 0 : 100), height: spring(!this.props.spoiler ? 0 : 50) }}>
+          {({ opacity, height }) =>
+            <div className="spoiler-input" style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
+              <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} type="text" className="spoiler-input__input" />
+            </div>
+          }
+        </Motion>
+
         {replyArea}
 
         <AutosuggestTextarea
@@ -133,7 +155,7 @@ const ComposeForm = React.createClass({
 
         <div style={{ marginTop: '10px', overflow: 'hidden' }}>
           <div style={{ float: 'right' }}><Button text={intl.formatMessage(messages.publish)} onClick={this.handleSubmit} disabled={disabled} /></div>
-          <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.text} /></div>
+          <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.spoiler ? (this.props.spoiler_text + "\n" + this.props.text) : this.props.text} /></div>
           <UploadButtonContainer style={{ paddingTop: '4px' }} />
         </div>
 
@@ -142,6 +164,11 @@ const ComposeForm = React.createClass({
           <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
         </label>
 
+        <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle' }}>
+          <Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} />
+          <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide behind content warning' /></span>
+        </label>
+
         <Motion defaultStyle={{ opacity: (this.props.private || reply_to_other) ? 0 : 100, height: (this.props.private || reply_to_other) ? 39.5 : 0 }} style={{ opacity: spring((this.props.private || reply_to_other) ? 0 : 100), height: spring((this.props.private || reply_to_other) ? 0 : 39.5) }}>
           {({ opacity, height }) =>
             <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
index 1b5a506d5..8ccfce059 100644
--- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
@@ -8,6 +8,8 @@ import {
   fetchComposeSuggestions,
   selectComposeSuggestion,
   changeComposeSensitivity,
+  changeComposeSpoilerness,
+  changeComposeSpoilerText,
   changeComposeVisibility,
   changeComposeListability
 } from '../../../actions/compose';
@@ -22,6 +24,8 @@ const makeMapStateToProps = () => {
       suggestion_token: state.getIn(['compose', 'suggestion_token']),
       suggestions: state.getIn(['compose', 'suggestions']),
       sensitive: state.getIn(['compose', 'sensitive']),
+      spoiler: state.getIn(['compose', 'spoiler']),
+      spoiler_text: state.getIn(['compose', 'spoiler_text']),
       unlisted: state.getIn(['compose', 'unlisted']),
       private: state.getIn(['compose', 'private']),
       fileDropDate: state.getIn(['compose', 'fileDropDate']),
@@ -66,6 +70,14 @@ const mapDispatchToProps = function (dispatch) {
       dispatch(changeComposeSensitivity(checked));
     },
 
+    onChangeSpoilerness (checked) {
+      dispatch(changeComposeSpoilerness(checked));
+    },
+
+    onChangeSpoilerText (checked) {
+      dispatch(changeComposeSpoilerText(checked));
+    },
+
     onChangeVisibility (checked) {
       dispatch(changeComposeVisibility(checked));
     },
diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx
index 2df50c45b..1c6c3d4f4 100644
--- a/app/assets/javascripts/components/reducers/compose.jsx
+++ b/app/assets/javascripts/components/reducers/compose.jsx
@@ -17,6 +17,8 @@ import {
   COMPOSE_SUGGESTIONS_READY,
   COMPOSE_SUGGESTION_SELECT,
   COMPOSE_SENSITIVITY_CHANGE,
+  COMPOSE_SPOILERNESS_CHANGE,
+  COMPOSE_SPOILER_TEXT_CHANGE,
   COMPOSE_VISIBILITY_CHANGE,
   COMPOSE_LISTABILITY_CHANGE
 } from '../actions/compose';
@@ -27,6 +29,8 @@ import Immutable from 'immutable';
 const initialState = Immutable.Map({
   mounted: false,
   sensitive: false,
+  spoiler: false,
+  spoiler_text: '',
   unlisted: false,
   private: false,
   text: '',
@@ -56,6 +60,8 @@ function statusToTextMentions(state, status) {
 function clearAll(state) {
   return state.withMutations(map => {
     map.set('text', '');
+    map.set('spoiler', false);
+    map.set('spoiler_text', '');
     map.set('is_submitting', false);
     map.set('in_reply_to', null);
     map.update('media_attachments', list => list.clear());
@@ -98,6 +104,10 @@ export default function compose(state = initialState, action) {
       return state.set('mounted', false);
     case COMPOSE_SENSITIVITY_CHANGE:
       return state.set('sensitive', action.checked);
+    case COMPOSE_SPOILERNESS_CHANGE:
+      return state.set('spoiler', action.checked);
+    case COMPOSE_SPOILER_TEXT_CHANGE:
+      return state.set('spoiler_text', action.text);
     case COMPOSE_VISIBILITY_CHANGE:
       return state.set('private', action.checked);
     case COMPOSE_LISTABILITY_CHANGE:
diff --git a/app/assets/javascripts/extras.jsx b/app/assets/javascripts/extras.jsx
index 5738863dd..5784d17c2 100644
--- a/app/assets/javascripts/extras.jsx
+++ b/app/assets/javascripts/extras.jsx
@@ -14,6 +14,16 @@ $(() => {
     }
   });
 
+  $.each($('.spoiler'), (_, content) => {
+    $(content).on('click', e => {
+      var hasClass = $(content).hasClass('spoiler-on');
+      if (hasClass || e.target === content) {
+        e.preventDefault();
+        $(content).siblings(".spoiler").andSelf().toggleClass('spoiler-on', !hasClass);
+      }
+    });
+  });
+
   $('.media-spoiler').on('click', e => {
     $(e.target).hide();
   });
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index 73d1acccd..681259861 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -584,21 +584,20 @@
   }
 }
 
-.autosuggest-textarea {
+.autosuggest-textarea, .spoiler-input {
   position: relative;
 }
 
-.autosuggest-textarea__textarea {
+.autosuggest-textarea__textarea, .spoiler-input__input {
   display: block;
   box-sizing: border-box;
   width: 100%;
-  height: 100px;
   resize: none;
+  margin: 0;
   color: $color1;
   padding: 7px;
   font-family: inherit;
   font-size: 14px;
-  margin: 0;
   resize: vertical;
 
   border: 3px dashed transparent;
@@ -609,6 +608,10 @@
   }
 }
 
+.autosuggest-textarea__textarea {
+  height: 100px;
+}
+
 .autosuggest-textarea__suggestions {
   position: absolute;
   top: 100%;
@@ -663,6 +666,39 @@
   }
 }
 
+.spoiler-helper {
+  margin-bottom: -20px !important;
+}
+
+.spoiler {
+  &::before {
+    margin-top: 20px;
+    display: block;
+    content: '';
+  }
+
+  display: inline;
+  cursor: pointer;
+  border-bottom: 1px dashed white;
+  .light & {
+    border-bottom: 1px dashed black;
+  }
+
+  &.spoiler-on {
+    &, & * {
+      color: transparent !important;
+    }
+    background: white;
+    .light & {
+      background: black;
+    }
+
+    .emojione {
+      opacity: 0;
+    }
+  }
+}
+
 button i.fa-retweet {
   height: 19px;
   width: 22px;