diff options
author | blackle <isabelle@blackle-mori.com> | 2017-01-12 23:54:26 -0500 |
---|---|---|
committer | blackle <isabelle@blackle-mori.com> | 2017-01-23 21:07:40 -0500 |
commit | bf0f6eb62d0f5bd1f0d8e4e2a6e9e8fd3b297b6c (patch) | |
tree | c06ebcba34c5971d564beb98aa81d5d9784ec2c7 | |
parent | 1761d3f9c33f3e2e98a09906fae1a03783b54b10 (diff) |
Implement a click-to-view spoiler system
18 files changed, 192 insertions, 77 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) } diff --git a/db/migrate/20170112041538_add_spoiler_to_statuses.rb b/db/migrate/20170112041538_add_spoiler_to_statuses.rb new file mode 100644 index 000000000..3b46433f5 --- /dev/null +++ b/db/migrate/20170112041538_add_spoiler_to_statuses.rb @@ -0,0 +1,5 @@ +class AddSpoilerToStatuses < ActiveRecord::Migration[5.0] + def change + add_column :statuses, :spoiler, :boolean, default: false + end +end diff --git a/db/migrate/20170114014334_add_spoiler_text_to_statuses.rb b/db/migrate/20170114014334_add_spoiler_text_to_statuses.rb new file mode 100644 index 000000000..e4065d7db --- /dev/null +++ b/db/migrate/20170114014334_add_spoiler_text_to_statuses.rb @@ -0,0 +1,5 @@ +class AddSpoilerTextToStatuses < ActiveRecord::Migration[5.0] + def change + add_column :statuses, :spoiler_text, :text, default: "" + end +end diff --git a/db/schema.rb b/db/schema.rb index 3876faa56..f228da01e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -186,74 +186,6 @@ ActiveRecord::Schema.define(version: 20170123203248) do t.index ["topic", "callback"], name: "index_pubsubhubbub_subscriptions_on_topic_and_callback", unique: true, using: :btree end - create_table "push_devices", force: :cascade do |t| - t.string "service", default: "", null: false - t.string "token", default: "", null: false - t.integer "account", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["service", "token"], name: "index_push_devices_on_service_and_token", unique: true, using: :btree - end - - create_table "rpush_apps", force: :cascade do |t| - t.string "name", null: false - t.string "environment" - t.text "certificate" - t.string "password" - t.integer "connections", default: 1, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "type", null: false - t.string "auth_key" - t.string "client_id" - t.string "client_secret" - t.string "access_token" - t.datetime "access_token_expiration" - end - - create_table "rpush_feedback", force: :cascade do |t| - t.string "device_token", limit: 64, null: false - t.datetime "failed_at", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "app_id" - t.index ["device_token"], name: "index_rpush_feedback_on_device_token", using: :btree - end - - create_table "rpush_notifications", force: :cascade do |t| - t.integer "badge" - t.string "device_token", limit: 64 - t.string "sound", default: "default" - t.text "alert" - t.text "data" - t.integer "expiry", default: 86400 - t.boolean "delivered", default: false, null: false - t.datetime "delivered_at" - t.boolean "failed", default: false, null: false - t.datetime "failed_at" - t.integer "error_code" - t.text "error_description" - t.datetime "deliver_after" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "alert_is_json", default: false - t.string "type", null: false - t.string "collapse_key" - t.boolean "delay_while_idle", default: false, null: false - t.text "registration_ids" - t.integer "app_id", null: false - t.integer "retries", default: 0 - t.string "uri" - t.datetime "fail_after" - t.boolean "processing", default: false, null: false - t.integer "priority" - t.text "url_args" - t.string "category" - t.boolean "content_available", default: false - t.text "notification" - t.index ["delivered", "failed"], name: "index_rpush_notifications_multi", where: "((NOT delivered) AND (NOT failed))", using: :btree - end - create_table "settings", force: :cascade do |t| t.string "var", null: false t.text "value" @@ -276,6 +208,9 @@ ActiveRecord::Schema.define(version: 20170123203248) do t.boolean "sensitive", default: false t.integer "visibility", default: 0, null: false t.integer "in_reply_to_account_id" + t.string "conversation_uri" + t.boolean "spoiler", default: false + t.text "spoiler_text", default: "" t.integer "application_id" t.index ["account_id"], name: "index_statuses_on_account_id", using: :btree t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id", using: :btree |