Merge branch 'blackle-master'
This commit is contained in:
		| @ -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,7 @@ 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_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 +221,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, | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PureRenderMixin from 'react-addons-pure-render-mixin'; | ||||
| import emojify from '../emoji'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
|  | ||||
| const StatusContent = React.createClass({ | ||||
|  | ||||
| @ -13,6 +14,12 @@ const StatusContent = React.createClass({ | ||||
|     onClick: React.PropTypes.func | ||||
|   }, | ||||
|  | ||||
|   getInitialState () { | ||||
|     return { | ||||
|       hidden: true | ||||
|     }; | ||||
|   }, | ||||
|  | ||||
|   mixins: [PureRenderMixin], | ||||
|  | ||||
|   componentDidMount () { | ||||
| @ -69,20 +76,40 @@ const StatusContent = React.createClass({ | ||||
|     this.startXY = null; | ||||
|   }, | ||||
|  | ||||
|   handleSpoilerClick () { | ||||
|     this.setState({ hidden: !this.state.hidden }); | ||||
|   }, | ||||
|  | ||||
|   render () { | ||||
|     const { status } = this.props; | ||||
|     const { hidden } = this.state; | ||||
|  | ||||
|     const content = { __html: emojify(status.get('content')) }; | ||||
|     const spoilerContent = { __html: emojify(status.get('spoiler_text')) }; | ||||
|  | ||||
|     return ( | ||||
|       <div | ||||
|         className='status__content' | ||||
|         style={{ cursor: 'pointer' }} | ||||
|         dangerouslySetInnerHTML={content} | ||||
|         onMouseDown={this.handleMouseDown} | ||||
|         onMouseUp={this.handleMouseUp} | ||||
|       /> | ||||
|     ); | ||||
|     if (status.get('spoiler_text').length > 0) { | ||||
|       const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />; | ||||
|  | ||||
|       return ( | ||||
|         <div className='status__content' style={{ cursor: 'pointer' }} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> | ||||
|           <p> | ||||
|             <span dangerouslySetInnerHTML={spoilerContent} /> <a onClick={this.handleSpoilerClick}>{toggleText}</a> | ||||
|           </p> | ||||
|  | ||||
|           <div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} /> | ||||
|         </div> | ||||
|       ); | ||||
|     } else { | ||||
|       return ( | ||||
|         <div | ||||
|           className='status__content' | ||||
|           style={{ cursor: 'pointer' }} | ||||
|           onMouseDown={this.handleMouseDown} | ||||
|           onMouseUp={this.handleMouseUp} | ||||
|           dangerouslySetInnerHTML={content} | ||||
|         /> | ||||
|       ); | ||||
|     } | ||||
|   }, | ||||
|  | ||||
| }); | ||||
|  | ||||
| @ -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_text, this.props.text].join('')} /></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 }}> | ||||
|  | ||||
| @ -12,7 +12,8 @@ const UploadForm = React.createClass({ | ||||
|   propTypes: { | ||||
|     media: ImmutablePropTypes.list.isRequired, | ||||
|     is_uploading: React.PropTypes.bool, | ||||
|     onRemoveFile: React.PropTypes.func.isRequired | ||||
|     onRemoveFile: React.PropTypes.func.isRequired, | ||||
|     intl: React.PropTypes.object.isRequired | ||||
|   }, | ||||
|  | ||||
|   mixins: [PureRenderMixin], | ||||
|  | ||||
| @ -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)); | ||||
|     }, | ||||
|  | ||||
| @ -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()); | ||||
| @ -90,64 +96,68 @@ const insertSuggestion = (state, position, token, completion) => { | ||||
|  | ||||
| export default function compose(state = initialState, action) { | ||||
|   switch(action.type) { | ||||
|     case STORE_HYDRATE: | ||||
|       return state.merge(action.state.get('compose')); | ||||
|     case COMPOSE_MOUNT: | ||||
|       return state.set('mounted', true); | ||||
|     case COMPOSE_UNMOUNT: | ||||
|       return state.set('mounted', false); | ||||
|     case COMPOSE_SENSITIVITY_CHANGE: | ||||
|       return state.set('sensitive', action.checked); | ||||
|     case COMPOSE_VISIBILITY_CHANGE: | ||||
|       return state.set('private', action.checked); | ||||
|     case COMPOSE_LISTABILITY_CHANGE: | ||||
|       return state.set('unlisted', action.checked); | ||||
|     case COMPOSE_CHANGE: | ||||
|       return state.set('text', action.text); | ||||
|     case COMPOSE_REPLY: | ||||
|       return state.withMutations(map => { | ||||
|         map.set('in_reply_to', action.status.get('id')); | ||||
|         map.set('text', statusToTextMentions(state, action.status)); | ||||
|       }); | ||||
|     case COMPOSE_REPLY_CANCEL: | ||||
|       return state.withMutations(map => { | ||||
|         map.set('in_reply_to', null); | ||||
|         map.set('text', ''); | ||||
|       }); | ||||
|     case COMPOSE_SUBMIT_REQUEST: | ||||
|       return state.set('is_submitting', true); | ||||
|     case COMPOSE_SUBMIT_SUCCESS: | ||||
|       return clearAll(state); | ||||
|     case COMPOSE_SUBMIT_FAIL: | ||||
|       return state.set('is_submitting', false); | ||||
|     case COMPOSE_UPLOAD_REQUEST: | ||||
|       return state.withMutations(map => { | ||||
|         map.set('is_uploading', true); | ||||
|         map.set('fileDropDate', new Date()); | ||||
|       }); | ||||
|     case COMPOSE_UPLOAD_SUCCESS: | ||||
|       return appendMedia(state, Immutable.fromJS(action.media)); | ||||
|     case COMPOSE_UPLOAD_FAIL: | ||||
|       return state.set('is_uploading', false); | ||||
|     case COMPOSE_UPLOAD_UNDO: | ||||
|       return removeMedia(state, action.media_id); | ||||
|     case COMPOSE_UPLOAD_PROGRESS: | ||||
|       return state.set('progress', Math.round((action.loaded / action.total) * 100)); | ||||
|     case COMPOSE_MENTION: | ||||
|       return state.update('text', text => `${text}@${action.account.get('acct')} `); | ||||
|     case COMPOSE_SUGGESTIONS_CLEAR: | ||||
|       return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null); | ||||
|     case COMPOSE_SUGGESTIONS_READY: | ||||
|       return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token); | ||||
|     case COMPOSE_SUGGESTION_SELECT: | ||||
|       return insertSuggestion(state, action.position, action.token, action.completion); | ||||
|     case TIMELINE_DELETE: | ||||
|       if (action.id === state.get('in_reply_to')) { | ||||
|         return state.set('in_reply_to', null); | ||||
|       } else { | ||||
|         return state; | ||||
|       } | ||||
|     default: | ||||
|   case STORE_HYDRATE: | ||||
|     return state.merge(action.state.get('compose')); | ||||
|   case COMPOSE_MOUNT: | ||||
|     return state.set('mounted', true); | ||||
|   case COMPOSE_UNMOUNT: | ||||
|     return state.set('mounted', false); | ||||
|   case COMPOSE_SENSITIVITY_CHANGE: | ||||
|     return state.set('sensitive', action.checked); | ||||
|   case COMPOSE_SPOILERNESS_CHANGE: | ||||
|     return (action.checked ? state : state.set('spoiler_text', '')).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: | ||||
|     return state.set('unlisted', action.checked); | ||||
|   case COMPOSE_CHANGE: | ||||
|     return state.set('text', action.text); | ||||
|   case COMPOSE_REPLY: | ||||
|     return state.withMutations(map => { | ||||
|       map.set('in_reply_to', action.status.get('id')); | ||||
|       map.set('text', statusToTextMentions(state, action.status)); | ||||
|     }); | ||||
|   case COMPOSE_REPLY_CANCEL: | ||||
|     return state.withMutations(map => { | ||||
|       map.set('in_reply_to', null); | ||||
|       map.set('text', ''); | ||||
|     }); | ||||
|   case COMPOSE_SUBMIT_REQUEST: | ||||
|     return state.set('is_submitting', true); | ||||
|   case COMPOSE_SUBMIT_SUCCESS: | ||||
|     return clearAll(state); | ||||
|   case COMPOSE_SUBMIT_FAIL: | ||||
|     return state.set('is_submitting', false); | ||||
|   case COMPOSE_UPLOAD_REQUEST: | ||||
|     return state.withMutations(map => { | ||||
|       map.set('is_uploading', true); | ||||
|       map.set('fileDropDate', new Date()); | ||||
|     }); | ||||
|   case COMPOSE_UPLOAD_SUCCESS: | ||||
|     return appendMedia(state, Immutable.fromJS(action.media)); | ||||
|   case COMPOSE_UPLOAD_FAIL: | ||||
|     return state.set('is_uploading', false); | ||||
|   case COMPOSE_UPLOAD_UNDO: | ||||
|     return removeMedia(state, action.media_id); | ||||
|   case COMPOSE_UPLOAD_PROGRESS: | ||||
|     return state.set('progress', Math.round((action.loaded / action.total) * 100)); | ||||
|   case COMPOSE_MENTION: | ||||
|     return state.update('text', text => `${text}@${action.account.get('acct')} `); | ||||
|   case COMPOSE_SUGGESTIONS_CLEAR: | ||||
|     return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null); | ||||
|   case COMPOSE_SUGGESTIONS_READY: | ||||
|     return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token); | ||||
|   case COMPOSE_SUGGESTION_SELECT: | ||||
|     return insertSuggestion(state, action.position, action.token, action.completion); | ||||
|   case TIMELINE_DELETE: | ||||
|     if (action.id === state.get('in_reply_to')) { | ||||
|       return state.set('in_reply_to', null); | ||||
|     } else { | ||||
|       return state; | ||||
|     } | ||||
|   default: | ||||
|     return state; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @ -597,21 +597,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; | ||||
| @ -622,6 +621,10 @@ | ||||
|   } | ||||
| } | ||||
|  | ||||
| .autosuggest-textarea__textarea { | ||||
|   height: 100px; | ||||
| } | ||||
|  | ||||
| .autosuggest-textarea__suggestions { | ||||
|   position: absolute; | ||||
|   top: 100%; | ||||
| @ -676,8 +679,42 @@ | ||||
|   } | ||||
| } | ||||
|  | ||||
| .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; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @import 'boost'; | ||||
|  | ||||
|  | ||||
| button i.fa-retweet { | ||||
|   height: 19px; | ||||
|   width: 22px; | ||||
|  | ||||
| @ -249,6 +249,7 @@ | ||||
|       padding: 5px; | ||||
|       border-radius: 100px; | ||||
|       color: rgba($color5, 0.8); | ||||
|       z-index: 1; | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @ -263,6 +264,7 @@ | ||||
|     flex-direction: column; | ||||
|     text-align: center; | ||||
|     transition: all 100ms linear; | ||||
|     z-index: 2; | ||||
|  | ||||
|     &:hover { | ||||
|       background: darken($color3, 5%); | ||||
|  | ||||
| @ -57,7 +57,12 @@ 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_text: params[:spoiler_text], | ||||
|                                                                                                                                                              visibility: params[:visibility], | ||||
|                                                                                                                                                              application: doorkeeper_token.application) | ||||
|  | ||||
|     render action: :show | ||||
|   end | ||||
|  | ||||
|  | ||||
| @ -41,8 +41,10 @@ module AtomBuilderHelper | ||||
|     xml['activity'].send('verb', TagManager::VERBS[verb]) | ||||
|   end | ||||
|  | ||||
|   def content(xml, content) | ||||
|     xml.content({ type: 'html' }, content) unless content.blank? | ||||
|   def content(xml, content, warning = nil) | ||||
|     extra = { type: 'html' } | ||||
|     extra[:warning] = warning unless warning.blank? | ||||
|     xml.content(extra, content) unless content.blank? | ||||
|   end | ||||
|  | ||||
|   def title(xml, title) | ||||
| @ -153,12 +155,20 @@ module AtomBuilderHelper | ||||
|     portable_contact xml, account | ||||
|   end | ||||
|  | ||||
|   def rich_content(xml, activity) | ||||
|     if activity.is_a?(Status) | ||||
|       content xml, conditionally_formatted(activity), activity.spoiler_text | ||||
|     else | ||||
|       content xml, conditionally_formatted(activity) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def include_entry(xml, stream_entry) | ||||
|     unique_id      xml, stream_entry.created_at, stream_entry.activity_id, stream_entry.activity_type | ||||
|     published_at   xml, stream_entry.created_at | ||||
|     updated_at     xml, stream_entry.updated_at | ||||
|     title          xml, stream_entry.title | ||||
|     content        xml, conditionally_formatted(stream_entry.activity) | ||||
|     rich_content   xml, stream_entry.activity | ||||
|     verb           xml, stream_entry.verb | ||||
|     link_self      xml, account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom') | ||||
|     link_alternate xml, account_stream_entry_url(stream_entry.account, stream_entry) | ||||
|  | ||||
| @ -14,7 +14,7 @@ class Formatter | ||||
|  | ||||
|     html = status.text | ||||
|     html = encode(html) | ||||
|     html = simple_format(html, sanitize: false) | ||||
|     html = simple_format(html, {}, sanitize: false) | ||||
|     html = html.gsub(/\n/, '') | ||||
|     html = link_urls(html) | ||||
|     html = link_mentions(html, status.mentions) | ||||
|  | ||||
							
								
								
									
										10
									
								
								app/lib/status_length_validator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								app/lib/status_length_validator.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class StatusLengthValidator < ActiveModel::Validator | ||||
|   MAX_CHARS = 500 | ||||
|  | ||||
|   def validate(status) | ||||
|     return unless status.local? && !status.reblog? | ||||
|     status.errors.add(:text, I18n.t('statuses.over_character_limit', max: MAX_CHARS)) if [status.text, status.spoiler_text].join.length > MAX_CHARS | ||||
|   end | ||||
| end | ||||
| @ -1,6 +1,7 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Status < ApplicationRecord | ||||
|   include ActiveModel::Validations | ||||
|   include Paginable | ||||
|   include Streamable | ||||
|   include Cacheable | ||||
| @ -27,8 +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 :text, presence: true, unless: 'reblog?' | ||||
|   validates_with StatusLengthValidator | ||||
|   validates :reblog, uniqueness: { scope: :account, message: 'of status already exists' }, if: 'reblog?' | ||||
|  | ||||
|   default_scope { order('id desc') } | ||||
| @ -174,6 +175,7 @@ class Status < ApplicationRecord | ||||
|  | ||||
|   before_validation do | ||||
|     text.strip! | ||||
|     spoiler_text&.strip! | ||||
|  | ||||
|     self.reblog                 = reblog.reblog if reblog? && reblog.reblog? | ||||
|     self.in_reply_to_account_id = (thread.account_id == account_id && thread.reply? ? thread.in_reply_to_account_id : thread.account_id) if reply? | ||||
|  | ||||
| @ -9,7 +9,7 @@ class FetchLinkCardService < BaseService | ||||
|  | ||||
|     response = http_client.get(url) | ||||
|  | ||||
|     return if response.code != 200 | ||||
|     return if response.code != 200 || response.mime_type != 'text/html' | ||||
|  | ||||
|     page = Nokogiri::HTML(response.to_s) | ||||
|     card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url) | ||||
| @ -18,6 +18,8 @@ class FetchLinkCardService < BaseService | ||||
|     card.description = meta_property(page, 'og:description') || meta_property(page, 'description') | ||||
|     card.image       = URI.parse(meta_property(page, 'og:image')) if meta_property(page, 'og:image') | ||||
|  | ||||
|     return if card.title.blank? | ||||
|  | ||||
|     card.save_with_optional_image! | ||||
|   end | ||||
|  | ||||
|  | ||||
| @ -8,14 +8,16 @@ class PostStatusService < BaseService | ||||
|   # @param [Hash] options | ||||
|   # @option [Boolean] :sensitive | ||||
|   # @option [String] :visibility | ||||
|   # @option [String] :spoiler_text | ||||
|   # @option [Enumerable] :media_ids Optional array of media IDs to attach | ||||
|   # @option [Doorkeeper::Application] :application | ||||
|   # @return [Status] | ||||
|   def call(account, text, in_reply_to = nil, options = {}) | ||||
|     status = account.statuses.create!(text:        text, | ||||
|                                       thread:      in_reply_to, | ||||
|                                       sensitive:   options[:sensitive], | ||||
|                                       visibility:  options[:visibility], | ||||
|     status = account.statuses.create!(text: text, | ||||
|                                       thread: in_reply_to, | ||||
|                                       sensitive: options[:sensitive], | ||||
|                                       spoiler_text: options[:spoiler_text], | ||||
|                                       visibility: options[:visibility], | ||||
|                                       application: options[:application]) | ||||
|  | ||||
|     attach_media(status, options[:media_ids]) | ||||
|  | ||||
| @ -103,6 +103,7 @@ class ProcessFeedService < BaseService | ||||
|         url: url(entry), | ||||
|         account: account, | ||||
|         text: content(entry), | ||||
|         spoiler_text: content_warning(entry), | ||||
|         created_at: published(entry) | ||||
|       ) | ||||
|  | ||||
| @ -223,6 +224,10 @@ class ProcessFeedService < BaseService | ||||
|       xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS).content | ||||
|     end | ||||
|  | ||||
|     def content_warning(xml = @xml) | ||||
|       xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS)['warning'] | ||||
|     end | ||||
|  | ||||
|     def published(xml = @xml) | ||||
|       xml.at_xpath('./xmlns:published', xmlns: TagManager::XMLNS).content | ||||
|     end | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| attributes :id, :created_at, :in_reply_to_id, :sensitive, :visibility | ||||
| attributes :id, :created_at, :in_reply_to_id, :sensitive, :spoiler_text, :visibility | ||||
|  | ||||
| node(:uri)              { |status| TagManager.instance.uri_for(status) } | ||||
| node(:content)          { |status| Formatter.instance.format(status) } | ||||
|  | ||||
| @ -7,7 +7,10 @@ | ||||
|       %strong.p-name.emojify= display_name(status.account) | ||||
|       %span.p-nickname= acct(status.account) | ||||
|  | ||||
|   .status__content.e-content.p-name.emojify= Formatter.instance.format(status) | ||||
|   .status__content.e-content.p-name.emojify< | ||||
|     - unless status.spoiler_text.blank? | ||||
|       %p= status.spoiler_text | ||||
|     = Formatter.instance.format(status) | ||||
|  | ||||
|   - unless status.media_attachments.empty? | ||||
|     - if status.media_attachments.first.video? | ||||
|  | ||||
| @ -12,7 +12,10 @@ | ||||
|         %strong.p-name.emojify= display_name(status.account) | ||||
|         %span.p-nickname= acct(status.account) | ||||
|  | ||||
|   .status__content.e-content.p-name.emojify= Formatter.instance.format(status) | ||||
|   .status__content.e-content.p-name.emojify< | ||||
|     - unless status.spoiler_text.blank? | ||||
|       %p= status.spoiler_text | ||||
|     = Formatter.instance.format(status) | ||||
|  | ||||
|   - unless status.media_attachments.empty? | ||||
|     .status__attachments | ||||
|  | ||||
| @ -93,6 +93,8 @@ en: | ||||
|     back: Back to Mastodon | ||||
|     edit_profile: Edit profile | ||||
|     preferences: Preferences | ||||
|   statuses: | ||||
|     over_character_limit: character limit of %{max} exceeded | ||||
|   stream_entries: | ||||
|     click_to_show: Click to show | ||||
|     favourited: favourited a post by | ||||
|  | ||||
| @ -0,0 +1,5 @@ | ||||
| class AddSpoilerTextToStatuses < ActiveRecord::Migration[5.0] | ||||
|   def change | ||||
|     add_column :statuses, :spoiler_text, :text, default: "" | ||||
|   end | ||||
| end | ||||
							
								
								
									
										82
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										82
									
								
								db/schema.rb
									
									
									
									
									
								
							| @ -173,87 +173,6 @@ ActiveRecord::Schema.define(version: 20170123203248) do | ||||
|     t.index ["status_id"], name: "index_preview_cards_on_status_id", unique: true, using: :btree | ||||
|   end | ||||
|  | ||||
|   create_table "pubsubhubbub_subscriptions", force: :cascade do |t| | ||||
|     t.string   "topic",      default: "",    null: false | ||||
|     t.string   "callback",   default: "",    null: false | ||||
|     t.string   "mode",       default: "",    null: false | ||||
|     t.string   "challenge",  default: "",    null: false | ||||
|     t.string   "secret" | ||||
|     t.boolean  "confirmed",  default: false, null: false | ||||
|     t.datetime "expires_at",                 null: false | ||||
|     t.datetime "created_at",                 null: false | ||||
|     t.datetime "updated_at",                 null: false | ||||
|     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 +195,7 @@ 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.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 | ||||
|  | ||||
		Reference in New Issue
	
	Block a user