Merge branch 'master' into french-translation
This commit is contained in:
		
						commit
						f447649d25
					
				
					 43 changed files with 533 additions and 172 deletions
				
			
		
							
								
								
									
										5
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								Gemfile
									
									
									
									
									
								
							|  | @ -50,11 +50,6 @@ gem 'react-rails' | |||
| gem 'browserify-rails' | ||||
| gem 'autoprefixer-rails' | ||||
| 
 | ||||
| gem 'rack-mini-profiler', require: false | ||||
| gem 'flamegraph' | ||||
| gem 'stackprof' | ||||
| gem 'memory_profiler' | ||||
| 
 | ||||
| group :development, :test do | ||||
|   gem 'rspec-rails' | ||||
|   gem 'pry-rails' | ||||
|  |  | |||
|  | @ -136,7 +136,6 @@ GEM | |||
|     execjs (2.7.0) | ||||
|     fabrication (2.15.2) | ||||
|     fast_blank (1.0.0) | ||||
|     flamegraph (0.9.5) | ||||
|     font-awesome-rails (4.6.3.1) | ||||
|       railties (>= 3.2, < 5.1) | ||||
|     fuubar (2.1.1) | ||||
|  | @ -206,7 +205,6 @@ GEM | |||
|       nokogiri (>= 1.5.9) | ||||
|     mail (2.6.4) | ||||
|       mime-types (>= 1.16, < 4) | ||||
|     memory_profiler (0.9.7) | ||||
|     method_source (0.8.2) | ||||
|     mime-types (3.1) | ||||
|       mime-types-data (~> 3.2015) | ||||
|  | @ -264,8 +262,6 @@ GEM | |||
|     rack-attack (5.0.1) | ||||
|       rack | ||||
|     rack-cors (0.4.0) | ||||
|     rack-mini-profiler (0.10.1) | ||||
|       rack (>= 1.2.0) | ||||
|     rack-protection (1.5.3) | ||||
|       rack | ||||
|     rack-test (0.6.3) | ||||
|  | @ -376,7 +372,6 @@ GEM | |||
|       actionpack (>= 4.0) | ||||
|       activesupport (>= 4.0) | ||||
|       sprockets (>= 3.0.0) | ||||
|     stackprof (0.2.10) | ||||
|     temple (0.7.7) | ||||
|     term-ansicolor (1.4.0) | ||||
|       tins (~> 1.0) | ||||
|  | @ -425,7 +420,6 @@ DEPENDENCIES | |||
|   dotenv-rails | ||||
|   fabrication | ||||
|   fast_blank | ||||
|   flamegraph | ||||
|   font-awesome-rails | ||||
|   fuubar | ||||
|   goldfinger | ||||
|  | @ -441,7 +435,6 @@ DEPENDENCIES | |||
|   letter_opener | ||||
|   link_header | ||||
|   lograge | ||||
|   memory_profiler | ||||
|   neography | ||||
|   nokogiri | ||||
|   oj | ||||
|  | @ -456,7 +449,6 @@ DEPENDENCIES | |||
|   rabl | ||||
|   rack-attack | ||||
|   rack-cors | ||||
|   rack-mini-profiler | ||||
|   rails! | ||||
|   rails_12factor | ||||
|   rails_autolink | ||||
|  | @ -471,7 +463,6 @@ DEPENDENCIES | |||
|   sidekiq | ||||
|   simple_form | ||||
|   simplecov | ||||
|   stackprof | ||||
|   uglifier (>= 1.3.0) | ||||
|   webmock | ||||
|   will_paginate | ||||
|  |  | |||
|  | @ -246,7 +246,8 @@ export function blockAccount(id) { | |||
|     dispatch(blockAccountRequest(id)); | ||||
| 
 | ||||
|     api(getState).post(`/api/v1/accounts/${id}/block`).then(response => { | ||||
|       dispatch(blockAccountSuccess(response.data)); | ||||
|       // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers | ||||
|       dispatch(blockAccountSuccess(response.data, getState().get('statuses'))); | ||||
|     }).catch(error => { | ||||
|       dispatch(blockAccountFail(id, error)); | ||||
|     }); | ||||
|  | @ -272,10 +273,11 @@ export function blockAccountRequest(id) { | |||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function blockAccountSuccess(relationship) { | ||||
| export function blockAccountSuccess(relationship, statuses) { | ||||
|   return { | ||||
|     type: ACCOUNT_BLOCK_SUCCESS, | ||||
|     relationship | ||||
|     relationship, | ||||
|     statuses | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -22,6 +22,8 @@ export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; | |||
| export const COMPOSE_MOUNT   = 'COMPOSE_MOUNT'; | ||||
| export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; | ||||
| 
 | ||||
| export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; | ||||
| 
 | ||||
| export function changeCompose(text) { | ||||
|   return { | ||||
|     type: COMPOSE_CHANGE, | ||||
|  | @ -62,7 +64,8 @@ export function submitCompose() { | |||
|     api(getState).post('/api/v1/statuses', { | ||||
|       status: getState().getIn(['compose', 'text'], ''), | ||||
|       in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), | ||||
|       media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')) | ||||
|       media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')), | ||||
|       sensitive: getState().getIn(['compose', 'sensitive']) | ||||
|     }).then(function (response) { | ||||
|       dispatch(submitComposeSuccess(response.data)); | ||||
|       dispatch(updateTimeline('home', response.data)); | ||||
|  | @ -197,3 +200,10 @@ export function unmountCompose() { | |||
|     type: COMPOSE_UNMOUNT | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function changeComposeSensitivity(checked) { | ||||
|   return { | ||||
|     type: COMPOSE_SENSITIVITY_CHANGE, | ||||
|     checked | ||||
|   }; | ||||
| }; | ||||
|  |  | |||
|  | @ -7,7 +7,14 @@ const Button = React.createClass({ | |||
|     onClick: React.PropTypes.func, | ||||
|     disabled: React.PropTypes.bool, | ||||
|     block: React.PropTypes.bool, | ||||
|     secondary: React.PropTypes.bool | ||||
|     secondary: React.PropTypes.bool, | ||||
|     size: React.PropTypes.number, | ||||
|   }, | ||||
| 
 | ||||
|   getDefaultProps () { | ||||
|     return { | ||||
|       size: 36 | ||||
|     }; | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
|  | @ -32,16 +39,16 @@ const Button = React.createClass({ | |||
|       fontWeight: '500', | ||||
|       letterSpacing: '0', | ||||
|       textTransform: 'uppercase', | ||||
|       padding: '0 16px', | ||||
|       height: '36px', | ||||
|       padding: `0 ${this.props.size / 2.25}px`, | ||||
|       height: `${this.props.size}px`, | ||||
|       cursor: 'pointer', | ||||
|       lineHeight: '36px', | ||||
|       lineHeight: `${this.props.size}px`, | ||||
|       borderRadius: '4px', | ||||
|       textDecoration: 'none' | ||||
|     }; | ||||
|      | ||||
| 
 | ||||
|     return ( | ||||
|       <button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={style}> | ||||
|       <button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={{ ...style, ...this.props.style }}> | ||||
|         {this.props.text || this.props.children} | ||||
|       </button> | ||||
|     ); | ||||
|  |  | |||
|  | @ -1,9 +1,47 @@ | |||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PureRenderMixin    from 'react-addons-pure-render-mixin'; | ||||
| import PureRenderMixin from 'react-addons-pure-render-mixin'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| const outerStyle = { | ||||
|   marginTop: '8px', | ||||
|   overflow: 'hidden', | ||||
|   width: '100%', | ||||
|   boxSizing: 'border-box' | ||||
| }; | ||||
| 
 | ||||
| const spoilerStyle = { | ||||
|   background: '#000', | ||||
|   color: '#fff', | ||||
|   textAlign: 'center', | ||||
|   height: '100%', | ||||
|   cursor: 'pointer', | ||||
|   display: 'flex', | ||||
|   alignItems: 'center', | ||||
|   justifyContent: 'center', | ||||
|   flexDirection: 'column' | ||||
| }; | ||||
| 
 | ||||
| const spoilerSpanStyle = { | ||||
|   display: 'block', | ||||
|   fontSize: '14px', | ||||
| }; | ||||
| 
 | ||||
| const spoilerSubSpanStyle = { | ||||
|   display: 'block', | ||||
|   fontSize: '11px', | ||||
|   fontWeight: '500' | ||||
| }; | ||||
| 
 | ||||
| const MediaGallery = React.createClass({ | ||||
| 
 | ||||
|   getInitialState () { | ||||
|     return { | ||||
|       visible: false | ||||
|     }; | ||||
|   }, | ||||
| 
 | ||||
|   propTypes: { | ||||
|     sensitive: React.PropTypes.bool, | ||||
|     media: ImmutablePropTypes.list.isRequired, | ||||
|     height: React.PropTypes.number.isRequired, | ||||
|     onOpenMedia: React.PropTypes.func.isRequired | ||||
|  | @ -20,69 +58,85 @@ const MediaGallery = React.createClass({ | |||
|     e.stopPropagation(); | ||||
|   }, | ||||
| 
 | ||||
|   handleOpen () { | ||||
|     this.setState({ visible: true }); | ||||
|   }, | ||||
| 
 | ||||
|   render () { | ||||
|     var children = this.props.media.take(4); | ||||
|     var size     = children.size; | ||||
|     const { media, sensitive } = this.props; | ||||
| 
 | ||||
|     children = children.map((attachment, i) => { | ||||
|       let width  = 50; | ||||
|       let height = 100; | ||||
|       let top    = 'auto'; | ||||
|       let left   = 'auto'; | ||||
|       let bottom = 'auto'; | ||||
|       let right  = 'auto'; | ||||
|     let children; | ||||
| 
 | ||||
|       if (size === 1) { | ||||
|         width = 100; | ||||
|       } | ||||
| 
 | ||||
|       if (size === 4 || (size === 3 && i > 0)) { | ||||
|         height = 50; | ||||
|       } | ||||
| 
 | ||||
|       if (size === 2) { | ||||
|         if (i === 0) { | ||||
|           right = '2px'; | ||||
|         } else { | ||||
|           left = '2px'; | ||||
|         } | ||||
|       } else if (size === 3) { | ||||
|         if (i === 0) { | ||||
|           right = '2px'; | ||||
|         } else if (i > 0) { | ||||
|           left = '2px'; | ||||
|         } | ||||
| 
 | ||||
|         if (i === 1) { | ||||
|           bottom = '2px'; | ||||
|         } else if (i > 1) { | ||||
|           top = '2px'; | ||||
|         } | ||||
|       } else if (size === 4) { | ||||
|         if (i === 0 || i === 2) { | ||||
|           right = '2px'; | ||||
|         } | ||||
| 
 | ||||
|         if (i === 1 || i === 3) { | ||||
|           left = '2px'; | ||||
|         } | ||||
| 
 | ||||
|         if (i < 2) { | ||||
|           bottom = '2px'; | ||||
|         } else { | ||||
|           top = '2px'; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       return ( | ||||
|         <div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}> | ||||
|           <a href={attachment.get('url')} onClick={this.handleClick.bind(this, attachment.get('url'))} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} /> | ||||
|     if (sensitive && !this.state.visible) { | ||||
|       children = ( | ||||
|         <div style={spoilerStyle} onClick={this.handleOpen}> | ||||
|           <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> | ||||
|           <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> | ||||
|         </div> | ||||
|       ); | ||||
|     }); | ||||
|     } else { | ||||
|       const size = media.take(4).size; | ||||
| 
 | ||||
|       children = media.take(4).map((attachment, i) => { | ||||
|         let width  = 50; | ||||
|         let height = 100; | ||||
|         let top    = 'auto'; | ||||
|         let left   = 'auto'; | ||||
|         let bottom = 'auto'; | ||||
|         let right  = 'auto'; | ||||
| 
 | ||||
|         if (size === 1) { | ||||
|           width = 100; | ||||
|         } | ||||
| 
 | ||||
|         if (size === 4 || (size === 3 && i > 0)) { | ||||
|           height = 50; | ||||
|         } | ||||
| 
 | ||||
|         if (size === 2) { | ||||
|           if (i === 0) { | ||||
|             right = '2px'; | ||||
|           } else { | ||||
|             left = '2px'; | ||||
|           } | ||||
|         } else if (size === 3) { | ||||
|           if (i === 0) { | ||||
|             right = '2px'; | ||||
|           } else if (i > 0) { | ||||
|             left = '2px'; | ||||
|           } | ||||
| 
 | ||||
|           if (i === 1) { | ||||
|             bottom = '2px'; | ||||
|           } else if (i > 1) { | ||||
|             top = '2px'; | ||||
|           } | ||||
|         } else if (size === 4) { | ||||
|           if (i === 0 || i === 2) { | ||||
|             right = '2px'; | ||||
|           } | ||||
| 
 | ||||
|           if (i === 1 || i === 3) { | ||||
|             left = '2px'; | ||||
|           } | ||||
| 
 | ||||
|           if (i < 2) { | ||||
|             bottom = '2px'; | ||||
|           } else { | ||||
|             top = '2px'; | ||||
|           } | ||||
|         } | ||||
| 
 | ||||
|         return ( | ||||
|           <div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}> | ||||
|             <a href={attachment.get('url')} onClick={this.handleClick.bind(this, attachment.get('url'))} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} /> | ||||
|           </div> | ||||
|         ); | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <div style={{ marginTop: '8px', overflow: 'hidden', width: '100%', height: `${this.props.height}px`, boxSizing: 'border-box' }}> | ||||
|       <div style={{ ...outerStyle, height: `${this.props.height}px` }}> | ||||
|         {children} | ||||
|       </div> | ||||
|     ); | ||||
|  |  | |||
|  | @ -34,6 +34,7 @@ const Status = React.createClass({ | |||
|     onReblog: React.PropTypes.func, | ||||
|     onDelete: React.PropTypes.func, | ||||
|     onOpenMedia: React.PropTypes.func, | ||||
|     onBlock: React.PropTypes.func, | ||||
|     me: React.PropTypes.number, | ||||
|     muted: React.PropTypes.bool | ||||
|   }, | ||||
|  | @ -83,9 +84,9 @@ const Status = React.createClass({ | |||
| 
 | ||||
|     if (status.get('media_attachments').size > 0) { | ||||
|       if (status.getIn(['media_attachments', 0, 'type']) === 'video') { | ||||
|         media = <VideoPlayer media={status.getIn(['media_attachments', 0])} />; | ||||
|         media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} />; | ||||
|       } else { | ||||
|         media = <MediaGallery media={status.get('media_attachments')} height={110} onOpenMedia={this.props.onOpenMedia} />; | ||||
|         media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -7,6 +7,7 @@ import { defineMessages, injectIntl } from 'react-intl'; | |||
| const messages = defineMessages({ | ||||
|   delete: { id: 'status.delete', defaultMessage: 'Delete' }, | ||||
|   mention: { id: 'status.mention', defaultMessage: 'Mention' }, | ||||
|   block: { id: 'account.block', defaultMessage: 'Block' }, | ||||
|   reply: { id: 'status.reply', defaultMessage: 'Reply' }, | ||||
|   reblog: { id: 'status.reblog', defaultMessage: 'Reblog' }, | ||||
|   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' } | ||||
|  | @ -24,7 +25,8 @@ const StatusActionBar = React.createClass({ | |||
|     onFavourite: React.PropTypes.func, | ||||
|     onReblog: React.PropTypes.func, | ||||
|     onDelete: React.PropTypes.func, | ||||
|     onMention: React.PropTypes.func | ||||
|     onMention: React.PropTypes.func, | ||||
|     onBlock: React.PropTypes.func | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
|  | @ -49,6 +51,10 @@ const StatusActionBar = React.createClass({ | |||
|     this.props.onMention(this.props.status.get('account')); | ||||
|   }, | ||||
| 
 | ||||
|   handleBlockClick () { | ||||
|     this.props.onBlock(this.props.status.get('account')); | ||||
|   }, | ||||
| 
 | ||||
|   render () { | ||||
|     const { status, me, intl } = this.props; | ||||
|     let menu = []; | ||||
|  | @ -57,6 +63,7 @@ const StatusActionBar = React.createClass({ | |||
|       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); | ||||
|     } else { | ||||
|       menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick }); | ||||
|       menu.push({ text: intl.formatMessage(messages.block), action: this.handleBlockClick }); | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PureRenderMixin from 'react-addons-pure-render-mixin'; | ||||
| import IconButton from './icon_button'; | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' } | ||||
|  | @ -25,6 +25,30 @@ const muteStyle = { | |||
|   zIndex: '5' | ||||
| }; | ||||
| 
 | ||||
| const spoilerStyle = { | ||||
|   marginTop: '8px', | ||||
|   background: '#000', | ||||
|   color: '#fff', | ||||
|   textAlign: 'center', | ||||
|   height: '100%', | ||||
|   cursor: 'pointer', | ||||
|   display: 'flex', | ||||
|   alignItems: 'center', | ||||
|   justifyContent: 'center', | ||||
|   flexDirection: 'column' | ||||
| }; | ||||
| 
 | ||||
| const spoilerSpanStyle = { | ||||
|   display: 'block', | ||||
|   fontSize: '14px' | ||||
| }; | ||||
| 
 | ||||
| const spoilerSubSpanStyle = { | ||||
|   display: 'block', | ||||
|   fontSize: '11px', | ||||
|   fontWeight: '500' | ||||
| }; | ||||
| 
 | ||||
| const VideoPlayer = React.createClass({ | ||||
|   propTypes: { | ||||
|     media: ImmutablePropTypes.map.isRequired, | ||||
|  | @ -41,6 +65,7 @@ const VideoPlayer = React.createClass({ | |||
| 
 | ||||
|   getInitialState () { | ||||
|     return { | ||||
|       visible: false, | ||||
|       muted: true | ||||
|     }; | ||||
|   }, | ||||
|  | @ -63,8 +88,21 @@ const VideoPlayer = React.createClass({ | |||
|     } | ||||
|   }, | ||||
| 
 | ||||
|   handleOpen () { | ||||
|     this.setState({ visible: true }); | ||||
|   }, | ||||
| 
 | ||||
|   render () { | ||||
|     const { media, intl, width, height } = this.props; | ||||
|     const { media, intl, width, height, sensitive } = this.props; | ||||
| 
 | ||||
|     if (sensitive && !this.state.visible) { | ||||
|       return ( | ||||
|         <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}> | ||||
|           <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> | ||||
|           <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}> | ||||
|  |  | |||
|  | @ -75,11 +75,6 @@ const Mastodon = React.createClass({ | |||
|               return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message))); | ||||
|             case 'delete': | ||||
|               return store.dispatch(deleteFromTimelines(data.id)); | ||||
|             case 'merge': | ||||
|             case 'unmerge': | ||||
|               return store.dispatch(refreshTimeline('home', true)); | ||||
|             case 'block': | ||||
|               return store.dispatch(refreshTimeline('mentions', true)); | ||||
|             case 'notification': | ||||
|               return store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale)); | ||||
|           } | ||||
|  |  | |||
|  | @ -1,18 +1,19 @@ | |||
| import { connect }       from 'react-redux'; | ||||
| import Status            from '../components/status'; | ||||
| import { connect } from 'react-redux'; | ||||
| import Status from '../components/status'; | ||||
| import { makeGetStatus } from '../selectors'; | ||||
| import { | ||||
|   replyCompose, | ||||
|   mentionCompose | ||||
| }                        from '../actions/compose'; | ||||
| } from '../actions/compose'; | ||||
| import { | ||||
|   reblog, | ||||
|   favourite, | ||||
|   unreblog, | ||||
|   unfavourite | ||||
| }                        from '../actions/interactions'; | ||||
| import { deleteStatus }  from '../actions/statuses'; | ||||
| import { openMedia }     from '../actions/modal'; | ||||
| } from '../actions/interactions'; | ||||
| import { blockAccount } from '../actions/accounts'; | ||||
| import { deleteStatus } from '../actions/statuses'; | ||||
| import { openMedia } from '../actions/modal'; | ||||
| import { createSelector } from 'reselect' | ||||
| 
 | ||||
| const mapStateToProps = (state, props) => ({ | ||||
|  | @ -91,6 +92,10 @@ const mapDispatchToProps = (dispatch) => ({ | |||
| 
 | ||||
|   onOpenMedia (url) { | ||||
|     dispatch(openMedia(url)); | ||||
|   }, | ||||
| 
 | ||||
|   onBlock (account) { | ||||
|     dispatch(blockAccount(account.get('id'))); | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
|  |  | |||
|  | @ -58,10 +58,8 @@ const ActionBar = React.createClass({ | |||
|     } else if (account.getIn(['relationship', 'blocking'])) { | ||||
|       menu.push({ text: intl.formatMessage(messages.unblock), action: this.props.onBlock }); | ||||
|     } else if (account.getIn(['relationship', 'following'])) { | ||||
|       menu.push({ text: intl.formatMessage(messages.unfollow), action: this.props.onFollow }); | ||||
|       menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock }); | ||||
|     } else { | ||||
|       menu.push({ text: intl.formatMessage(messages.follow), action: this.props.onFollow }); | ||||
|       menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock }); | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -2,22 +2,30 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; | |||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import emojify from '../../../emoji'; | ||||
| import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import IconButton from '../../../components/icon_button'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, | ||||
|   follow: { id: 'account.follow', defaultMessage: 'Follow' }, | ||||
| }); | ||||
| 
 | ||||
| const Header = React.createClass({ | ||||
| 
 | ||||
|   propTypes: { | ||||
|     account: ImmutablePropTypes.map.isRequired, | ||||
|     me: React.PropTypes.number.isRequired | ||||
|     me: React.PropTypes.number.isRequired, | ||||
|     onFollow: React.PropTypes.func.isRequired | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
| 
 | ||||
|   render () { | ||||
|     const { account, me } = this.props; | ||||
|     const { account, me, intl } = this.props; | ||||
| 
 | ||||
|     let displayName = account.get('display_name'); | ||||
|     let info        = ''; | ||||
|     let actionBtn   = ''; | ||||
| 
 | ||||
|     if (displayName.length === 0) { | ||||
|       displayName = account.get('username'); | ||||
|  | @ -27,6 +35,14 @@ const Header = React.createClass({ | |||
|       info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span> | ||||
|     } | ||||
| 
 | ||||
|     if (me !== account.get('id')) { | ||||
|       actionBtn = ( | ||||
|         <div style={{ position: 'absolute', top: '10px', left: '20px' }}> | ||||
|           <IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} /> | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     const content         = { __html: emojify(account.get('note')) }; | ||||
|     const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; | ||||
| 
 | ||||
|  | @ -45,6 +61,7 @@ const Header = React.createClass({ | |||
|           <div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} /> | ||||
| 
 | ||||
|           {info} | ||||
|           {actionBtn} | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|  | @ -52,4 +69,4 @@ const Header = React.createClass({ | |||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default Header; | ||||
| export default injectIntl(Header); | ||||
|  |  | |||
|  | @ -87,9 +87,8 @@ const Account = React.createClass({ | |||
|     return ( | ||||
|       <Column> | ||||
|         <ColumnBackButton /> | ||||
|         <Header account={account} me={me} /> | ||||
| 
 | ||||
|         <ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} onMention={this.handleMention} /> | ||||
|         <Header account={account} me={me} onFollow={this.handleFollow} /> | ||||
|         <ActionBar account={account} me={me} onBlock={this.handleBlock} onMention={this.handleMention} /> | ||||
| 
 | ||||
|         {this.props.children} | ||||
|       </Column> | ||||
|  |  | |||
|  | @ -8,7 +8,8 @@ import Autosuggest from 'react-autosuggest'; | |||
| import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container'; | ||||
| import { debounce } from 'react-decoration'; | ||||
| import UploadButtonContainer from '../containers/upload_button_container'; | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import Toggle from 'react-toggle'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, | ||||
|  | @ -67,6 +68,7 @@ const ComposeForm = React.createClass({ | |||
|     text: React.PropTypes.string.isRequired, | ||||
|     suggestion_token: React.PropTypes.string, | ||||
|     suggestions: React.PropTypes.array, | ||||
|     sensitive: React.PropTypes.bool, | ||||
|     is_submitting: React.PropTypes.bool, | ||||
|     is_uploading: React.PropTypes.bool, | ||||
|     in_reply_to: ImmutablePropTypes.map, | ||||
|  | @ -75,7 +77,8 @@ const ComposeForm = React.createClass({ | |||
|     onCancelReply: React.PropTypes.func.isRequired, | ||||
|     onClearSuggestions: React.PropTypes.func.isRequired, | ||||
|     onFetchSuggestions: React.PropTypes.func.isRequired, | ||||
|     onSuggestionSelected: React.PropTypes.func.isRequired | ||||
|     onSuggestionSelected: React.PropTypes.func.isRequired, | ||||
|     onChangeSensitivity: React.PropTypes.func.isRequired | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
|  | @ -139,6 +142,10 @@ const ComposeForm = React.createClass({ | |||
|     this.autosuggest = c; | ||||
|   }, | ||||
| 
 | ||||
|   handleChangeSensitivity (e) { | ||||
|     this.props.onChangeSensitivity(e.target.checked); | ||||
|   }, | ||||
| 
 | ||||
|   render () { | ||||
|     const { intl } = this.props; | ||||
|     let replyArea  = ''; | ||||
|  | @ -178,6 +185,11 @@ const ComposeForm = React.createClass({ | |||
|           <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.text} /></div> | ||||
|           <UploadButtonContainer style={{ paddingTop: '4px' }} /> | ||||
|         </div> | ||||
| 
 | ||||
|         <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', marginTop: '10px', borderTop: '1px solid #616b86', paddingTop: '10px' }}> | ||||
|           <Toggle checked={this.props.sensitive} onChange={this.handleChangeSensitivity} /> | ||||
|           <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark content as sensitive' /></span> | ||||
|         </label> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  |  | |||
|  | @ -6,7 +6,8 @@ import { | |||
|   cancelReplyCompose, | ||||
|   clearComposeSuggestions, | ||||
|   fetchComposeSuggestions, | ||||
|   selectComposeSuggestion | ||||
|   selectComposeSuggestion, | ||||
|   changeComposeSensitivity | ||||
| } from '../../../actions/compose'; | ||||
| import { makeGetStatus } from '../../../selectors'; | ||||
| 
 | ||||
|  | @ -18,6 +19,7 @@ const makeMapStateToProps = () => { | |||
|       text: state.getIn(['compose', 'text']), | ||||
|       suggestion_token: state.getIn(['compose', 'suggestion_token']), | ||||
|       suggestions: state.getIn(['compose', 'suggestions']).toJS(), | ||||
|       sensitive: state.getIn(['compose', 'sensitive']), | ||||
|       is_submitting: state.getIn(['compose', 'is_submitting']), | ||||
|       is_uploading: state.getIn(['compose', 'is_uploading']), | ||||
|       in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])) | ||||
|  | @ -51,6 +53,10 @@ const mapDispatchToProps = function (dispatch) { | |||
| 
 | ||||
|     onSuggestionSelected (position, accountId) { | ||||
|       dispatch(selectComposeSuggestion(position, accountId)); | ||||
|     }, | ||||
| 
 | ||||
|     onChangeSensitivity (checked) { | ||||
|       dispatch(changeComposeSensitivity(checked)); | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|  |  | |||
|  | @ -36,9 +36,9 @@ const DetailedStatus = React.createClass({ | |||
| 
 | ||||
|     if (status.get('media_attachments').size > 0) { | ||||
|       if (status.getIn(['media_attachments', 0, 'type']) === 'video') { | ||||
|         media = <VideoPlayer media={status.getIn(['media_attachments', 0])} width={317} height={178} />; | ||||
|         media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={317} height={178} />; | ||||
|       } else { | ||||
|         media = <MediaGallery media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />; | ||||
|         media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -8,6 +8,8 @@ const en = { | |||
|   "status.reblog": "Reblog", | ||||
|   "status.favourite": "Favourite", | ||||
|   "status.reblogged_by": "{name} reblogged", | ||||
|   "status.sensitive_warning": "Sensitive content", | ||||
|   "status.sensitive_toggle": "Click to view", | ||||
|   "video_player.toggle_sound": "Toggle sound", | ||||
|   "account.mention": "Mention", | ||||
|   "account.edit_profile": "Edit profile", | ||||
|  | @ -35,6 +37,7 @@ const en = { | |||
|   "tabs_bar.notifications": "Notifications", | ||||
|   "compose_form.placeholder": "What is on your mind?", | ||||
|   "compose_form.publish": "Toot", | ||||
|   "compose_form.sensitive": "Mark content as sensitive", | ||||
|   "navigation_bar.settings": "Settings", | ||||
|   "navigation_bar.public_timeline": "Public timeline", | ||||
|   "navigation_bar.logout": "Logout", | ||||
|  |  | |||
|  | @ -15,7 +15,8 @@ import { | |||
|   COMPOSE_UPLOAD_PROGRESS, | ||||
|   COMPOSE_SUGGESTIONS_CLEAR, | ||||
|   COMPOSE_SUGGESTIONS_READY, | ||||
|   COMPOSE_SUGGESTION_SELECT | ||||
|   COMPOSE_SUGGESTION_SELECT, | ||||
|   COMPOSE_SENSITIVITY_CHANGE | ||||
| } from '../actions/compose'; | ||||
| import { TIMELINE_DELETE } from '../actions/timelines'; | ||||
| import { ACCOUNT_SET_SELF } from '../actions/accounts'; | ||||
|  | @ -23,6 +24,7 @@ import Immutable from 'immutable'; | |||
| 
 | ||||
| const initialState = Immutable.Map({ | ||||
|   mounted: false, | ||||
|   sensitive: false, | ||||
|   text: '', | ||||
|   in_reply_to: null, | ||||
|   is_submitting: false, | ||||
|  | @ -87,6 +89,8 @@ export default function compose(state = initialState, action) { | |||
|       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_CHANGE: | ||||
|       return state.set('text', action.text); | ||||
|     case COMPOSE_REPLY: | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ import { | |||
|   NOTIFICATIONS_REFRESH_SUCCESS, | ||||
|   NOTIFICATIONS_EXPAND_SUCCESS | ||||
| } from '../actions/notifications'; | ||||
| import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; | ||||
| import Immutable from 'immutable'; | ||||
| 
 | ||||
| const initialState = Immutable.Map({ | ||||
|  | @ -43,6 +44,10 @@ const appendNormalizedNotifications = (state, notifications, next) => { | |||
|   return state.update('items', list => list.push(...items)).set('next', next); | ||||
| }; | ||||
| 
 | ||||
| const filterNotifications = (state, relationship) => { | ||||
|   return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id)); | ||||
| }; | ||||
| 
 | ||||
| export default function notifications(state = initialState, action) { | ||||
|   switch(action.type) { | ||||
|     case NOTIFICATIONS_UPDATE: | ||||
|  | @ -51,6 +56,8 @@ export default function notifications(state = initialState, action) { | |||
|       return normalizeNotifications(state, action.notifications, action.next); | ||||
|     case NOTIFICATIONS_EXPAND_SUCCESS: | ||||
|       return appendNormalizedNotifications(state, action.notifications, action.next); | ||||
|     case ACCOUNT_BLOCK_SUCCESS: | ||||
|       return filterNotifications(state, action.relationship); | ||||
|     default: | ||||
|       return state; | ||||
|   } | ||||
|  |  | |||
|  | @ -1,7 +1,11 @@ | |||
| import { | ||||
|   REBLOG_REQUEST, | ||||
|   REBLOG_SUCCESS, | ||||
|   REBLOG_FAIL, | ||||
|   UNREBLOG_SUCCESS, | ||||
|   FAVOURITE_REQUEST, | ||||
|   FAVOURITE_SUCCESS, | ||||
|   FAVOURITE_FAIL, | ||||
|   UNFAVOURITE_SUCCESS | ||||
| } from '../actions/interactions'; | ||||
| import { | ||||
|  | @ -16,7 +20,8 @@ import { | |||
| } from '../actions/timelines'; | ||||
| import { | ||||
|   ACCOUNT_TIMELINE_FETCH_SUCCESS, | ||||
|   ACCOUNT_TIMELINE_EXPAND_SUCCESS | ||||
|   ACCOUNT_TIMELINE_EXPAND_SUCCESS, | ||||
|   ACCOUNT_BLOCK_SUCCESS | ||||
| } from '../actions/accounts'; | ||||
| import { | ||||
|   NOTIFICATIONS_UPDATE, | ||||
|  | @ -56,6 +61,18 @@ const deleteStatus = (state, id, references) => { | |||
|   return state.delete(id); | ||||
| }; | ||||
| 
 | ||||
| const filterStatuses = (state, relationship) => { | ||||
|   state.forEach(status => { | ||||
|     if (status.get('account') !== relationship.id) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     state = deleteStatus(state, status.get('id'), state.filter(item => item.get('reblog') === status.get('id'))); | ||||
|   }); | ||||
| 
 | ||||
|   return state; | ||||
| }; | ||||
| 
 | ||||
| const initialState = Immutable.Map(); | ||||
| 
 | ||||
| export default function statuses(state = initialState, action) { | ||||
|  | @ -69,6 +86,14 @@ export default function statuses(state = initialState, action) { | |||
|     case FAVOURITE_SUCCESS: | ||||
|     case UNFAVOURITE_SUCCESS: | ||||
|       return normalizeStatus(state, action.response); | ||||
|     case FAVOURITE_REQUEST: | ||||
|       return state.setIn([action.status.get('id'), 'favourited'], true); | ||||
|     case FAVOURITE_FAIL: | ||||
|       return state.setIn([action.status.get('id'), 'favourited'], false); | ||||
|     case REBLOG_REQUEST: | ||||
|       return state.setIn([action.status.get('id'), 'reblogged'], true); | ||||
|     case REBLOG_FAIL: | ||||
|       return state.setIn([action.status.get('id'), 'reblogged'], false); | ||||
|     case TIMELINE_REFRESH_SUCCESS: | ||||
|     case TIMELINE_EXPAND_SUCCESS: | ||||
|     case ACCOUNT_TIMELINE_FETCH_SUCCESS: | ||||
|  | @ -79,6 +104,8 @@ export default function statuses(state = initialState, action) { | |||
|       return normalizeStatuses(state, action.statuses); | ||||
|     case TIMELINE_DELETE: | ||||
|       return deleteStatus(state, action.id, action.references); | ||||
|     case ACCOUNT_BLOCK_SUCCESS: | ||||
|       return filterStatuses(state, action.relationship); | ||||
|     default: | ||||
|       return state; | ||||
|   } | ||||
|  |  | |||
|  | @ -13,7 +13,8 @@ import { | |||
| import { | ||||
|   ACCOUNT_FETCH_SUCCESS, | ||||
|   ACCOUNT_TIMELINE_FETCH_SUCCESS, | ||||
|   ACCOUNT_TIMELINE_EXPAND_SUCCESS | ||||
|   ACCOUNT_TIMELINE_EXPAND_SUCCESS, | ||||
|   ACCOUNT_BLOCK_SUCCESS | ||||
| } from '../actions/accounts'; | ||||
| import { | ||||
|   STATUS_FETCH_SUCCESS, | ||||
|  | @ -140,6 +141,21 @@ const deleteStatus = (state, id, accountId, references) => { | |||
|   return state; | ||||
| }; | ||||
| 
 | ||||
| const filterTimelines = (state, relationship, statuses) => { | ||||
|   let references; | ||||
| 
 | ||||
|   statuses.forEach(status => { | ||||
|     if (status.get('account') !== relationship.id) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => [item.get('id'), item.get('account')]); | ||||
|     state = deleteStatus(state, status.get('id'), status.get('account'), references); | ||||
|   }); | ||||
| 
 | ||||
|   return state; | ||||
| }; | ||||
| 
 | ||||
| const normalizeContext = (state, id, ancestors, descendants) => { | ||||
|   const ancestorsIds   = ancestors.map(ancestor => ancestor.get('id')); | ||||
|   const descendantsIds = descendants.map(descendant => descendant.get('id')); | ||||
|  | @ -166,6 +182,8 @@ export default function timelines(state = initialState, action) { | |||
|       return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace); | ||||
|     case ACCOUNT_TIMELINE_EXPAND_SUCCESS: | ||||
|       return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); | ||||
|     case ACCOUNT_BLOCK_SUCCESS: | ||||
|       return filterTimelines(state, action.relationship, action.statuses); | ||||
|     default: | ||||
|       return state; | ||||
|   } | ||||
|  |  | |||
|  | @ -405,3 +405,109 @@ | |||
|     text-decoration: underline; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .react-toggle { | ||||
|   display: inline-block; | ||||
|   position: relative; | ||||
|   cursor: pointer; | ||||
|   background-color: transparent; | ||||
|   border: 0; | ||||
|   padding: 0; | ||||
|   user-select: none; | ||||
|   -webkit-tap-highlight-color: rgba(0,0,0,0); | ||||
|   -webkit-tap-highlight-color: transparent; | ||||
| } | ||||
| 
 | ||||
| .react-toggle-screenreader-only { | ||||
|   border: 0; | ||||
|   clip: rect(0 0 0 0); | ||||
|   height: 1px; | ||||
|   margin: -1px; | ||||
|   overflow: hidden; | ||||
|   padding: 0; | ||||
|   position: absolute; | ||||
|   width: 1px; | ||||
| } | ||||
| 
 | ||||
| .react-toggle--disabled { | ||||
|   cursor: not-allowed; | ||||
|   opacity: 0.5; | ||||
|   transition: opacity 0.25s; | ||||
| } | ||||
| 
 | ||||
| .react-toggle-track { | ||||
|   width: 50px; | ||||
|   height: 24px; | ||||
|   padding: 0; | ||||
|   border-radius: 30px; | ||||
|   background-color: #282c37; | ||||
|   transition: all 0.2s ease; | ||||
| } | ||||
| 
 | ||||
| .react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { | ||||
|   background-color: darken(#282c37, 10%); | ||||
| } | ||||
| 
 | ||||
| .react-toggle--checked .react-toggle-track { | ||||
|   background-color: #2b90d9; | ||||
| } | ||||
| 
 | ||||
| .react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track { | ||||
|   background-color: lighten(#2b90d9, 10%); | ||||
| } | ||||
| 
 | ||||
| .react-toggle-track-check { | ||||
|   position: absolute; | ||||
|   width: 14px; | ||||
|   height: 10px; | ||||
|   top: 0px; | ||||
|   bottom: 0px; | ||||
|   margin-top: auto; | ||||
|   margin-bottom: auto; | ||||
|   line-height: 0; | ||||
|   left: 8px; | ||||
|   opacity: 0; | ||||
|   transition: opacity 0.25s ease; | ||||
| } | ||||
| 
 | ||||
| .react-toggle--checked .react-toggle-track-check { | ||||
|   opacity: 1; | ||||
|   transition: opacity 0.25s ease; | ||||
| } | ||||
| 
 | ||||
| .react-toggle-track-x { | ||||
|   position: absolute; | ||||
|   width: 10px; | ||||
|   height: 10px; | ||||
|   top: 0px; | ||||
|   bottom: 0px; | ||||
|   margin-top: auto; | ||||
|   margin-bottom: auto; | ||||
|   line-height: 0; | ||||
|   right: 10px; | ||||
|   opacity: 1; | ||||
|   transition: opacity 0.25s ease; | ||||
| } | ||||
| 
 | ||||
| .react-toggle--checked .react-toggle-track-x { | ||||
|   opacity: 0; | ||||
| } | ||||
| 
 | ||||
| .react-toggle-thumb { | ||||
|   transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms; | ||||
|   position: absolute; | ||||
|   top: 1px; | ||||
|   left: 1px; | ||||
|   width: 22px; | ||||
|   height: 22px; | ||||
|   border: 1px solid #282c37; | ||||
|   border-radius: 50%; | ||||
|   background-color: #FAFAFA; | ||||
|   box-sizing: border-box; | ||||
|   transition: all 0.25s ease; | ||||
| } | ||||
| 
 | ||||
| .react-toggle--checked .react-toggle-thumb { | ||||
|   left: 27px; | ||||
|   border-color: #2b90d9; | ||||
| } | ||||
|  |  | |||
|  | @ -57,7 +57,8 @@ class Api::V1::AccountsController < ApiController | |||
|   end | ||||
| 
 | ||||
|   def statuses | ||||
|     @statuses = @account.statuses.with_includes.paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a | ||||
|     @statuses = @account.statuses.paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a | ||||
|     @statuses = cache(@statuses) | ||||
| 
 | ||||
|     set_maps(@statuses) | ||||
|     set_counters_maps(@statuses) | ||||
|  | @ -120,4 +121,23 @@ class Api::V1::AccountsController < ApiController | |||
|     @followed_by = Account.followed_by_map([@account.id], current_user.account_id) | ||||
|     @blocking    = Account.blocking_map([@account.id], current_user.account_id) | ||||
|   end | ||||
| 
 | ||||
|   def cache(raw) | ||||
|     uncached_ids           = [] | ||||
|     cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key)) | ||||
| 
 | ||||
|     raw.each do |status| | ||||
|       uncached_ids << status.id unless cached_keys_with_value.key?(status.cache_key) | ||||
|     end | ||||
| 
 | ||||
|     unless uncached_ids.empty? | ||||
|       uncached = Status.where(id: uncached_ids).with_includes.map { |s| [s.id, s] }.to_h | ||||
| 
 | ||||
|       uncached.values.each do |status| | ||||
|         Rails.cache.write(status.cache_key, status) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     raw.map { |status| cached_keys_with_value[status.cache_key] || uncached[status.id] } | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -4,6 +4,9 @@ class Api::V1::MediaController < ApiController | |||
|   before_action -> { doorkeeper_authorize! :write } | ||||
|   before_action :require_user! | ||||
| 
 | ||||
|   include ObfuscateFilename | ||||
|   obfuscate_filename :file | ||||
| 
 | ||||
|   respond_to :json | ||||
| 
 | ||||
|   def create | ||||
|  |  | |||
|  | @ -9,6 +9,8 @@ class Api::V1::StatusesController < ApiController | |||
|   respond_to :json | ||||
| 
 | ||||
|   def show | ||||
|     cached  = Rails.cache.read(@status.cache_key) | ||||
|     @status = cached unless cached.nil? | ||||
|   end | ||||
| 
 | ||||
|   def context | ||||
|  | @ -50,7 +52,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]), params[:media_ids]) | ||||
|     @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]) | ||||
|     render action: :show | ||||
|   end | ||||
| 
 | ||||
|  |  | |||
|  | @ -14,7 +14,6 @@ class ApplicationController < ActionController::Base | |||
| 
 | ||||
|   before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? | ||||
|   before_action :set_locale | ||||
|   before_action :check_rack_mini_profiler | ||||
| 
 | ||||
|   def raise_not_found | ||||
|     raise ActionController::RoutingError, "No route matches #{params[:unmatched_route]}" | ||||
|  | @ -32,10 +31,6 @@ class ApplicationController < ActionController::Base | |||
|     I18n.locale = I18n.default_locale | ||||
|   end | ||||
| 
 | ||||
|   def check_rack_mini_profiler | ||||
|     Rack::MiniProfiler.authorize_request if current_user && current_user.admin? | ||||
|   end | ||||
| 
 | ||||
|   protected | ||||
| 
 | ||||
|   def not_found | ||||
|  |  | |||
|  | @ -6,6 +6,10 @@ class Settings::ProfilesController < ApplicationController | |||
|   before_action :authenticate_user! | ||||
|   before_action :set_account | ||||
| 
 | ||||
|   include ObfuscateFilename | ||||
|   obfuscate_filename [:account, :avatar] | ||||
|   obfuscate_filename [:account, :header] | ||||
| 
 | ||||
|   def show | ||||
|   end | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										16
									
								
								app/models/concerns/obfuscate_filename.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								app/models/concerns/obfuscate_filename.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,16 @@ | |||
| module ObfuscateFilename | ||||
|   extend ActiveSupport::Concern | ||||
| 
 | ||||
|   class_methods do | ||||
|     def obfuscate_filename(*args) | ||||
|       before_action { obfuscate_filename(*args) } | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   def obfuscate_filename(path) | ||||
|     file = params.dig(*path) | ||||
|     return if file.nil? | ||||
| 
 | ||||
|     file.original_filename = "media" + File.extname(file.original_filename) | ||||
|   end | ||||
| end | ||||
|  | @ -6,19 +6,27 @@ class BlockService < BaseService | |||
| 
 | ||||
|     UnfollowService.new.call(account, target_account) if account.following?(target_account) | ||||
|     account.block!(target_account) | ||||
|     clear_mentions(account, target_account) | ||||
|     clear_timelines(account, target_account) | ||||
|     clear_notifications(account, target_account) | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def clear_mentions(account, target_account) | ||||
|     timeline_key = FeedManager.instance.key(:mentions, account.id) | ||||
|   def clear_timelines(account, target_account) | ||||
|     mentions_key = FeedManager.instance.key(:mentions, account.id) | ||||
|     home_key     = FeedManager.instance.key(:home, account.id) | ||||
| 
 | ||||
|     target_account.statuses.select('id').find_each do |status| | ||||
|       redis.zrem(timeline_key, status.id) | ||||
|       redis.zrem(mentions_key, status.id) | ||||
|       redis.zrem(home_key, status.id) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|     FeedManager.instance.broadcast(account.id, type: 'block', id: target_account.id) | ||||
|   def clear_notifications(account, target_account) | ||||
|     Notification.where(account: account).joins(:follow).where(activity_type: 'Follow', follows: { account_id: target_account.id }).destroy_all | ||||
|     Notification.where(account: account).joins(mention: :status).where(activity_type: 'Mention', statuses: { account_id: target_account.id }).destroy_all | ||||
|     Notification.where(account: account).joins(:favourite).where(activity_type: 'Favourite', favourites: { account_id: target_account.id }).destroy_all | ||||
|     Notification.where(account: account).joins(:status).where(activity_type: 'Status', statuses: { account_id: target_account.id }).destroy_all | ||||
|   end | ||||
| 
 | ||||
|   def redis | ||||
|  |  | |||
|  | @ -24,7 +24,7 @@ class FanOutOnWriteService < BaseService | |||
|   def deliver_to_followers(status) | ||||
|     Rails.logger.debug "Delivering status #{status.id} to followers" | ||||
| 
 | ||||
|     status.account.followers.where(domain: nil).find_each do |follower| | ||||
|     status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', 14.days.ago).find_each do |follower| | ||||
|       next if FeedManager.instance.filter?(:home, status, follower) | ||||
|       FeedManager.instance.push(:home, follower, status) | ||||
|     end | ||||
|  |  | |||
|  | @ -33,7 +33,6 @@ class FollowService < BaseService | |||
|     end | ||||
| 
 | ||||
|     FeedManager.instance.trim(:home, into_account.id) | ||||
|     FeedManager.instance.broadcast(into_account.id, type: 'merge') | ||||
|   end | ||||
| 
 | ||||
|   def redis | ||||
|  |  | |||
|  | @ -5,11 +5,13 @@ class PostStatusService < BaseService | |||
|   # @param [Account] account Account from which to post | ||||
|   # @param [String] text Message | ||||
|   # @param [Status] in_reply_to Optional status to reply to | ||||
|   # @param [Enumerable] media_ids Optional array of media IDs to attach | ||||
|   # @param [Hash] options | ||||
|   # @option [Boolean] :sensitive | ||||
|   # @option [Enumerable] :media_ids Optional array of media IDs to attach | ||||
|   # @return [Status] | ||||
|   def call(account, text, in_reply_to = nil, media_ids = nil) | ||||
|     status = account.statuses.create!(text: text, thread: in_reply_to) | ||||
|     attach_media(status, media_ids) | ||||
|   def call(account, text, in_reply_to = nil, options = {}) | ||||
|     status = account.statuses.create!(text: text, thread: in_reply_to, sensitive: options[:sensitive]) | ||||
|     attach_media(status, options[:media_ids]) | ||||
|     process_mentions_service.call(status) | ||||
|     process_hashtags_service.call(status) | ||||
|     DistributionWorker.perform_async(status.id) | ||||
|  |  | |||
|  | @ -17,9 +17,8 @@ class UnfollowService < BaseService | |||
| 
 | ||||
|     from_account.statuses.select('id').find_each do |status| | ||||
|       redis.zrem(timeline_key, status.id) | ||||
|       redis.zremrangebyscore(timeline_key, status.id, status.id) | ||||
|     end | ||||
| 
 | ||||
|     FeedManager.instance.broadcast(into_account.id, type: 'unmerge') | ||||
|   end | ||||
| 
 | ||||
|   def redis | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| attributes :id, :created_at, :in_reply_to_id | ||||
| attributes :id, :created_at, :in_reply_to_id, :sensitive | ||||
| 
 | ||||
| node(:uri)              { |status| TagManager.instance.uri_for(status) } | ||||
| node(:content)          { |status| Formatter.instance.format(status) } | ||||
|  |  | |||
|  | @ -1,5 +1,8 @@ | |||
| - content_for :header_tags do | ||||
|   %link{ rel: 'alternate', type: 'application/atom+xml', href: account_stream_entry_url(@account, @stream_entry, format: 'atom') }/ | ||||
|   %meta{ name: 'og:site_name', content: 'Mastodon' }/ | ||||
|   %meta{ name: 'og:type', content: 'article' }/ | ||||
|   %meta{ name: 'og:article:author', content: @account.username }/ | ||||
| 
 | ||||
| .activity-stream.activity-stream-headless | ||||
|   = render partial: @type, locals: { @type.to_sym => @stream_entry.activity, include_threads: true } | ||||
|  |  | |||
|  | @ -50,7 +50,8 @@ Rails.application.configure do | |||
|     host: ENV.fetch('REDIS_HOST') { 'localhost' }, | ||||
|     port: ENV.fetch('REDIS_PORT') { 6379 }, | ||||
|     db: 0, | ||||
|     namespace: 'cache' | ||||
|     namespace: 'cache', | ||||
|     expires_in: 20.minutes | ||||
|   } | ||||
| 
 | ||||
|   # Enable serving of images, stylesheets, and JavaScripts from an asset server. | ||||
|  |  | |||
|  | @ -1,17 +0,0 @@ | |||
| require 'rack-mini-profiler' | ||||
| 
 | ||||
| Rack::MiniProfilerRails.initialize!(Rails.application) | ||||
| 
 | ||||
| Rails.application.middleware.swap(Rack::Deflater, Rack::MiniProfiler) | ||||
| Rails.application.middleware.swap(Rack::MiniProfiler, Rack::Deflater) | ||||
| 
 | ||||
| Rack::MiniProfiler.config.storage = Rack::MiniProfiler::MemoryStore | ||||
| 
 | ||||
| if Rails.env.production? | ||||
|   Rack::MiniProfiler.config.storage_options = { | ||||
|     host: ENV.fetch('REDIS_HOST') { 'localhost' }, | ||||
|     port: ENV.fetch('REDIS_PORT') { 6379 }, | ||||
|   } | ||||
| 
 | ||||
|   Rack::MiniProfiler.config.storage = Rack::MiniProfiler::RedisStore | ||||
| end | ||||
							
								
								
									
										5
									
								
								db/migrate/20161123093447_add_sensitive_to_statuses.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								db/migrate/20161123093447_add_sensitive_to_statuses.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | |||
| class AddSensitiveToStatuses < ActiveRecord::Migration[5.0] | ||||
|   def change | ||||
|     add_column :statuses, :sensitive, :boolean, default: false | ||||
|   end | ||||
| end | ||||
							
								
								
									
										11
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								db/schema.rb
									
									
									
									
									
								
							|  | @ -10,7 +10,7 @@ | |||
| # | ||||
| # It's strongly recommended that you check this file into your version control system. | ||||
| 
 | ||||
| ActiveRecord::Schema.define(version: 20161122163057) do | ||||
| ActiveRecord::Schema.define(version: 20161123093447) do | ||||
| 
 | ||||
|   # These are extensions that must be enabled in order to support this database | ||||
|   enable_extension "plpgsql" | ||||
|  | @ -155,13 +155,14 @@ ActiveRecord::Schema.define(version: 20161122163057) do | |||
| 
 | ||||
|   create_table "statuses", force: :cascade do |t| | ||||
|     t.string   "uri" | ||||
|     t.integer  "account_id",                  null: false | ||||
|     t.text     "text",           default: "", null: false | ||||
|     t.datetime "created_at",                  null: false | ||||
|     t.datetime "updated_at",                  null: false | ||||
|     t.integer  "account_id",                     null: false | ||||
|     t.text     "text",           default: "",    null: false | ||||
|     t.datetime "created_at",                     null: false | ||||
|     t.datetime "updated_at",                     null: false | ||||
|     t.integer  "in_reply_to_id" | ||||
|     t.integer  "reblog_of_id" | ||||
|     t.string   "url" | ||||
|     t.boolean  "sensitive",      default: false | ||||
|     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 | ||||
|     t.index ["reblog_of_id"], name: "index_statuses_on_reblog_of_id", using: :btree | ||||
|  |  | |||
|  | @ -36,8 +36,16 @@ namespace :mastodon do | |||
|   end | ||||
| 
 | ||||
|   namespace :feeds do | ||||
|     desc 'Clears all timelines so that they would be regenerated on next hit' | ||||
|     desc 'Clear timelines of inactive users' | ||||
|     task clear: :environment do | ||||
|       User.where('current_sign_in_at < ?', 14.days.ago).find_each do |user| | ||||
|         Redis.current.del(FeedManager.instance.key(:home, user.account_id)) | ||||
|         Redis.current.del(FeedManager.instance.key(:mentions, user.account_id)) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     desc 'Clears all timelines so that they would be regenerated on next hit' | ||||
|     task clear_all: :environment do | ||||
|       Redis.current.keys('feed:*').each { |key| Redis.current.del(key) } | ||||
|     end | ||||
|   end | ||||
|  |  | |||
							
								
								
									
										25
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								package.json
									
									
									
									
									
								
							|  | @ -8,6 +8,7 @@ | |||
|     "@kadira/storybook": "^2.24.0", | ||||
|     "axios": "^0.14.0", | ||||
|     "babel-plugin-react-transform": "^2.0.2", | ||||
|     "babel-plugin-transform-decorators-legacy": "^1.3.4", | ||||
|     "babel-plugin-transform-object-rest-spread": "^6.8.0", | ||||
|     "babel-preset-es2015": "^6.13.2", | ||||
|     "babel-preset-react": "^6.11.1", | ||||
|  | @ -16,37 +17,39 @@ | |||
|     "browserify-incremental": "^3.1.1", | ||||
|     "chai": "^3.5.0", | ||||
|     "chai-enzyme": "^0.5.2", | ||||
|     "emojione": "^2.2.6", | ||||
|     "enzyme": "^2.4.1", | ||||
|     "es6-promise": "^3.2.1", | ||||
|     "http-link-header": "^0.5.0", | ||||
|     "immutable": "^3.8.1", | ||||
|     "intl": "^1.2.5", | ||||
|     "jsdom": "^9.6.0", | ||||
|     "mocha": "^3.1.1", | ||||
|     "react": "^15.3.2", | ||||
|     "react-addons-perf": "^15.3.2", | ||||
|     "react-addons-pure-render-mixin": "^15.3.1", | ||||
|     "react-addons-test-utils": "^15.3.2", | ||||
|     "react-autosuggest": "^7.0.1", | ||||
|     "react-decoration": "^1.4.0", | ||||
|     "react-dom": "^15.3.0", | ||||
|     "react-immutable-proptypes": "^2.1.0", | ||||
|     "react-intl": "^2.1.5", | ||||
|     "react-motion": "^0.4.5", | ||||
|     "react-notification": "^6.4.0", | ||||
|     "react-proxy": "^1.1.8", | ||||
|     "react-redux": "^5.0.0-beta.3", | ||||
|     "react-redux-loading-bar": "^2.4.1", | ||||
|     "react-responsive": "^1.1.5", | ||||
|     "react-router": "^2.8.0", | ||||
|     "react-router-scroll": "^0.3.2", | ||||
|     "react-simple-dropdown": "^1.1.4", | ||||
|     "redux": "^3.5.2", | ||||
|     "redux-immutable": "^3.0.8", | ||||
|     "redux-thunk": "^2.1.0", | ||||
|     "reselect": "^2.5.4", | ||||
|     "sinon": "^1.17.6", | ||||
|     "babel-plugin-transform-decorators-legacy": "^1.3.4", | ||||
|     "emojione": "^2.2.6", | ||||
|     "http-link-header": "^0.5.0", | ||||
|     "intl": "^1.2.5", | ||||
|     "react-autosuggest": "^7.0.1", | ||||
|     "react-decoration": "^1.4.0", | ||||
|     "react-intl": "^2.1.5", | ||||
|     "react-motion": "^0.4.5", | ||||
|     "react-responsive": "^1.1.5", | ||||
|     "react-router-scroll": "^0.3.2" | ||||
|     "sinon": "^1.17.6" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "react-toggle": "^2.1.1" | ||||
|   } | ||||
| } | ||||
|  |  | |||
							
								
								
									
										11
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								yarn.lock
									
									
									
									
									
								
							|  | @ -1335,7 +1335,7 @@ clap@^1.0.9: | |||
|   dependencies: | ||||
|     chalk "^1.1.3" | ||||
| 
 | ||||
| classnames@^2.1.2, classnames@^2.2.3: | ||||
| classnames@^2.1.2, classnames@^2.2.3, classnames@~2.2: | ||||
|   version "2.2.5" | ||||
|   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d" | ||||
| 
 | ||||
|  | @ -3841,7 +3841,7 @@ react-addons-perf@^15.3.2: | |||
|   version "15.3.2" | ||||
|   resolved "https://registry.yarnpkg.com/react-addons-perf/-/react-addons-perf-15.3.2.tgz#bbdbebe8649f936f9636a5750ac145bf5c620213" | ||||
| 
 | ||||
| react-addons-pure-render-mixin@^15.3.1: | ||||
| react-addons-pure-render-mixin@>=0.14.0, react-addons-pure-render-mixin@^15.3.1: | ||||
|   version "15.3.2" | ||||
|   resolved "https://registry.yarnpkg.com/react-addons-pure-render-mixin/-/react-addons-pure-render-mixin-15.3.2.tgz#c5f54764667ead26e6cdf7178b6c8dbbd8463ec2" | ||||
| 
 | ||||
|  | @ -4022,6 +4022,13 @@ react-themeable@^1.1.0: | |||
|   dependencies: | ||||
|     object-assign "^3.0.0" | ||||
| 
 | ||||
| react-toggle@^2.1.1: | ||||
|   version "2.1.1" | ||||
|   resolved "https://registry.yarnpkg.com/react-toggle/-/react-toggle-2.1.1.tgz#80600a64417a1acc8aaa4c1477f7fbdb88b988fb" | ||||
|   dependencies: | ||||
|     classnames "~2.2" | ||||
|     react-addons-pure-render-mixin ">=0.14.0" | ||||
| 
 | ||||
| react@^15.3.2: | ||||
|   version "15.3.2" | ||||
|   resolved "https://registry.yarnpkg.com/react/-/react-15.3.2.tgz#a7bccd2fee8af126b0317e222c28d1d54528d09e" | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue