about summary refs log tree commit diff
path: root/app
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
parent1761d3f9c33f3e2e98a09906fae1a03783b54b10 (diff)
Implement a click-to-view spoiler system
Diffstat (limited to 'app')
-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
-rw-r--r--app/controllers/api/v1/statuses_controller.rb2
-rw-r--r--app/helpers/atom_builder_helper.rb2
-rw-r--r--app/lib/formatter.rb17
-rw-r--r--app/models/status.rb4
-rw-r--r--app/services/post_status_service.rb4
-rw-r--r--app/services/process_hashtags_service.rb1
-rw-r--r--app/validators/status_length_validator.rb15
-rw-r--r--app/views/api/v1/statuses/_show.rabl2
15 files changed, 179 insertions, 9 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;
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index da87ebbad..0155b64a3 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -57,7 +57,7 @@ class Api::V1::StatusesController < ApiController
   end
 
   def create
-    @status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids], sensitive: params[:sensitive], visibility: params[:visibility], application: doorkeeper_token.application)
+    @status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids], sensitive: params[:sensitive], spoiler: params[:spoiler], spoiler_text: params[:spoiler_text], visibility: params[:visibility], application: doorkeeper_token.application)
     render action: :show
   end
 
diff --git a/app/helpers/atom_builder_helper.rb b/app/helpers/atom_builder_helper.rb
index 036a72166..7547e77e4 100644
--- a/app/helpers/atom_builder_helper.rb
+++ b/app/helpers/atom_builder_helper.rb
@@ -207,6 +207,7 @@ module AtomBuilderHelper
           end
 
           category(xml, 'nsfw') if stream_entry.target.sensitive?
+          category(xml, 'spoiler') if stream_entry.target.spoiler?
         end
       end
     end
@@ -228,6 +229,7 @@ module AtomBuilderHelper
     end
 
     category(xml, 'nsfw') if stream_entry.activity.sensitive?
+    category(xml, 'spoiler') if stream_entry.activity.spoiler?
   end
 
   private
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 3565611bc..ccdef0382 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -14,7 +14,15 @@ class Formatter
 
     html = status.text
     html = encode(html)
-    html = simple_format(html, sanitize: false)
+
+    if (status.spoiler?)
+      spoilerhtml = status.spoiler_text
+      spoilerhtml = encode(spoilerhtml)
+      html = wrap_spoilers(html, spoilerhtml)
+    else
+      html = simple_format(html, sanitize: false)
+    end
+
     html = html.gsub(/\n/, '')
     html = link_urls(html)
     html = link_mentions(html, status.mentions)
@@ -43,6 +51,13 @@ class Formatter
     HTMLEntities.new.encode(html)
   end
 
+  def wrap_spoilers(html, spoilerhtml)
+    spoilerhtml = simple_format(spoilerhtml, {class: "spoiler-helper"}, {sanitize: false})
+    html = simple_format(html, {class: ["spoiler", "spoiler-on"]}, {sanitize: false})
+
+    spoilerhtml + html
+  end
+
   def link_urls(html)
     html.gsub(URI.regexp(%w(http https))) do |match|
       link_html(match)
diff --git a/app/models/status.rb b/app/models/status.rb
index d5f52b55c..42abe92e5 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 class Status < ApplicationRecord
+  include ActiveModel::Validations
   include Paginable
   include Streamable
   include Cacheable
@@ -27,7 +28,8 @@ class Status < ApplicationRecord
 
   validates :account, presence: true
   validates :uri, uniqueness: true, unless: 'local?'
-  validates :text, presence: true, length: { maximum: 500 }, if: proc { |s| s.local? && !s.reblog? }
+  validates :text, presence: true, if: proc { |s| s.local? && !s.reblog? }
+  validates_with StatusLengthValidator
   validates :text, presence: true, if: proc { |s| !s.local? && !s.reblog? }
   validates :reblog, uniqueness: { scope: :account, message: 'of status already exists' }, if: 'reblog?'
 
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 8765ef5e3..ef8aa4a91 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -8,6 +8,8 @@ class PostStatusService < BaseService
   # @param [Hash] options
   # @option [Boolean] :sensitive
   # @option [String] :visibility
+  # @option [Boolean] :spoiler
+  # @option [String] :spoiler_text
   # @option [Enumerable] :media_ids Optional array of media IDs to attach
   # @option [Doorkeeper::Application] :application
   # @return [Status]
@@ -15,6 +17,8 @@ class PostStatusService < BaseService
     status = account.statuses.create!(text:        text,
                                       thread:      in_reply_to,
                                       sensitive:   options[:sensitive],
+                                      spoiler:   options[:spoiler],
+                                      spoiler_text:   options[:spoiler_text],
                                       visibility:  options[:visibility],
                                       application: options[:application])
 
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index 617a38159..9da7ef74e 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -9,5 +9,6 @@ class ProcessHashtagsService < BaseService
     end
 
     status.update(sensitive: true) if tags.include?('nsfw')
+    status.update(spoiler: true) if tags.include?('spoiler')
   end
 end
diff --git a/app/validators/status_length_validator.rb b/app/validators/status_length_validator.rb
new file mode 100644
index 000000000..5491d3d5f
--- /dev/null
+++ b/app/validators/status_length_validator.rb
@@ -0,0 +1,15 @@
+class StatusLengthValidator < ActiveModel::Validator
+  def validate(status)
+    if status.local? && !status.reblog?
+      combinedText = status.text
+      if (status.spoiler? && status.spoiler_text.present?)
+        combinedText = status.spoiler_text + "\n" + status.text
+      end
+
+      maxChars = 500
+      unless combinedText.length <= maxChars
+        status.errors[:text] << "is too long (maximum is #{maxChars})"
+      end
+    end
+  end
+end
\ No newline at end of file
diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl
index a3fc78763..8b54d5852 100644
--- a/app/views/api/v1/statuses/_show.rabl
+++ b/app/views/api/v1/statuses/_show.rabl
@@ -1,4 +1,4 @@
-attributes :id, :created_at, :in_reply_to_id, :sensitive, :visibility
+attributes :id, :created_at, :in_reply_to_id, :sensitive, :spoiler, :visibility
 
 node(:uri)              { |status| TagManager.instance.uri_for(status) }
 node(:content)          { |status| Formatter.instance.format(status) }