Merge branch 'master' into development
This commit is contained in:
		
						commit
						c7e14e496b
					
				
					 147 changed files with 3101 additions and 1089 deletions
				
			
		|  | @ -13,7 +13,7 @@ Below are the guidelines for working on pull requests: | |||
| 
 | ||||
| ## General | ||||
| 
 | ||||
| - 2 spaces indendation | ||||
| - 2 spaces indentation | ||||
| 
 | ||||
| ## Documentation | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										43
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										43
									
								
								Dockerfile
									
									
									
									
									
								
							|  | @ -1,24 +1,31 @@ | |||
| FROM ruby:2.3.1 | ||||
| FROM ruby:2.3.1-alpine | ||||
| 
 | ||||
| ENV RAILS_ENV=production | ||||
| ENV NODE_ENV=production | ||||
| 
 | ||||
| RUN echo 'deb http://httpredir.debian.org/debian jessie-backports main contrib non-free' >> /etc/apt/sources.list | ||||
| RUN curl -sL https://deb.nodesource.com/setup_4.x | bash - | ||||
| RUN apt-get update -qq && apt-get install -y build-essential libpq-dev libxml2-dev libxslt1-dev nodejs ffmpeg && rm -rf /var/lib/apt/lists/* | ||||
| RUN npm install -g npm@3 && npm install -g yarn | ||||
| RUN mkdir /mastodon | ||||
| ENV RAILS_ENV=production \ | ||||
|     NODE_ENV=production | ||||
| 
 | ||||
| WORKDIR /mastodon | ||||
| 
 | ||||
| ADD Gemfile /mastodon/Gemfile | ||||
| ADD Gemfile.lock /mastodon/Gemfile.lock | ||||
| RUN bundle install --deployment --without test development | ||||
| COPY . /mastodon | ||||
| 
 | ||||
| ADD package.json /mastodon/package.json | ||||
| ADD yarn.lock /mastodon/yarn.lock | ||||
| RUN yarn | ||||
| RUN BUILD_DEPS=" \ | ||||
|     postgresql-dev \ | ||||
|     libxml2-dev \ | ||||
|     libxslt-dev \ | ||||
|     build-base" \ | ||||
|  && apk -U upgrade && apk add \ | ||||
|     $BUILD_DEPS \ | ||||
|     nodejs \ | ||||
|     libpq \ | ||||
|     libxml2 \ | ||||
|     libxslt \ | ||||
|     ffmpeg \ | ||||
|     file \ | ||||
|     imagemagick \ | ||||
|  && npm install -g npm@3 && npm install -g yarn \ | ||||
|  && bundle install --deployment --without test development \ | ||||
|  && yarn \ | ||||
|  && npm cache clean \ | ||||
|  && apk del $BUILD_DEPS \ | ||||
|  && rm -rf /tmp/* /var/cache/apk/* | ||||
| 
 | ||||
| ADD . /mastodon | ||||
| 
 | ||||
| VOLUME ["/mastodon/public/system", "/mastodon/public/assets"] | ||||
| VOLUME /mastodon/public/system /mastodon/public/assets | ||||
|  |  | |||
							
								
								
									
										3
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								Gemfile
									
									
									
									
									
								
							|  | @ -50,6 +50,8 @@ gem 'rails-settings-cached' | |||
| gem 'simple-navigation' | ||||
| gem 'statsd-instrument' | ||||
| gem 'ruby-oembed', require: 'oembed' | ||||
| gem 'rack-timeout' | ||||
| gem 'tzinfo-data' | ||||
| 
 | ||||
| gem 'react-rails' | ||||
| gem 'browserify-rails' | ||||
|  | @ -89,5 +91,4 @@ group :production do | |||
|   gem 'rails_12factor' | ||||
|   gem 'redis-rails' | ||||
|   gem 'lograge' | ||||
|   gem 'rack-timeout' | ||||
| end | ||||
|  |  | |||
|  | @ -423,6 +423,8 @@ GEM | |||
|       unf (~> 0.1.0) | ||||
|     tzinfo (1.2.2) | ||||
|       thread_safe (~> 0.1) | ||||
|     tzinfo-data (1.2017.2) | ||||
|       tzinfo (>= 1.0.0) | ||||
|     uglifier (3.0.1) | ||||
|       execjs (>= 0.3.0, < 3) | ||||
|     unf (0.1.4) | ||||
|  | @ -513,6 +515,7 @@ DEPENDENCIES | |||
|   simplecov | ||||
|   statsd-instrument | ||||
|   twitter-text | ||||
|   tzinfo-data | ||||
|   uglifier (>= 1.3.0) | ||||
|   webmock | ||||
|   will_paginate | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ Mastodon | |||
| [travis]: https://travis-ci.org/tootsuite/mastodon | ||||
| [code_climate]: https://codeclimate.com/github/tootsuite/mastodon | ||||
| 
 | ||||
| Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly. | ||||
| Mastodon is a free, open-source social network server. A decentralized solution to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly. | ||||
| 
 | ||||
| An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon. | ||||
| 
 | ||||
|  |  | |||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 59 KiB | 
|  | @ -579,15 +579,18 @@ export function expandFollowingFail(id, error) { | |||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function fetchRelationships(account_ids) { | ||||
| export function fetchRelationships(accountIds) { | ||||
|   return (dispatch, getState) => { | ||||
|     if (account_ids.length === 0) { | ||||
|     const loadedRelationships = getState().get('relationships'); | ||||
|     const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null); | ||||
| 
 | ||||
|     if (newAccountIds.length === 0) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     dispatch(fetchRelationshipsRequest(account_ids)); | ||||
|     dispatch(fetchRelationshipsRequest(newAccountIds)); | ||||
| 
 | ||||
|     api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => { | ||||
|     api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { | ||||
|       dispatch(fetchRelationshipsSuccess(response.data)); | ||||
|     }).catch(error => { | ||||
|       dispatch(fetchRelationshipsFail(error)); | ||||
|  |  | |||
|  | @ -1,14 +1,11 @@ | |||
| export const MEDIA_OPEN  = 'MEDIA_OPEN'; | ||||
| export const MODAL_OPEN  = 'MODAL_OPEN'; | ||||
| export const MODAL_CLOSE = 'MODAL_CLOSE'; | ||||
| 
 | ||||
| export const MODAL_INDEX_DECREASE = 'MODAL_INDEX_DECREASE'; | ||||
| export const MODAL_INDEX_INCREASE = 'MODAL_INDEX_INCREASE'; | ||||
| 
 | ||||
| export function openMedia(media, index) { | ||||
| export function openModal(type, props) { | ||||
|   return { | ||||
|     type: MEDIA_OPEN, | ||||
|     media, | ||||
|     index | ||||
|     type: MODAL_OPEN, | ||||
|     modalType: type, | ||||
|     modalProps: props | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
|  | @ -17,15 +14,3 @@ export function closeModal() { | |||
|     type: MODAL_CLOSE | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function decreaseIndexInModal() { | ||||
|   return { | ||||
|     type: MODAL_INDEX_DECREASE | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function increaseIndexInModal() { | ||||
|   return { | ||||
|     type: MODAL_INDEX_INCREASE | ||||
|   }; | ||||
| }; | ||||
|  |  | |||
|  | @ -1,9 +1,12 @@ | |||
| import api from '../api' | ||||
| 
 | ||||
| export const SEARCH_CHANGE            = 'SEARCH_CHANGE'; | ||||
| export const SEARCH_SUGGESTIONS_CLEAR = 'SEARCH_SUGGESTIONS_CLEAR'; | ||||
| export const SEARCH_SUGGESTIONS_READY = 'SEARCH_SUGGESTIONS_READY'; | ||||
| export const SEARCH_RESET             = 'SEARCH_RESET'; | ||||
| export const SEARCH_CHANGE = 'SEARCH_CHANGE'; | ||||
| export const SEARCH_CLEAR  = 'SEARCH_CLEAR'; | ||||
| export const SEARCH_SHOW   = 'SEARCH_SHOW'; | ||||
| 
 | ||||
| export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; | ||||
| export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; | ||||
| export const SEARCH_FETCH_FAIL    = 'SEARCH_FETCH_FAIL'; | ||||
| 
 | ||||
| export function changeSearch(value) { | ||||
|   return { | ||||
|  | @ -12,42 +15,59 @@ export function changeSearch(value) { | |||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function clearSearchSuggestions() { | ||||
| export function clearSearch() { | ||||
|   return { | ||||
|     type: SEARCH_SUGGESTIONS_CLEAR | ||||
|     type: SEARCH_CLEAR | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function readySearchSuggestions(value, { accounts, hashtags, statuses }) { | ||||
|   return { | ||||
|     type: SEARCH_SUGGESTIONS_READY, | ||||
|     value, | ||||
|     accounts, | ||||
|     hashtags, | ||||
|     statuses | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function fetchSearchSuggestions(value) { | ||||
| export function submitSearch() { | ||||
|   return (dispatch, getState) => { | ||||
|     if (getState().getIn(['search', 'loaded_value']) === value) { | ||||
|     const value = getState().getIn(['search', 'value']); | ||||
| 
 | ||||
|     if (value.length === 0) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     dispatch(fetchSearchRequest()); | ||||
| 
 | ||||
|     api(getState).get('/api/v1/search', { | ||||
|       params: { | ||||
|         q: value, | ||||
|         resolve: true, | ||||
|         limit: 4 | ||||
|         resolve: true | ||||
|       } | ||||
|     }).then(response => { | ||||
|       dispatch(readySearchSuggestions(value, response.data)); | ||||
|       dispatch(fetchSearchSuccess(response.data)); | ||||
|     }).catch(error => { | ||||
|       dispatch(fetchSearchFail(error)); | ||||
|     }); | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function resetSearch() { | ||||
| export function fetchSearchRequest() { | ||||
|   return { | ||||
|     type: SEARCH_RESET | ||||
|     type: SEARCH_FETCH_REQUEST | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function fetchSearchSuccess(results) { | ||||
|   return { | ||||
|     type: SEARCH_FETCH_SUCCESS, | ||||
|     results, | ||||
|     accounts: results.accounts, | ||||
|     statuses: results.statuses | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function fetchSearchFail(error) { | ||||
|   return { | ||||
|     type: SEARCH_FETCH_FAIL, | ||||
|     error | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function showSearch() { | ||||
|   return { | ||||
|     type: SEARCH_SHOW | ||||
|   }; | ||||
| }; | ||||
|  |  | |||
|  | @ -14,6 +14,9 @@ export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL'; | |||
| 
 | ||||
| export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; | ||||
| 
 | ||||
| export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT'; | ||||
| export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; | ||||
| 
 | ||||
| export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { | ||||
|   return { | ||||
|     type: TIMELINE_REFRESH_SUCCESS, | ||||
|  | @ -76,6 +79,11 @@ export function refreshTimeline(timeline, id = null) { | |||
|     let skipLoading = false; | ||||
| 
 | ||||
|     if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded']) && (id === null || getState().getIn(['timelines', timeline, 'id']) === id)) { | ||||
|       if (id === null && getState().getIn(['timelines', timeline, 'online'])) { | ||||
|         // Skip refreshing when timeline is live anyway | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       params          = { ...params, since_id: newestId }; | ||||
|       skipLoading     = true; | ||||
|     } | ||||
|  | @ -162,3 +170,17 @@ export function scrollTopTimeline(timeline, top) { | |||
|     top | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function connectTimeline(timeline) { | ||||
|   return { | ||||
|     type: TIMELINE_CONNECT, | ||||
|     timeline | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function disconnectTimeline(timeline) { | ||||
|   return { | ||||
|     type: TIMELINE_DISCONNECT, | ||||
|     timeline | ||||
|   }; | ||||
| }; | ||||
|  |  | |||
|  | @ -1,82 +0,0 @@ | |||
| import PureRenderMixin from 'react-addons-pure-render-mixin'; | ||||
| import IconButton from './icon_button'; | ||||
| import { Motion, spring } from 'react-motion'; | ||||
| import { injectIntl } from 'react-intl'; | ||||
| 
 | ||||
| const overlayStyle = { | ||||
|   position: 'fixed', | ||||
|   top: '0', | ||||
|   left: '0', | ||||
|   width: '100%', | ||||
|   height: '100%', | ||||
|   background: 'rgba(0, 0, 0, 0.5)', | ||||
|   display: 'flex', | ||||
|   justifyContent: 'center', | ||||
|   alignContent: 'center', | ||||
|   flexDirection: 'row', | ||||
|   zIndex: '9999' | ||||
| }; | ||||
| 
 | ||||
| const dialogStyle = { | ||||
|   color: '#282c37', | ||||
|   boxShadow: '0 0 30px rgba(0, 0, 0, 0.8)', | ||||
|   margin: 'auto', | ||||
|   position: 'relative' | ||||
| }; | ||||
| 
 | ||||
| const closeStyle = { | ||||
|   position: 'absolute', | ||||
|   top: '4px', | ||||
|   right: '4px' | ||||
| }; | ||||
| 
 | ||||
| const Lightbox = React.createClass({ | ||||
| 
 | ||||
|   propTypes: { | ||||
|     isVisible: React.PropTypes.bool, | ||||
|     onOverlayClicked: React.PropTypes.func, | ||||
|     onCloseClicked: React.PropTypes.func, | ||||
|     intl: React.PropTypes.object.isRequired, | ||||
|     children: React.PropTypes.node | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
| 
 | ||||
|   componentDidMount () { | ||||
|     this._listener = e => { | ||||
|       if (this.props.isVisible && e.key === 'Escape') { | ||||
|         this.props.onCloseClicked(); | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     window.addEventListener('keyup', this._listener); | ||||
|   }, | ||||
| 
 | ||||
|   componentWillUnmount () { | ||||
|     window.removeEventListener('keyup', this._listener); | ||||
|   }, | ||||
| 
 | ||||
|   stopPropagation (e) { | ||||
|     e.stopPropagation(); | ||||
|   }, | ||||
| 
 | ||||
|   render () { | ||||
|     const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props; | ||||
| 
 | ||||
|     return ( | ||||
|       <Motion defaultStyle={{ backgroundOpacity: 0, opacity: 0, y: -400 }} style={{ backgroundOpacity: spring(isVisible ? 50 : 0), opacity: isVisible ? spring(200) : 0, y: spring(isVisible ? 0 : -400, { stiffness: 150, damping: 12 }) }}> | ||||
|         {({ backgroundOpacity, opacity, y }) => | ||||
|           <div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex', pointerEvents: !isVisible ? 'none' : 'auto'}} onClick={onOverlayClicked}> | ||||
|             <div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }} onClick={this.stopPropagation}> | ||||
|               <IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} /> | ||||
|               {children} | ||||
|             </div> | ||||
|           </div> | ||||
|         } | ||||
|       </Motion> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default injectIntl(Lightbox); | ||||
|  | @ -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 @{name}' }, | ||||
|   mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, | ||||
|   block: { id: 'account.block', defaultMessage: 'Block @{name}' }, | ||||
|   reply: { id: 'status.reply', defaultMessage: 'Reply' }, | ||||
|   reblog: { id: 'status.reblog', defaultMessage: 'Reblog' }, | ||||
|  | @ -28,6 +29,7 @@ const StatusActionBar = React.createClass({ | |||
|     onReblog: React.PropTypes.func, | ||||
|     onDelete: React.PropTypes.func, | ||||
|     onMention: React.PropTypes.func, | ||||
|     onMute: React.PropTypes.func, | ||||
|     onBlock: React.PropTypes.func, | ||||
|     onReport: React.PropTypes.func, | ||||
|     me: React.PropTypes.number.isRequired, | ||||
|  | @ -56,6 +58,10 @@ const StatusActionBar = React.createClass({ | |||
|     this.props.onMention(this.props.status.get('account'), this.context.router); | ||||
|   }, | ||||
| 
 | ||||
|   handleMuteClick () { | ||||
|     this.props.onMute(this.props.status.get('account')); | ||||
|   }, | ||||
| 
 | ||||
|   handleBlockClick () { | ||||
|     this.props.onBlock(this.props.status.get('account')); | ||||
|   }, | ||||
|  | @ -81,6 +87,7 @@ const StatusActionBar = React.createClass({ | |||
|     } else { | ||||
|       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); | ||||
|       menu.push(null); | ||||
|       menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); | ||||
|       menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); | ||||
|       menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); | ||||
|     } | ||||
|  |  | |||
|  | @ -23,6 +23,8 @@ const muteStyle = { | |||
|   position: 'absolute', | ||||
|   top: '10px', | ||||
|   right: '10px', | ||||
|   color: 'white', | ||||
|   textShadow: "0px 1px 1px black, 1px 0px 1px black", | ||||
|   opacity: '0.8', | ||||
|   zIndex: '5' | ||||
| }; | ||||
|  | @ -54,6 +56,8 @@ const spoilerButtonStyle = { | |||
|   position: 'absolute', | ||||
|   top: '6px', | ||||
|   left: '8px', | ||||
|   color: 'white', | ||||
|   textShadow: "0px 1px 1px black, 1px 0px 1px black", | ||||
|   zIndex: '100' | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -4,7 +4,9 @@ import { | |||
|   refreshTimelineSuccess, | ||||
|   updateTimeline, | ||||
|   deleteFromTimelines, | ||||
|   refreshTimeline | ||||
|   refreshTimeline, | ||||
|   connectTimeline, | ||||
|   disconnectTimeline | ||||
| } from '../actions/timelines'; | ||||
| import { updateNotifications, refreshNotifications } from '../actions/notifications'; | ||||
| import createBrowserHistory from 'history/lib/createBrowserHistory'; | ||||
|  | @ -44,6 +46,7 @@ import fr from 'react-intl/locale-data/fr'; | |||
| import pt from 'react-intl/locale-data/pt'; | ||||
| import hu from 'react-intl/locale-data/hu'; | ||||
| import uk from 'react-intl/locale-data/uk'; | ||||
| import fi from 'react-intl/locale-data/fi'; | ||||
| import getMessagesForLocale from '../locales'; | ||||
| import { hydrateStore } from '../actions/store'; | ||||
| import createStream from '../stream'; | ||||
|  | @ -56,7 +59,7 @@ const browserHistory = useRouterHistory(createBrowserHistory)({ | |||
|   basename: '/web' | ||||
| }); | ||||
| 
 | ||||
| addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk]); | ||||
| addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk, ...fi]); | ||||
| 
 | ||||
| const Mastodon = React.createClass({ | ||||
| 
 | ||||
|  | @ -70,6 +73,14 @@ const Mastodon = React.createClass({ | |||
| 
 | ||||
|     this.subscription = createStream(accessToken, 'user', { | ||||
| 
 | ||||
|       connected () { | ||||
|         store.dispatch(connectTimeline('home')); | ||||
|       }, | ||||
| 
 | ||||
|       disconnected () { | ||||
|         store.dispatch(disconnectTimeline('home')); | ||||
|       }, | ||||
| 
 | ||||
|       received (data) { | ||||
|         switch(data.event) { | ||||
|         case 'update': | ||||
|  | @ -85,6 +96,7 @@ const Mastodon = React.createClass({ | |||
|       }, | ||||
| 
 | ||||
|       reconnected () { | ||||
|         store.dispatch(connectTimeline('home')); | ||||
|         store.dispatch(refreshTimeline('home')); | ||||
|         store.dispatch(refreshNotifications()); | ||||
|       } | ||||
|  |  | |||
|  | @ -17,7 +17,7 @@ import { | |||
| } from '../actions/accounts'; | ||||
| import { deleteStatus } from '../actions/statuses'; | ||||
| import { initReport } from '../actions/reports'; | ||||
| import { openMedia } from '../actions/modal'; | ||||
| import { openModal } from '../actions/modal'; | ||||
| import { createSelector } from 'reselect' | ||||
| import { isMobile } from '../is_mobile' | ||||
| 
 | ||||
|  | @ -63,7 +63,7 @@ const mapDispatchToProps = (dispatch) => ({ | |||
|   }, | ||||
| 
 | ||||
|   onOpenMedia (media, index) { | ||||
|     dispatch(openMedia(media, index)); | ||||
|     dispatch(openModal('MEDIA', { media, index })); | ||||
|   }, | ||||
| 
 | ||||
|   onBlock (account) { | ||||
|  |  | |||
|  | @ -4,6 +4,7 @@ import emojify from '../../../emoji'; | |||
| import escapeTextContentForBrowser from 'escape-html'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import IconButton from '../../../components/icon_button'; | ||||
| import { Motion, spring } from 'react-motion'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, | ||||
|  | @ -11,6 +12,47 @@ const messages = defineMessages({ | |||
|   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' } | ||||
| }); | ||||
| 
 | ||||
| const Avatar = React.createClass({ | ||||
| 
 | ||||
|   propTypes: { | ||||
|     account: ImmutablePropTypes.map.isRequired | ||||
|   }, | ||||
| 
 | ||||
|   getInitialState () { | ||||
|     return { | ||||
|       isHovered: false | ||||
|     }; | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
| 
 | ||||
|   handleMouseOver () { | ||||
|     if (this.state.isHovered) return; | ||||
|     this.setState({ isHovered: true }); | ||||
|   }, | ||||
| 
 | ||||
|   handleMouseOut () { | ||||
|     if (!this.state.isHovered) return; | ||||
|     this.setState({ isHovered: false }); | ||||
|   }, | ||||
| 
 | ||||
|   render () { | ||||
|     const { account }   = this.props; | ||||
|     const { isHovered } = this.state; | ||||
| 
 | ||||
|     return ( | ||||
|       <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}> | ||||
|         {({ radius }) => | ||||
|           <a href={account.get('url')} className='account__header__avatar' target='_blank' rel='noopener' style={{ display: 'block', width: '90px', height: '90px', margin: '0 auto', marginBottom: '10px', borderRadius: `${radius}px`, overflow: 'hidden' }} onMouseOver={this.handleMouseOver} onMouseOut={this.handleMouseOut}> | ||||
|             <img src={account.get('avatar')} alt={account.get('acct')} style={{ display: 'block', width: '90px', height: '90px' }} /> | ||||
|           </a> | ||||
|         } | ||||
|       </Motion> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| const Header = React.createClass({ | ||||
| 
 | ||||
|   propTypes: { | ||||
|  | @ -68,14 +110,9 @@ const Header = React.createClass({ | |||
|     return ( | ||||
|       <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> | ||||
|         <div style={{ padding: '20px 10px' }}> | ||||
|           <a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}> | ||||
|             <div className='account__header__avatar' style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}> | ||||
|               <img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} /> | ||||
|             </div> | ||||
| 
 | ||||
|             <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} /> | ||||
|           </a> | ||||
|           <Avatar account={account} /> | ||||
| 
 | ||||
|           <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} /> | ||||
|           <span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span> | ||||
|           <div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} /> | ||||
| 
 | ||||
|  |  | |||
|  | @ -5,7 +5,9 @@ import Column from '../ui/components/column'; | |||
| import { | ||||
|   refreshTimeline, | ||||
|   updateTimeline, | ||||
|   deleteFromTimelines | ||||
|   deleteFromTimelines, | ||||
|   connectTimeline, | ||||
|   disconnectTimeline | ||||
| } from '../../actions/timelines'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import ColumnBackButtonSlim from '../../components/column_back_button_slim'; | ||||
|  | @ -44,6 +46,18 @@ const CommunityTimeline = React.createClass({ | |||
| 
 | ||||
|     subscription = createStream(accessToken, 'public:local', { | ||||
| 
 | ||||
|       connected () { | ||||
|         dispatch(connectTimeline('community')); | ||||
|       }, | ||||
| 
 | ||||
|       reconnected () { | ||||
|         dispatch(connectTimeline('community')); | ||||
|       }, | ||||
| 
 | ||||
|       disconnected () { | ||||
|         dispatch(disconnectTimeline('community')); | ||||
|       }, | ||||
| 
 | ||||
|       received (data) { | ||||
|         switch(data.event) { | ||||
|         case 'update': | ||||
|  |  | |||
|  | @ -1,44 +0,0 @@ | |||
| import { Link } from 'react-router'; | ||||
| import { injectIntl, defineMessages } from 'react-intl'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, | ||||
|   public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' }, | ||||
|   community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, | ||||
|   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, | ||||
|   logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' } | ||||
| }); | ||||
| 
 | ||||
| const Drawer = ({ children, withHeader, intl }) => { | ||||
|   let header = ''; | ||||
| 
 | ||||
|   if (withHeader) { | ||||
|     header = ( | ||||
|       <div className='drawer__header'> | ||||
|         <Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link> | ||||
|         <Link title={intl.formatMessage(messages.community)} className='drawer__tab' to='/timelines/public/local'><i className='fa fa-fw fa-users' /></Link> | ||||
|         <Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link> | ||||
|         <a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a> | ||||
|         <a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className='drawer'> | ||||
|       {header} | ||||
| 
 | ||||
|       <div className='drawer__inner'> | ||||
|         {children} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| Drawer.propTypes = { | ||||
|   withHeader: React.PropTypes.bool, | ||||
|   children: React.PropTypes.node, | ||||
|   intl: React.PropTypes.object | ||||
| }; | ||||
| 
 | ||||
| export default injectIntl(Drawer); | ||||
|  | @ -1,123 +1,68 @@ | |||
| import PureRenderMixin from 'react-addons-pure-render-mixin'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import Autosuggest from 'react-autosuggest'; | ||||
| import AutosuggestAccountContainer from '../containers/autosuggest_account_container'; | ||||
| import AutosuggestStatusContainer from '../containers/autosuggest_status_container'; | ||||
| import { debounce } from 'react-decoration'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   placeholder: { id: 'search.placeholder', defaultMessage: 'Search' } | ||||
| }); | ||||
| 
 | ||||
| const getSuggestionValue = suggestion => suggestion.value; | ||||
| 
 | ||||
| const renderSuggestion = suggestion => { | ||||
|   if (suggestion.type === 'account') { | ||||
|     return <AutosuggestAccountContainer id={suggestion.id} />; | ||||
|   } else if (suggestion.type === 'hashtag') { | ||||
|     return <span>#{suggestion.id}</span>; | ||||
|   } else { | ||||
|     return <AutosuggestStatusContainer id={suggestion.id} />; | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| const renderSectionTitle = section => ( | ||||
|   <strong><FormattedMessage id={`search.${section.title}`} defaultMessage={section.title} /></strong> | ||||
| ); | ||||
| 
 | ||||
| const getSectionSuggestions = section => section.items; | ||||
| 
 | ||||
| const outerStyle = { | ||||
|   padding: '10px', | ||||
|   lineHeight: '20px', | ||||
|   position: 'relative' | ||||
| }; | ||||
| 
 | ||||
| const iconStyle = { | ||||
|   position: 'absolute', | ||||
|   top: '18px', | ||||
|   right: '20px', | ||||
|   fontSize: '18px', | ||||
|   pointerEvents: 'none' | ||||
| }; | ||||
| 
 | ||||
| const Search = React.createClass({ | ||||
| 
 | ||||
|   contextTypes: { | ||||
|     router: React.PropTypes.object | ||||
|   }, | ||||
| 
 | ||||
|   propTypes: { | ||||
|     suggestions: React.PropTypes.array.isRequired, | ||||
|     value: React.PropTypes.string.isRequired, | ||||
|     submitted: React.PropTypes.bool, | ||||
|     onChange: React.PropTypes.func.isRequired, | ||||
|     onSubmit: React.PropTypes.func.isRequired, | ||||
|     onClear: React.PropTypes.func.isRequired, | ||||
|     onFetch: React.PropTypes.func.isRequired, | ||||
|     onReset: React.PropTypes.func.isRequired, | ||||
|     onShow: React.PropTypes.func.isRequired, | ||||
|     intl: React.PropTypes.object.isRequired | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
| 
 | ||||
|   onChange (_, { newValue }) { | ||||
|     if (typeof newValue !== 'string') { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.props.onChange(newValue); | ||||
|   handleChange (e) { | ||||
|     this.props.onChange(e.target.value); | ||||
|   }, | ||||
| 
 | ||||
|   onSuggestionsClearRequested () { | ||||
|   handleClear (e) { | ||||
|     e.preventDefault(); | ||||
|     this.props.onClear(); | ||||
|   }, | ||||
| 
 | ||||
|   @debounce(500) | ||||
|   onSuggestionsFetchRequested ({ value }) { | ||||
|     value = value.replace('#', ''); | ||||
|     this.props.onFetch(value.trim()); | ||||
|   }, | ||||
| 
 | ||||
|   onSuggestionSelected (_, { suggestion }) { | ||||
|     if (suggestion.type === 'account') { | ||||
|       this.context.router.push(`/accounts/${suggestion.id}`); | ||||
|     } else if(suggestion.type === 'hashtag') { | ||||
|       this.context.router.push(`/timelines/tag/${suggestion.id}`); | ||||
|     } else { | ||||
|       this.context.router.push(`/statuses/${suggestion.id}`); | ||||
|   handleKeyDown (e) { | ||||
|     if (e.key === 'Enter') { | ||||
|       e.preventDefault(); | ||||
|       this.props.onSubmit(); | ||||
|     } | ||||
|   }, | ||||
| 
 | ||||
|   handleFocus () { | ||||
|     this.props.onShow(); | ||||
|   }, | ||||
| 
 | ||||
|   render () { | ||||
|     const inputProps = { | ||||
|       placeholder: this.props.intl.formatMessage(messages.placeholder), | ||||
|       value: this.props.value, | ||||
|       onChange: this.onChange, | ||||
|       className: 'search__input' | ||||
|     }; | ||||
|     const { intl, value, submitted } = this.props; | ||||
|     const hasValue = value.length > 0 || submitted; | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='search' style={outerStyle}> | ||||
|         <Autosuggest | ||||
|           multiSection={true} | ||||
|           suggestions={this.props.suggestions} | ||||
|           focusFirstSuggestion={true} | ||||
|           focusInputOnSuggestionClick={false} | ||||
|           alwaysRenderSuggestions={false} | ||||
|           onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} | ||||
|           onSuggestionsClearRequested={this.onSuggestionsClearRequested} | ||||
|           onSuggestionSelected={this.onSuggestionSelected} | ||||
|           getSuggestionValue={getSuggestionValue} | ||||
|           renderSuggestion={renderSuggestion} | ||||
|           renderSectionTitle={renderSectionTitle} | ||||
|           getSectionSuggestions={getSectionSuggestions} | ||||
|           inputProps={inputProps} | ||||
|       <div className='search'> | ||||
|         <input | ||||
|           className='search__input' | ||||
|           type='text' | ||||
|           placeholder={intl.formatMessage(messages.placeholder)} | ||||
|           value={value} | ||||
|           onChange={this.handleChange} | ||||
|           onKeyUp={this.handleKeyDown} | ||||
|           onFocus={this.handleFocus} | ||||
|         /> | ||||
| 
 | ||||
|         <div style={iconStyle}><i className='fa fa-search' /></div> | ||||
|         <div className='search__icon'> | ||||
|           <i className={`fa fa-search ${hasValue ? '' : 'active'}`} /> | ||||
|           <i className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} onClick={this.handleClear} /> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   }, | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -0,0 +1,68 @@ | |||
| import PureRenderMixin from 'react-addons-pure-render-mixin'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import AccountContainer from '../../../containers/account_container'; | ||||
| import StatusContainer from '../../../containers/status_container'; | ||||
| import { Link } from 'react-router'; | ||||
| 
 | ||||
| const SearchResults = React.createClass({ | ||||
| 
 | ||||
|   propTypes: { | ||||
|     results: ImmutablePropTypes.map.isRequired | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
| 
 | ||||
|   render () { | ||||
|     const { results } = this.props; | ||||
| 
 | ||||
|     let accounts, statuses, hashtags; | ||||
|     let count = 0; | ||||
| 
 | ||||
|     if (results.get('accounts') && results.get('accounts').size > 0) { | ||||
|       count   += results.get('accounts').size; | ||||
|       accounts = ( | ||||
|         <div className='search-results__section'> | ||||
|           {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)} | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     if (results.get('statuses') && results.get('statuses').size > 0) { | ||||
|       count   += results.get('statuses').size; | ||||
|       statuses = ( | ||||
|         <div className='search-results__section'> | ||||
|           {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)} | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     if (results.get('hashtags') && results.get('hashtags').size > 0) { | ||||
|       count += results.get('hashtags').size; | ||||
|       hashtags = ( | ||||
|         <div className='search-results__section'> | ||||
|           {results.get('hashtags').map(hashtag => | ||||
|             <Link className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}> | ||||
|               #{hashtag} | ||||
|             </Link> | ||||
|           )} | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='search-results'> | ||||
|         <div className='search-results__header'> | ||||
|           <FormattedMessage id='search_results.total' defaultMessage='{count} {count, plural, one {result} other {results}}' values={{ count }} /> | ||||
|         </div> | ||||
| 
 | ||||
|         {accounts} | ||||
|         {statuses} | ||||
|         {hashtags} | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default SearchResults; | ||||
|  | @ -1,31 +0,0 @@ | |||
| import PureRenderMixin from 'react-addons-pure-render-mixin'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| import Toggle from 'react-toggle'; | ||||
| import Collapsable from '../../../components/collapsable'; | ||||
| 
 | ||||
| const SensitiveToggle = React.createClass({ | ||||
| 
 | ||||
|   propTypes: { | ||||
|     hasMedia: React.PropTypes.bool, | ||||
|     isSensitive: React.PropTypes.bool, | ||||
|     onChange: React.PropTypes.func.isRequired | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
| 
 | ||||
|   render () { | ||||
|     const { hasMedia, isSensitive, onChange } = this.props; | ||||
| 
 | ||||
|     return ( | ||||
|       <Collapsable isVisible={hasMedia} fullHeight={39.5}> | ||||
|         <label className='compose-form__label'> | ||||
|           <Toggle checked={isSensitive} onChange={onChange} /> | ||||
|           <span className='compose-form__label__text'><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark media as sensitive' /></span> | ||||
|         </label> | ||||
|       </Collapsable> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default SensitiveToggle; | ||||
|  | @ -1,27 +0,0 @@ | |||
| import PureRenderMixin from 'react-addons-pure-render-mixin'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| import Toggle from 'react-toggle'; | ||||
| 
 | ||||
| const SpoilerToggle = React.createClass({ | ||||
| 
 | ||||
|   propTypes: { | ||||
|     isSpoiler: React.PropTypes.bool, | ||||
|     onChange: React.PropTypes.func.isRequired | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
| 
 | ||||
|   render () { | ||||
|     const { isSpoiler, onChange } = this.props; | ||||
| 
 | ||||
|     return ( | ||||
|       <label className='compose-form__label with-border' style={{ marginTop: '10px' }}> | ||||
|         <Toggle checked={isSpoiler} onChange={onChange} /> | ||||
|         <span className='compose-form__label__text'><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span> | ||||
|       </label> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default SpoilerToggle; | ||||
|  | @ -1,15 +1,15 @@ | |||
| import { connect } from 'react-redux'; | ||||
| import { | ||||
|   changeSearch, | ||||
|   clearSearchSuggestions, | ||||
|   fetchSearchSuggestions, | ||||
|   resetSearch | ||||
|   clearSearch, | ||||
|   submitSearch, | ||||
|   showSearch | ||||
| } from '../../../actions/search'; | ||||
| import Search from '../components/search'; | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   suggestions: state.getIn(['search', 'suggestions']), | ||||
|   value: state.getIn(['search', 'value']) | ||||
|   value: state.getIn(['search', 'value']), | ||||
|   submitted: state.getIn(['search', 'submitted']) | ||||
| }); | ||||
| 
 | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
|  | @ -19,15 +19,15 @@ const mapDispatchToProps = dispatch => ({ | |||
|   }, | ||||
| 
 | ||||
|   onClear () { | ||||
|     dispatch(clearSearchSuggestions()); | ||||
|     dispatch(clearSearch()); | ||||
|   }, | ||||
| 
 | ||||
|   onFetch (value) { | ||||
|     dispatch(fetchSearchSuggestions(value)); | ||||
|   onSubmit () { | ||||
|     dispatch(submitSearch()); | ||||
|   }, | ||||
| 
 | ||||
|   onReset () { | ||||
|     dispatch(resetSearch()); | ||||
|   onShow () { | ||||
|     dispatch(showSearch()); | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
|  |  | |||
|  | @ -0,0 +1,8 @@ | |||
| import { connect } from 'react-redux'; | ||||
| import SearchResults from '../components/search_results'; | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   results: state.getIn(['search', 'results']) | ||||
| }); | ||||
| 
 | ||||
| export default connect(mapStateToProps)(SearchResults); | ||||
|  | @ -1,17 +1,34 @@ | |||
| import Drawer from './components/drawer'; | ||||
| import ComposeFormContainer from './containers/compose_form_container'; | ||||
| import UploadFormContainer from './containers/upload_form_container'; | ||||
| import NavigationContainer from './containers/navigation_container'; | ||||
| import PureRenderMixin from 'react-addons-pure-render-mixin'; | ||||
| import SearchContainer from './containers/search_container'; | ||||
| import { connect } from 'react-redux'; | ||||
| import { mountCompose, unmountCompose } from '../../actions/compose'; | ||||
| import { Link } from 'react-router'; | ||||
| import { injectIntl, defineMessages } from 'react-intl'; | ||||
| import SearchContainer from './containers/search_container'; | ||||
| import { Motion, spring } from 'react-motion'; | ||||
| import SearchResultsContainer from './containers/search_results_container'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, | ||||
|   public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' }, | ||||
|   community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, | ||||
|   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, | ||||
|   logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' } | ||||
| }); | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) | ||||
| }); | ||||
| 
 | ||||
| const Compose = React.createClass({ | ||||
| 
 | ||||
|   propTypes: { | ||||
|     dispatch: React.PropTypes.func.isRequired, | ||||
|     withHeader: React.PropTypes.bool | ||||
|     withHeader: React.PropTypes.bool, | ||||
|     showSearch: React.PropTypes.bool, | ||||
|     intl: React.PropTypes.object.isRequired | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
|  | @ -25,15 +42,46 @@ const Compose = React.createClass({ | |||
|   }, | ||||
| 
 | ||||
|   render () { | ||||
|     const { withHeader, showSearch, intl } = this.props; | ||||
| 
 | ||||
|     let header = ''; | ||||
| 
 | ||||
|     if (withHeader) { | ||||
|       header = ( | ||||
|         <div className='drawer__header'> | ||||
|           <Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link> | ||||
|           <Link title={intl.formatMessage(messages.community)} className='drawer__tab' to='/timelines/public/local'><i className='fa fa-fw fa-users' /></Link> | ||||
|           <Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link> | ||||
|           <a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a> | ||||
|           <a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a> | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <Drawer withHeader={this.props.withHeader}> | ||||
|       <div className='drawer'> | ||||
|         {header} | ||||
| 
 | ||||
|         <SearchContainer /> | ||||
|         <NavigationContainer /> | ||||
|         <ComposeFormContainer /> | ||||
|       </Drawer> | ||||
| 
 | ||||
|         <div className='drawer__pager'> | ||||
|           <div className='drawer__inner'> | ||||
|             <NavigationContainer /> | ||||
|             <ComposeFormContainer /> | ||||
|           </div> | ||||
| 
 | ||||
|           <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> | ||||
|             {({ x }) => | ||||
|               <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> | ||||
|                 <SearchResultsContainer /> | ||||
|               </div> | ||||
|             } | ||||
|           </Motion> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default connect()(Compose); | ||||
| export default connect(mapStateToProps)(injectIntl(Compose)); | ||||
|  |  | |||
|  | @ -43,9 +43,7 @@ const GettingStarted = ({ intl, me }) => { | |||
| 
 | ||||
|       <div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}> | ||||
|         <div className='static-content getting-started'> | ||||
|           <p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p> | ||||
|           <p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p> | ||||
|           <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p> | ||||
|           <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p> | ||||
|         </div> | ||||
|       </div> | ||||
|     </Column> | ||||
|  |  | |||
|  | @ -6,7 +6,7 @@ import SettingToggle from '../../notifications/components/setting_toggle'; | |||
| import SettingText from './setting_text'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter by regular expressions' } | ||||
|   filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' } | ||||
| }); | ||||
| 
 | ||||
| const outerStyle = { | ||||
|  | @ -44,7 +44,7 @@ const ColumnSettings = React.createClass({ | |||
|           <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> | ||||
| 
 | ||||
|           <div style={rowStyle}> | ||||
|             <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reblogs' />} /> | ||||
|             <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} /> | ||||
|           </div> | ||||
| 
 | ||||
|           <div style={rowStyle}> | ||||
|  |  | |||
|  | @ -5,7 +5,9 @@ import Column from '../ui/components/column'; | |||
| import { | ||||
|   refreshTimeline, | ||||
|   updateTimeline, | ||||
|   deleteFromTimelines | ||||
|   deleteFromTimelines, | ||||
|   connectTimeline, | ||||
|   disconnectTimeline | ||||
| } from '../../actions/timelines'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import ColumnBackButtonSlim from '../../components/column_back_button_slim'; | ||||
|  | @ -44,6 +46,18 @@ const PublicTimeline = React.createClass({ | |||
| 
 | ||||
|     subscription = createStream(accessToken, 'public', { | ||||
| 
 | ||||
|       connected () { | ||||
|         dispatch(connectTimeline('public')); | ||||
|       }, | ||||
| 
 | ||||
|       reconnected () { | ||||
|         dispatch(connectTimeline('public')); | ||||
|       }, | ||||
| 
 | ||||
|       disconnected () { | ||||
|         dispatch(disconnectTimeline('public')); | ||||
|       }, | ||||
| 
 | ||||
|       received (data) { | ||||
|         switch(data.event) { | ||||
|         case 'update': | ||||
|  |  | |||
|  | @ -28,7 +28,7 @@ import { | |||
| import { ScrollContainer } from 'react-router-scroll'; | ||||
| import ColumnBackButton from '../../components/column_back_button'; | ||||
| import StatusContainer from '../../containers/status_container'; | ||||
| import { openMedia } from '../../actions/modal'; | ||||
| import { openModal } from '../../actions/modal'; | ||||
| import { isMobile } from '../../is_mobile' | ||||
| 
 | ||||
| const makeMapStateToProps = () => { | ||||
|  | @ -99,7 +99,7 @@ const Status = React.createClass({ | |||
|   }, | ||||
| 
 | ||||
|   handleOpenMedia (media, index) { | ||||
|     this.props.dispatch(openMedia(media, index)); | ||||
|     this.props.dispatch(openModal('MEDIA', { media, index })); | ||||
|   }, | ||||
| 
 | ||||
|   handleReport (status) { | ||||
|  |  | |||
|  | @ -0,0 +1,133 @@ | |||
| import LoadingIndicator from '../../../components/loading_indicator'; | ||||
| import PureRenderMixin from 'react-addons-pure-render-mixin'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import ExtendedVideoPlayer from '../../../components/extended_video_player'; | ||||
| import ImageLoader from 'react-imageloader'; | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import IconButton from '../../../components/icon_button'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   close: { id: 'lightbox.close', defaultMessage: 'Close' } | ||||
| }); | ||||
| 
 | ||||
| const leftNavStyle = { | ||||
|   position: 'absolute', | ||||
|   background: 'rgba(0, 0, 0, 0.5)', | ||||
|   padding: '30px 15px', | ||||
|   cursor: 'pointer', | ||||
|   fontSize: '24px', | ||||
|   top: '0', | ||||
|   left: '-61px', | ||||
|   boxSizing: 'border-box', | ||||
|   height: '100%', | ||||
|   display: 'flex', | ||||
|   alignItems: 'center' | ||||
| }; | ||||
| 
 | ||||
| const rightNavStyle = { | ||||
|   position: 'absolute', | ||||
|   background: 'rgba(0, 0, 0, 0.5)', | ||||
|   padding: '30px 15px', | ||||
|   cursor: 'pointer', | ||||
|   fontSize: '24px', | ||||
|   top: '0', | ||||
|   right: '-61px', | ||||
|   boxSizing: 'border-box', | ||||
|   height: '100%', | ||||
|   display: 'flex', | ||||
|   alignItems: 'center' | ||||
| }; | ||||
| 
 | ||||
| const closeStyle = { | ||||
|   position: 'absolute', | ||||
|   top: '4px', | ||||
|   right: '4px' | ||||
| }; | ||||
| 
 | ||||
| const MediaModal = React.createClass({ | ||||
| 
 | ||||
|   propTypes: { | ||||
|     media: ImmutablePropTypes.list.isRequired, | ||||
|     index: React.PropTypes.number.isRequired, | ||||
|     onClose: React.PropTypes.func.isRequired, | ||||
|     intl: React.PropTypes.object.isRequired | ||||
|   }, | ||||
| 
 | ||||
|   getInitialState () { | ||||
|     return { | ||||
|       index: null | ||||
|     }; | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
| 
 | ||||
|   handleNextClick () { | ||||
|     this.setState({ index: (this.getIndex() + 1) % this.props.media.size}); | ||||
|   }, | ||||
| 
 | ||||
|   handlePrevClick () { | ||||
|     this.setState({ index: (this.getIndex() - 1) % this.props.media.size}); | ||||
|   }, | ||||
| 
 | ||||
|   handleKeyUp (e) { | ||||
|     switch(e.key) { | ||||
|     case 'ArrowLeft': | ||||
|       this.handlePrevClick(); | ||||
|       break; | ||||
|     case 'ArrowRight': | ||||
|       this.handleNextClick(); | ||||
|       break; | ||||
|     } | ||||
|   }, | ||||
| 
 | ||||
|   componentDidMount () { | ||||
|     window.addEventListener('keyup', this.handleKeyUp, false); | ||||
|   }, | ||||
| 
 | ||||
|   componentWillUnmount () { | ||||
|     window.removeEventListener('keyup', this.handleKeyUp); | ||||
|   }, | ||||
| 
 | ||||
|   getIndex () { | ||||
|     return this.state.index !== null ? this.state.index : this.props.index; | ||||
|   }, | ||||
| 
 | ||||
|   render () { | ||||
|     const { media, intl, onClose } = this.props; | ||||
| 
 | ||||
|     const index = this.getIndex(); | ||||
|     const attachment = media.get(index); | ||||
|     const url = attachment.get('url'); | ||||
| 
 | ||||
|     let leftNav, rightNav, content; | ||||
| 
 | ||||
|     leftNav = rightNav = content = ''; | ||||
| 
 | ||||
|     if (media.size > 1) { | ||||
|       leftNav  = <div style={leftNavStyle} className='modal-container__nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>; | ||||
|       rightNav = <div style={rightNavStyle} className='modal-container__nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>; | ||||
|     } | ||||
| 
 | ||||
|     if (attachment.get('type') === 'image') { | ||||
|       content = <ImageLoader src={url} imgProps={{ style: { display: 'block' } }} />; | ||||
|     } else if (attachment.get('type') === 'gifv') { | ||||
|       content = <ExtendedVideoPlayer src={url} />; | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='modal-root__modal media-modal'> | ||||
|         {leftNav} | ||||
| 
 | ||||
|         <div> | ||||
|           <IconButton title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} style={closeStyle} /> | ||||
|           {content} | ||||
|         </div> | ||||
| 
 | ||||
|         {rightNav} | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default injectIntl(MediaModal); | ||||
|  | @ -0,0 +1,80 @@ | |||
| import PureRenderMixin from 'react-addons-pure-render-mixin'; | ||||
| import MediaModal from './media_modal'; | ||||
| import { TransitionMotion, spring } from 'react-motion'; | ||||
| 
 | ||||
| const MODAL_COMPONENTS = { | ||||
|   'MEDIA': MediaModal | ||||
| }; | ||||
| 
 | ||||
| const ModalRoot = React.createClass({ | ||||
| 
 | ||||
|   propTypes: { | ||||
|     type: React.PropTypes.string, | ||||
|     props: React.PropTypes.object, | ||||
|     onClose: React.PropTypes.func.isRequired | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
| 
 | ||||
|   handleKeyUp (e) { | ||||
|     if (e.key === 'Escape' && !!this.props.type) { | ||||
|       this.props.onClose(); | ||||
|     } | ||||
|   }, | ||||
| 
 | ||||
|   componentDidMount () { | ||||
|     window.addEventListener('keyup', this.handleKeyUp, false); | ||||
|   }, | ||||
| 
 | ||||
|   componentWillUnmount () { | ||||
|     window.removeEventListener('keyup', this.handleKeyUp); | ||||
|   }, | ||||
| 
 | ||||
|   willEnter () { | ||||
|     return { opacity: 0, scale: 0.98 }; | ||||
|   }, | ||||
| 
 | ||||
|   willLeave () { | ||||
|     return { opacity: spring(0), scale: spring(0.98) }; | ||||
|   }, | ||||
| 
 | ||||
|   render () { | ||||
|     const { type, props, onClose } = this.props; | ||||
|     const items = []; | ||||
| 
 | ||||
|     if (!!type) { | ||||
|       items.push({ | ||||
|         key: type, | ||||
|         data: { type, props }, | ||||
|         style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) } | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <TransitionMotion | ||||
|         styles={items} | ||||
|         willEnter={this.willEnter} | ||||
|         willLeave={this.willLeave}> | ||||
|         {interpolatedStyles => | ||||
|           <div className='modal-root'> | ||||
|             {interpolatedStyles.map(({ key, data: { type, props }, style }) => { | ||||
|               const SpecificComponent = MODAL_COMPONENTS[type]; | ||||
| 
 | ||||
|               return ( | ||||
|                 <div key={key}> | ||||
|                   <div className='modal-root__overlay' style={{ opacity: style.opacity, transform: `translateZ(0px)` }} onClick={onClose} /> | ||||
|                   <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}> | ||||
|                     <SpecificComponent {...props} onClose={onClose} /> | ||||
|                   </div> | ||||
|                 </div> | ||||
|               ); | ||||
|             })} | ||||
|           </div> | ||||
|         } | ||||
|       </TransitionMotion> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default ModalRoot; | ||||
|  | @ -1,15 +1,23 @@ | |||
| import { Link } from 'react-router'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| const TabsBar = () => { | ||||
|   return ( | ||||
|     <div className='tabs-bar'> | ||||
|       <Link className='tabs-bar__link' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /> <FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link> | ||||
|       <Link className='tabs-bar__link' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /> <FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link> | ||||
|       <Link className='tabs-bar__link' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /> <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link> | ||||
|       <Link className='tabs-bar__link' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| const TabsBar = React.createClass({ | ||||
| 
 | ||||
|   render () { | ||||
|     return ( | ||||
|       <div className='tabs-bar'> | ||||
|         <Link className='tabs-bar__link primary' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link> | ||||
|         <Link className='tabs-bar__link primary' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link> | ||||
|         <Link className='tabs-bar__link primary' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link> | ||||
| 
 | ||||
|         <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public/local'><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></Link> | ||||
|         <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public'><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></Link> | ||||
| 
 | ||||
|         <Link className='tabs-bar__link primary' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default TabsBar; | ||||
|  |  | |||
|  | @ -1,170 +1,16 @@ | |||
| import { connect } from 'react-redux'; | ||||
| import { | ||||
|   closeModal, | ||||
|   decreaseIndexInModal, | ||||
|   increaseIndexInModal | ||||
| } from '../../../actions/modal'; | ||||
| import Lightbox from '../../../components/lightbox'; | ||||
| import ImageLoader from 'react-imageloader'; | ||||
| import LoadingIndicator from '../../../components/loading_indicator'; | ||||
| import PureRenderMixin from 'react-addons-pure-render-mixin'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import ExtendedVideoPlayer from '../../../components/extended_video_player'; | ||||
| import { closeModal } from '../../../actions/modal'; | ||||
| import ModalRoot from '../components/modal_root'; | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   media: state.getIn(['modal', 'media']), | ||||
|   index: state.getIn(['modal', 'index']), | ||||
|   isVisible: state.getIn(['modal', 'open']) | ||||
|   type: state.get('modal').modalType, | ||||
|   props: state.get('modal').modalProps | ||||
| }); | ||||
| 
 | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
|   onCloseClicked () { | ||||
|   onClose () { | ||||
|     dispatch(closeModal()); | ||||
|   }, | ||||
| 
 | ||||
|   onOverlayClicked () { | ||||
|     dispatch(closeModal()); | ||||
|   }, | ||||
| 
 | ||||
|   onNextClicked () { | ||||
|     dispatch(increaseIndexInModal()); | ||||
|   }, | ||||
| 
 | ||||
|   onPrevClicked () { | ||||
|     dispatch(decreaseIndexInModal()); | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| const imageStyle = { | ||||
|   display: 'block', | ||||
|   maxWidth: '80vw', | ||||
|   maxHeight: '80vh' | ||||
| }; | ||||
| 
 | ||||
| const loadingStyle = { | ||||
|   width: '400px', | ||||
|   paddingBottom: '120px' | ||||
| }; | ||||
| 
 | ||||
| const preloader = () => ( | ||||
|   <div className='modal-container--preloader' style={loadingStyle}> | ||||
|     <LoadingIndicator /> | ||||
|   </div> | ||||
| ); | ||||
| 
 | ||||
| const leftNavStyle = { | ||||
|   position: 'absolute', | ||||
|   background: 'rgba(0, 0, 0, 0.5)', | ||||
|   padding: '30px 15px', | ||||
|   cursor: 'pointer', | ||||
|   fontSize: '24px', | ||||
|   top: '0', | ||||
|   left: '-61px', | ||||
|   boxSizing: 'border-box', | ||||
|   height: '100%', | ||||
|   display: 'flex', | ||||
|   alignItems: 'center' | ||||
| }; | ||||
| 
 | ||||
| const rightNavStyle = { | ||||
|   position: 'absolute', | ||||
|   background: 'rgba(0, 0, 0, 0.5)', | ||||
|   padding: '30px 15px', | ||||
|   cursor: 'pointer', | ||||
|   fontSize: '24px', | ||||
|   top: '0', | ||||
|   right: '-61px', | ||||
|   boxSizing: 'border-box', | ||||
|   height: '100%', | ||||
|   display: 'flex', | ||||
|   alignItems: 'center' | ||||
| }; | ||||
| 
 | ||||
| const Modal = React.createClass({ | ||||
| 
 | ||||
|   propTypes: { | ||||
|     media: ImmutablePropTypes.list, | ||||
|     index: React.PropTypes.number.isRequired, | ||||
|     isVisible: React.PropTypes.bool, | ||||
|     onCloseClicked: React.PropTypes.func, | ||||
|     onOverlayClicked: React.PropTypes.func, | ||||
|     onNextClicked: React.PropTypes.func, | ||||
|     onPrevClicked: React.PropTypes.func | ||||
|   }, | ||||
| 
 | ||||
|   mixins: [PureRenderMixin], | ||||
| 
 | ||||
|   handleNextClick () { | ||||
|     this.props.onNextClicked(); | ||||
|   }, | ||||
| 
 | ||||
|   handlePrevClick () { | ||||
|     this.props.onPrevClicked(); | ||||
|   }, | ||||
| 
 | ||||
|   componentDidMount () { | ||||
|     this._listener = e => { | ||||
|       if (!this.props.isVisible) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       switch(e.key) { | ||||
|       case 'ArrowLeft': | ||||
|         this.props.onPrevClicked(); | ||||
|         break; | ||||
|       case 'ArrowRight': | ||||
|         this.props.onNextClicked(); | ||||
|         break; | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     window.addEventListener('keyup', this._listener); | ||||
|   }, | ||||
| 
 | ||||
|   componentWillUnmount () { | ||||
|     window.removeEventListener('keyup', this._listener); | ||||
|   }, | ||||
| 
 | ||||
|   render () { | ||||
|     const { media, index, ...other } = this.props; | ||||
| 
 | ||||
|     if (!media) { | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     const attachment = media.get(index); | ||||
|     const url = attachment.get('url'); | ||||
| 
 | ||||
|     let leftNav, rightNav, content; | ||||
| 
 | ||||
|     leftNav = rightNav = content = ''; | ||||
| 
 | ||||
|     if (media.size > 1) { | ||||
|       leftNav  = <div style={leftNavStyle} className='modal-container--nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>; | ||||
|       rightNav = <div style={rightNavStyle} className='modal-container--nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>; | ||||
|     } | ||||
| 
 | ||||
|     if (attachment.get('type') === 'image') { | ||||
|       content = ( | ||||
|         <ImageLoader | ||||
|           src={url} | ||||
|           preloader={preloader} | ||||
|           imgProps={{ style: imageStyle }} | ||||
|         /> | ||||
|       ); | ||||
|     } else if (attachment.get('type') === 'gifv') { | ||||
|       content = <ExtendedVideoPlayer src={url} />; | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <Lightbox {...other}> | ||||
|         {leftNav} | ||||
|         {content} | ||||
|         {rightNav} | ||||
|       </Lightbox> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default connect(mapStateToProps, mapDispatchToProps)(Modal); | ||||
| export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot); | ||||
|  |  | |||
|  | @ -36,15 +36,33 @@ const UI = React.createClass({ | |||
|     this.setState({ width: window.innerWidth }); | ||||
|   }, | ||||
| 
 | ||||
|   handleDragEnter (e) { | ||||
|     e.preventDefault(); | ||||
| 
 | ||||
|     if (!this.dragTargets) { | ||||
|       this.dragTargets = []; | ||||
|     } | ||||
| 
 | ||||
|     if (this.dragTargets.indexOf(e.target) === -1) { | ||||
|       this.dragTargets.push(e.target); | ||||
|     } | ||||
| 
 | ||||
|     if (e.dataTransfer && e.dataTransfer.files.length > 0) { | ||||
|       this.setState({ draggingOver: true }); | ||||
|     } | ||||
|   }, | ||||
| 
 | ||||
|   handleDragOver (e) { | ||||
|     e.preventDefault(); | ||||
|     e.stopPropagation(); | ||||
| 
 | ||||
|     e.dataTransfer.dropEffect = 'copy'; | ||||
|     try { | ||||
|       e.dataTransfer.dropEffect = 'copy'; | ||||
|     } catch (err) { | ||||
| 
 | ||||
|     if (e.dataTransfer.effectAllowed === 'all' || e.dataTransfer.effectAllowed === 'uninitialized') { | ||||
|       this.setState({ draggingOver: true }); | ||||
|     } | ||||
| 
 | ||||
|     return false; | ||||
|   }, | ||||
| 
 | ||||
|   handleDrop (e) { | ||||
|  | @ -57,14 +75,25 @@ const UI = React.createClass({ | |||
|     } | ||||
|   }, | ||||
| 
 | ||||
|   handleDragLeave () { | ||||
|   handleDragLeave (e) { | ||||
|     e.preventDefault(); | ||||
|     e.stopPropagation(); | ||||
| 
 | ||||
|     this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el)); | ||||
| 
 | ||||
|     if (this.dragTargets.length > 0) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.setState({ draggingOver: false }); | ||||
|   }, | ||||
| 
 | ||||
|   componentWillMount () { | ||||
|     window.addEventListener('resize', this.handleResize, { passive: true }); | ||||
|     window.addEventListener('dragover', this.handleDragOver); | ||||
|     window.addEventListener('drop', this.handleDrop); | ||||
|     document.addEventListener('dragenter', this.handleDragEnter, false); | ||||
|     document.addEventListener('dragover', this.handleDragOver, false); | ||||
|     document.addEventListener('drop', this.handleDrop, false); | ||||
|     document.addEventListener('dragleave', this.handleDragLeave, false); | ||||
| 
 | ||||
|     this.props.dispatch(refreshTimeline('home')); | ||||
|     this.props.dispatch(refreshNotifications()); | ||||
|  | @ -72,8 +101,14 @@ const UI = React.createClass({ | |||
| 
 | ||||
|   componentWillUnmount () { | ||||
|     window.removeEventListener('resize', this.handleResize); | ||||
|     window.removeEventListener('dragover', this.handleDragOver); | ||||
|     window.removeEventListener('drop', this.handleDrop); | ||||
|     document.removeEventListener('dragenter', this.handleDragEnter); | ||||
|     document.removeEventListener('dragover', this.handleDragOver); | ||||
|     document.removeEventListener('drop', this.handleDrop); | ||||
|     document.removeEventListener('dragleave', this.handleDragLeave); | ||||
|   }, | ||||
| 
 | ||||
|   setRef (c) { | ||||
|     this.node = c; | ||||
|   }, | ||||
| 
 | ||||
|   render () { | ||||
|  | @ -100,7 +135,7 @@ const UI = React.createClass({ | |||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='ui' onDragLeave={this.handleDragLeave}> | ||||
|       <div className='ui' ref={this.setRef}> | ||||
|         <TabsBar /> | ||||
| 
 | ||||
|         {mountedColumns} | ||||
|  |  | |||
|  | @ -25,7 +25,7 @@ const en = { | |||
|   "getting_started.heading": "Getting started", | ||||
|   "getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.", | ||||
|   "getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.", | ||||
|   "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}. {apps}.", | ||||
|   "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.", | ||||
|   "column.home": "Home", | ||||
|   "column.community": "Local timeline", | ||||
|   "column.public": "Federated timeline", | ||||
|  | @ -40,7 +40,7 @@ const en = { | |||
|   "compose_form.sensitive": "Mark media as sensitive", | ||||
|   "compose_form.spoiler": "Hide text behind warning", | ||||
|   "compose_form.private": "Mark as private", | ||||
|   "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}} to not leak your status?", | ||||
|   "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.", | ||||
|   "compose_form.unlisted": "Do not display on public timelines", | ||||
|   "navigation_bar.edit_profile": "Edit profile", | ||||
|   "navigation_bar.preferences": "Preferences", | ||||
|  |  | |||
							
								
								
									
										68
									
								
								app/assets/javascripts/components/locales/fi.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								app/assets/javascripts/components/locales/fi.jsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,68 @@ | |||
| const fi = { | ||||
|   "column_back_button.label": "Takaisin", | ||||
|   "lightbox.close": "Sulje", | ||||
|   "loading_indicator.label": "Ladataan...", | ||||
|   "status.mention": "Mainitse @{name}", | ||||
|   "status.delete": "Poista", | ||||
|   "status.reply": "Vastaa", | ||||
|   "status.reblog": "Boostaa", | ||||
|   "status.favourite": "Tykkää", | ||||
|   "status.reblogged_by": "{name} boostattu", | ||||
|   "status.sensitive_warning": "Arkaluontoista sisältöä", | ||||
|   "status.sensitive_toggle": "Klikkaa nähdäksesi", | ||||
|   "video_player.toggle_sound": "Äänet päälle/pois", | ||||
|   "account.mention": "Mainitse @{name}", | ||||
|   "account.edit_profile": "Muokkaa", | ||||
|   "account.unblock": "Salli @{name}", | ||||
|   "account.unfollow": "Lopeta seuraaminen", | ||||
|   "account.block": "Estä @{name}", | ||||
|   "account.follow": "Seuraa", | ||||
|   "account.posts": "Postit", | ||||
|   "account.follows": "Seuraa", | ||||
|   "account.followers": "Seuraajia", | ||||
|   "account.follows_you": "Seuraa sinua", | ||||
|   "account.requested": "Odottaa hyväksyntää", | ||||
|   "getting_started.heading": "Päästä alkuun", | ||||
|   "getting_started.about_addressing": "Voit seurata ihmisiä jos tiedät heidän käyttäjänimensä ja domainin missä he ovat syöttämällä e-mail-esque osoitteen Etsi kenttään.", | ||||
|   "getting_started.about_shortcuts": "Jos etsimäsi henkilö on samassa domainissa kuin sinä, pelkkä käyttäjänimi kelpaa. Sama pätee kun mainitset ihmisiä statuksessasi", | ||||
|   "getting_started.open_source_notice": "Mastodon Mastodon on avoimen lähdekoodin ohjelma. Voit avustaa tai raportoida ongelmia githubissa {github}. {apps}.", | ||||
|   "column.home": "Koti", | ||||
|   "column.community": "Paikallinen aikajana", | ||||
|   "column.public": "Yhdistetty aikajana", | ||||
|   "column.notifications": "Ilmoitukset", | ||||
|   "tabs_bar.compose": "Luo", | ||||
|   "tabs_bar.home": "Koti", | ||||
|   "tabs_bar.mentions": "Maininnat", | ||||
|   "tabs_bar.public": "Yleinen aikajana", | ||||
|   "tabs_bar.notifications": "Ilmoitukset", | ||||
|   "compose_form.placeholder": "Mitä sinulla on mielessä?", | ||||
|   "compose_form.publish": "Toot", | ||||
|   "compose_form.sensitive": "Merkitse media herkäksi", | ||||
|   "compose_form.spoiler": "Piiloita teksti varoituksen taakse", | ||||
|   "compose_form.private": "Merkitse yksityiseksi", | ||||
|   "compose_form.privacy_disclaimer": "Sinun yksityinen status toimitetaan mainitsemallesi käyttäjille domaineissa {domains}. Luotatko {domainsCount, plural, one {tähän palvelimeen} other {näihin palvelimiin}}? Postauksen yksityisyys toimii van Mastodon palvelimilla. Jos {domains} {domainsCount, plural, one {ei ole Mastodon palvelin} other {eivät ole Mastodon palvelin}}, viestiin ei tule Yksityinen-merkintää, ja sitä voidaan boostata tai muuten tehdä näkyväksi muille vastaanottajille.", | ||||
|   "compose_form.unlisted": "Älä näytä julkisilla aikajanoilla", | ||||
|   "navigation_bar.edit_profile": "Muokkaa profiilia", | ||||
|   "navigation_bar.preferences": "Ominaisuudet", | ||||
|   "navigation_bar.community_timeline": "Paikallinen aikajana", | ||||
|   "navigation_bar.public_timeline": "Yleinen aikajana", | ||||
|   "navigation_bar.logout": "Kirjaudu ulos", | ||||
|   "reply_indicator.cancel": "Peruuta", | ||||
|   "search.placeholder": "Hae", | ||||
|   "search.account": "Tili", | ||||
|   "search.hashtag": "Hashtag", | ||||
|   "upload_button.label": "Lisää mediaa", | ||||
|   "upload_form.undo": "Peru", | ||||
|   "notification.follow": "{name} seurasi sinua", | ||||
|   "notification.favourite": "{name} tykkäsi statuksestasi", | ||||
|   "notification.reblog": "{name} boostasi statustasi", | ||||
|   "notification.mention": "{name} mainitsi sinut", | ||||
|   "notifications.column_settings.alert": "Työpöytä ilmoitukset", | ||||
|   "notifications.column_settings.show": "Näytä sarakkeessa", | ||||
|   "notifications.column_settings.follow": "Uusia seuraajia:", | ||||
|   "notifications.column_settings.favourite": "Tykkäyksiä:", | ||||
|   "notifications.column_settings.mention": "Mainintoja:", | ||||
|   "notifications.column_settings.reblog": "Boosteja:", | ||||
| }; | ||||
| 
 | ||||
| export default fi; | ||||
|  | @ -1,68 +1,91 @@ | |||
| const fr = { | ||||
|   "account.block": "Bloquer", | ||||
|   "account.edit_profile": "Modifier le profil", | ||||
|   "account.followers": "Abonnés", | ||||
|   "account.follows": "Abonnements", | ||||
|   "account.follow": "Suivre", | ||||
|   "account.follows_you": "Vous suit", | ||||
|   "account.mention": "Mentionner", | ||||
|   "account.posts": "Statuts", | ||||
|   "account.requested": "Invitation envoyée", | ||||
|   "account.unblock": "Débloquer", | ||||
|   "account.unfollow": "Ne plus suivre", | ||||
|   "column_back_button.label": "Retour", | ||||
|   "column.home": "Accueil", | ||||
|   "column.mentions": "Mentions", | ||||
|   "column.notifications": "Notifications", | ||||
|   "column.public": "Fil public", | ||||
|   "compose_form.placeholder": "Qu’avez-vous en tête ?", | ||||
|   "compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ?", | ||||
|   "compose_form.private": "Rendre privé", | ||||
|   "compose_form.publish": "Pouet ", | ||||
|   "compose_form.sensitive": "Marquer le média comme délicat", | ||||
|   "compose_form.spoiler": "Masque le texte par un avertissement", | ||||
|   "compose_form.unlisted": "Ne pas afficher dans le fil public", | ||||
|   "getting_started.about_addressing": "Vous pouvez vous suivre les statuts de quelqu’un en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.", | ||||
|   "getting_started.about_developer": "Pour suivre le développeur de ce projet, c’est Gargron@mastodon.social", | ||||
|   "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, l’identifiant suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.", | ||||
|   "getting_started.heading": "Pour commencer", | ||||
|   "getting_started.open_source_notice": "Mastodon est un logiciel libre. Vous pouvez contribuer et envoyer vos commentaires et rapports de bogues via {github} sur GitHub.", | ||||
|   "lightbox.close": "Fermer", | ||||
|   "loading_indicator.label": "Chargement…", | ||||
|   "navigation_bar.edit_profile": "Modifier le profil", | ||||
|   "navigation_bar.logout": "Déconnexion", | ||||
|   "navigation_bar.preferences": "Préférences", | ||||
|   "navigation_bar.public_timeline": "Fil public", | ||||
|   "notification.favourite": "{name} a ajouté à ses favoris :", | ||||
|   "notification.follow": "{name} vous suit.", | ||||
|   "notification.mention": "{name} vous a mentionné⋅e :", | ||||
|   "notification.reblog": "{name} a partagé votre statut :", | ||||
|   "notifications.column_settings.alert": "Notifications locales", | ||||
|   "notifications.column_settings.favourite": "Favoris :", | ||||
|   "notifications.column_settings.follow": "Nouveaux abonnés :", | ||||
|   "notifications.column_settings.mention": "Mentions :", | ||||
|   "notifications.column_settings.reblog": "Partages :", | ||||
|   "notifications.column_settings.show": "Afficher dans la colonne", | ||||
|   "reply_indicator.cancel": "Annuler", | ||||
|   "search.account": "Compte", | ||||
|   "search.hashtag": "Mot-clé", | ||||
|   "search.placeholder": "Chercher", | ||||
|   "status.delete": "Effacer", | ||||
|   "status.favourite": "Ajouter aux favoris", | ||||
|   "status.mention": "Mentionner", | ||||
|   "status.reblogged_by": "{name} a partagé :", | ||||
|   "status.reblog": "Partager", | ||||
|   "status.delete": "Effacer", | ||||
|   "status.reply": "Répondre", | ||||
|   "status.sensitive_toggle": "Cliquer pour dévoiler", | ||||
|   "status.reblog": "Partager", | ||||
|   "status.favourite": "Ajouter aux favoris", | ||||
|   "status.reblogged_by": "{name} a partagé :", | ||||
|   "status.sensitive_warning": "Contenu délicat", | ||||
|   "status.sensitive_toggle": "Cliquer pour dévoiler", | ||||
|   "video_player.toggle_sound": "Mettre/Couper le son", | ||||
|   "account.mention": "Mentionner", | ||||
|   "account.edit_profile": "Modifier le profil", | ||||
|   "account.unblock": "Débloquer", | ||||
|   "account.unfollow": "Ne plus suivre", | ||||
|   "account.block": "Bloquer", | ||||
|   "account.mute": "Masquer", | ||||
|   "account.unmute": "Ne plus masquer", | ||||
|   "account.follow": "Suivre", | ||||
|   "account.posts": "Statuts", | ||||
|   "account.follows": "Abonnements", | ||||
|   "account.followers": "Abonnés", | ||||
|   "account.follows_you": "Vous suit", | ||||
|   "account.requested": "Invitation envoyée", | ||||
|   "account.report": "Signaler", | ||||
|   "account.disclaimer": "Ce compte est situé sur une autre instance. Les nombres peuvent être plus grands.", | ||||
|   "getting_started.heading": "Pour commencer", | ||||
|   "getting_started.about_addressing": "Vous pouvez suivre les statuts de quelqu’un en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.", | ||||
|   "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, l’identifiant suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.", | ||||
|   "getting_started.about_developer": "Pour suivre le développeur de ce projet, c’est Gargron@mastodon.social", | ||||
|   "getting_started.open_source_notice": "Mastodon est un logiciel libre. Vous pouvez contribuer et envoyer vos commentaires et rapports de bogues via {github} sur GitHub.", | ||||
|   "column.home": "Accueil", | ||||
|   "column.community": "Fil public local", | ||||
|   "column.public": "Fil public global", | ||||
|   "column.notifications": "Notifications", | ||||
|   "column.public": "Fil public", | ||||
|   "column.blocks": "Utilisateurs bloqués", | ||||
|   "column.favourites": "Favoris", | ||||
|   "tabs_bar.compose": "Composer", | ||||
|   "tabs_bar.home": "Accueil", | ||||
|   "tabs_bar.mentions": "Mentions", | ||||
|   "tabs_bar.public": "Fil public global", | ||||
|   "tabs_bar.notifications": "Notifications", | ||||
|   "tabs_bar.public": "Public", | ||||
|   "compose_form.placeholder": "Qu’avez-vous en tête ?", | ||||
|   "compose_form.publish": "Pouet ", | ||||
|   "compose_form.sensitive": "Marquer le média comme délicat", | ||||
|   "compose_form.spoiler": "Masquer le texte par un avertissement", | ||||
|   "compose_form.private": "Rendre privé", | ||||
|   "compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ? Les statuts privés ne fonctionnent que sur les instances de Mastodons. Si {domains} {domainsCount, plural, one {n'est pas une instance de Mastodon} other {ne sont pas des instances de Mastodon}}, il n'y aura aucune indication que votre statut est privé, et il pourrait être partagé ou rendu visible d'une autre manière à d'autres personnes imprévues", | ||||
|   "compose_form.unlisted": "Ne pas afficher dans les fils publics", | ||||
|   "emoji_button.label": "Insérer un emoji", | ||||
|   "navigation_bar.edit_profile": "Modifier le profil", | ||||
|   "navigation_bar.preferences": "Préférences", | ||||
|   "navigation_bar.community_timeline": "Fil public local", | ||||
|   "navigation_bar.public_timeline": "Fil public global", | ||||
|   "navigation_bar.blocks": "Utilisateurs bloqués", | ||||
|   "navigation_bar.favourites": "Favoris", | ||||
|   "navigation_bar.info": "Plus d'informations", | ||||
|   "notification.favourite": "{name} a ajouté à ses favoris :", | ||||
|   "navigation_bar.logout": "Déconnexion", | ||||
|   "reply_indicator.cancel": "Annuler", | ||||
|   "search.placeholder": "Chercher", | ||||
|   "search.account": "Compte", | ||||
|   "search.hashtag": "Mot-clé", | ||||
|   "search_results.total": "{count} {count, plural, one {résultat} other {résultats}}", | ||||
|   "upload_button.label": "Joindre un média", | ||||
|   "upload_form.undo": "Annuler", | ||||
|   "video_player.toggle_sound": "Mettre/Couper le son", | ||||
|   "notification.follow": "{name} vous suit.", | ||||
|   "notification.favourite": "{name} a ajouté à ses favoris :", | ||||
|   "notification.reblog": "{name} a partagé votre statut :", | ||||
|   "notification.mention": "{name} vous a mentionné⋅e :", | ||||
|   "notifications.column_settings.alert": "Notifications locales", | ||||
|   "notifications.column_settings.show": "Afficher dans la colonne", | ||||
|   "notifications.column_settings.follow": "Nouveaux abonnés :", | ||||
|   "notifications.column_settings.favourite": "Favoris :", | ||||
|   "notifications.column_settings.mention": "Mentions :", | ||||
|   "notifications.column_settings.reblog": "Partages :", | ||||
|   "privacy.public.short": "Public", | ||||
|   "privacy.public.long": "Afficher dans les fils publics", | ||||
|   "privacy.unlisted.short": "Non-listé", | ||||
|   "privacy.unlisted.long": "Ne pas afficher dans les fils publics", | ||||
|   "privacy.private.short": "Privé", | ||||
|   "privacy.private.long": "N’afficher que pour vos abonné⋅e⋅s", | ||||
|   "privacy.direct.short": "Direct", | ||||
|   "privacy.direct.long": "N’afficher que pour les personnes mentionné⋅e⋅s", | ||||
|   "privacy.change": "Ajuster la confidentialité du message", | ||||
| }; | ||||
| 
 | ||||
| export default fr; | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ import hu from './hu'; | |||
| import fr from './fr'; | ||||
| import pt from './pt'; | ||||
| import uk from './uk'; | ||||
| import fi from './fi'; | ||||
| 
 | ||||
| const locales = { | ||||
|   en, | ||||
|  | @ -13,7 +14,8 @@ const locales = { | |||
|   hu, | ||||
|   fr, | ||||
|   pt, | ||||
|   uk | ||||
|   uk, | ||||
|   fi | ||||
| }; | ||||
| 
 | ||||
| export default function getMessagesForLocale (locale) { | ||||
|  |  | |||
|  | @ -33,7 +33,7 @@ import { | |||
|   STATUS_FETCH_SUCCESS, | ||||
|   CONTEXT_FETCH_SUCCESS | ||||
| } from '../actions/statuses'; | ||||
| import { SEARCH_SUGGESTIONS_READY } from '../actions/search'; | ||||
| import { SEARCH_FETCH_SUCCESS } from '../actions/search'; | ||||
| import { | ||||
|   NOTIFICATIONS_UPDATE, | ||||
|   NOTIFICATIONS_REFRESH_SUCCESS, | ||||
|  | @ -97,7 +97,7 @@ export default function accounts(state = initialState, action) { | |||
|     return normalizeAccounts(state, action.accounts); | ||||
|   case NOTIFICATIONS_REFRESH_SUCCESS: | ||||
|   case NOTIFICATIONS_EXPAND_SUCCESS: | ||||
|   case SEARCH_SUGGESTIONS_READY: | ||||
|   case SEARCH_FETCH_SUCCESS: | ||||
|     return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); | ||||
|   case TIMELINE_REFRESH_SUCCESS: | ||||
|   case TIMELINE_EXPAND_SUCCESS: | ||||
|  |  | |||
|  | @ -1,31 +1,17 @@ | |||
| import { | ||||
|   MEDIA_OPEN, | ||||
|   MODAL_CLOSE, | ||||
|   MODAL_INDEX_DECREASE, | ||||
|   MODAL_INDEX_INCREASE | ||||
| } from '../actions/modal'; | ||||
| import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal'; | ||||
| import Immutable from 'immutable'; | ||||
| 
 | ||||
| const initialState = Immutable.Map({ | ||||
|   media: null, | ||||
|   index: 0, | ||||
|   open: false | ||||
| }); | ||||
| const initialState = { | ||||
|   modalType: null, | ||||
|   modalProps: {} | ||||
| }; | ||||
| 
 | ||||
| export default function modal(state = initialState, action) { | ||||
|   switch(action.type) { | ||||
|   case MEDIA_OPEN: | ||||
|     return state.withMutations(map => { | ||||
|       map.set('media', action.media); | ||||
|       map.set('index', action.index); | ||||
|       map.set('open', true); | ||||
|     }); | ||||
|   case MODAL_OPEN: | ||||
|     return { modalType: action.modalType, modalProps: action.modalProps }; | ||||
|   case MODAL_CLOSE: | ||||
|     return state.set('open', false); | ||||
|   case MODAL_INDEX_DECREASE: | ||||
|     return state.update('index', index => (index - 1) % state.get('media').size); | ||||
|   case MODAL_INDEX_INCREASE: | ||||
|     return state.update('index', index => (index + 1) % state.get('media').size); | ||||
|     return initialState; | ||||
|   default: | ||||
|     return state; | ||||
|   } | ||||
|  |  | |||
|  | @ -23,16 +23,16 @@ const initialState = Immutable.Map(); | |||
| 
 | ||||
| export default function relationships(state = initialState, action) { | ||||
|   switch(action.type) { | ||||
|     case ACCOUNT_FOLLOW_SUCCESS: | ||||
|     case ACCOUNT_UNFOLLOW_SUCCESS: | ||||
|     case ACCOUNT_BLOCK_SUCCESS: | ||||
|     case ACCOUNT_UNBLOCK_SUCCESS: | ||||
|     case ACCOUNT_MUTE_SUCCESS: | ||||
|     case ACCOUNT_UNMUTE_SUCCESS: | ||||
|       return normalizeRelationship(state, action.relationship); | ||||
|     case RELATIONSHIPS_FETCH_SUCCESS: | ||||
|       return normalizeRelationships(state, action.relationships); | ||||
|     default: | ||||
|       return state; | ||||
|   case ACCOUNT_FOLLOW_SUCCESS: | ||||
|   case ACCOUNT_UNFOLLOW_SUCCESS: | ||||
|   case ACCOUNT_BLOCK_SUCCESS: | ||||
|   case ACCOUNT_UNBLOCK_SUCCESS: | ||||
|   case ACCOUNT_MUTE_SUCCESS: | ||||
|   case ACCOUNT_UNMUTE_SUCCESS: | ||||
|     return normalizeRelationship(state, action.relationship); | ||||
|   case RELATIONSHIPS_FETCH_SUCCESS: | ||||
|     return normalizeRelationships(state, action.relationships); | ||||
|   default: | ||||
|     return state; | ||||
|   } | ||||
| }; | ||||
|  |  | |||
|  | @ -1,14 +1,17 @@ | |||
| import { | ||||
|   SEARCH_CHANGE, | ||||
|   SEARCH_SUGGESTIONS_READY, | ||||
|   SEARCH_RESET | ||||
|   SEARCH_CLEAR, | ||||
|   SEARCH_FETCH_SUCCESS, | ||||
|   SEARCH_SHOW | ||||
| } from '../actions/search'; | ||||
| import { COMPOSE_MENTION, COMPOSE_REPLY } from '../actions/compose'; | ||||
| import Immutable from 'immutable'; | ||||
| 
 | ||||
| const initialState = Immutable.Map({ | ||||
|   value: '', | ||||
|   loaded_value: '', | ||||
|   suggestions: [] | ||||
|   submitted: false, | ||||
|   hidden: false, | ||||
|   results: Immutable.Map() | ||||
| }); | ||||
| 
 | ||||
| const normalizeSuggestions = (state, value, accounts, hashtags, statuses) => { | ||||
|  | @ -69,14 +72,24 @@ export default function search(state = initialState, action) { | |||
|   switch(action.type) { | ||||
|   case SEARCH_CHANGE: | ||||
|     return state.set('value', action.value); | ||||
|   case SEARCH_SUGGESTIONS_READY: | ||||
|     return normalizeSuggestions(state, action.value, action.accounts, action.hashtags, action.statuses); | ||||
|   case SEARCH_RESET: | ||||
|   case SEARCH_CLEAR: | ||||
|     return state.withMutations(map => { | ||||
|       map.set('suggestions', []); | ||||
|       map.set('value', ''); | ||||
|       map.set('loaded_value', ''); | ||||
|       map.set('results', Immutable.Map()); | ||||
|       map.set('submitted', false); | ||||
|       map.set('hidden', false); | ||||
|     }); | ||||
|   case SEARCH_SHOW: | ||||
|     return state.set('hidden', false); | ||||
|   case COMPOSE_REPLY: | ||||
|   case COMPOSE_MENTION: | ||||
|     return state.set('hidden', true); | ||||
|   case SEARCH_FETCH_SUCCESS: | ||||
|     return state.set('results', Immutable.Map({ | ||||
|       accounts: Immutable.List(action.results.accounts.map(item => item.id)), | ||||
|       statuses: Immutable.List(action.results.statuses.map(item => item.id)), | ||||
|       hashtags: Immutable.List(action.results.hashtags) | ||||
|     })).set('submitted', true); | ||||
|   default: | ||||
|     return state; | ||||
|   } | ||||
|  |  | |||
|  | @ -32,7 +32,7 @@ import { | |||
|   FAVOURITED_STATUSES_FETCH_SUCCESS, | ||||
|   FAVOURITED_STATUSES_EXPAND_SUCCESS | ||||
| } from '../actions/favourites'; | ||||
| import { SEARCH_SUGGESTIONS_READY } from '../actions/search'; | ||||
| import { SEARCH_FETCH_SUCCESS } from '../actions/search'; | ||||
| import Immutable from 'immutable'; | ||||
| 
 | ||||
| const normalizeStatus = (state, status) => { | ||||
|  | @ -109,7 +109,7 @@ export default function statuses(state = initialState, action) { | |||
|   case NOTIFICATIONS_EXPAND_SUCCESS: | ||||
|   case FAVOURITED_STATUSES_FETCH_SUCCESS: | ||||
|   case FAVOURITED_STATUSES_EXPAND_SUCCESS: | ||||
|   case SEARCH_SUGGESTIONS_READY: | ||||
|   case SEARCH_FETCH_SUCCESS: | ||||
|     return normalizeStatuses(state, action.statuses); | ||||
|   case TIMELINE_DELETE: | ||||
|     return deleteStatus(state, action.id, action.references); | ||||
|  |  | |||
|  | @ -7,7 +7,9 @@ import { | |||
|   TIMELINE_EXPAND_SUCCESS, | ||||
|   TIMELINE_EXPAND_REQUEST, | ||||
|   TIMELINE_EXPAND_FAIL, | ||||
|   TIMELINE_SCROLL_TOP | ||||
|   TIMELINE_SCROLL_TOP, | ||||
|   TIMELINE_CONNECT, | ||||
|   TIMELINE_DISCONNECT | ||||
| } from '../actions/timelines'; | ||||
| import { | ||||
|   REBLOG_SUCCESS, | ||||
|  | @ -35,6 +37,7 @@ const initialState = Immutable.Map({ | |||
|     path: () => '/api/v1/timelines/home', | ||||
|     next: null, | ||||
|     isLoading: false, | ||||
|     online: false, | ||||
|     loaded: false, | ||||
|     top: true, | ||||
|     unread: 0, | ||||
|  | @ -45,6 +48,7 @@ const initialState = Immutable.Map({ | |||
|     path: () => '/api/v1/timelines/public', | ||||
|     next: null, | ||||
|     isLoading: false, | ||||
|     online: false, | ||||
|     loaded: false, | ||||
|     top: true, | ||||
|     unread: 0, | ||||
|  | @ -56,6 +60,7 @@ const initialState = Immutable.Map({ | |||
|     next: null, | ||||
|     params: { local: true }, | ||||
|     isLoading: false, | ||||
|     online: false, | ||||
|     loaded: false, | ||||
|     top: true, | ||||
|     unread: 0, | ||||
|  | @ -300,6 +305,10 @@ export default function timelines(state = initialState, action) { | |||
|     return filterTimelines(state, action.relationship, action.statuses); | ||||
|   case TIMELINE_SCROLL_TOP: | ||||
|     return updateTop(state, action.timeline, action.top); | ||||
|   case TIMELINE_CONNECT: | ||||
|     return state.setIn([action.timeline, 'online'], true); | ||||
|   case TIMELINE_DISCONNECT: | ||||
|     return state.setIn([action.timeline, 'online'], false); | ||||
|   default: | ||||
|     return state; | ||||
|   } | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ const getStatuses = state => state.get('statuses'); | |||
| const getAccounts = state => state.get('accounts'); | ||||
| 
 | ||||
| const getAccountBase         = (state, id) => state.getIn(['accounts', id], null); | ||||
| const getAccountRelationship = (state, id) => state.getIn(['relationships', id]); | ||||
| const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null); | ||||
| 
 | ||||
| export const makeGetAccount = () => { | ||||
|   return createSelector([getAccountBase, getAccountRelationship], (base, relationship) => { | ||||
|  |  | |||
|  | @ -24,4 +24,17 @@ $(() => { | |||
|       window.location.href = $(e.target).attr('href'); | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   $('.status__content__spoiler-link').on('click', e => { | ||||
|     e.preventDefault(); | ||||
|     const contentEl = $(e.target).parent().parent().find('div'); | ||||
| 
 | ||||
|     if (contentEl.is(':visible')) { | ||||
|       contentEl.hide(); | ||||
|       $(e.target).parent().attr('style', 'margin-bottom: 0'); | ||||
|     } else { | ||||
|       contentEl.show(); | ||||
|       $(e.target).parent().attr('style', null); | ||||
|     } | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -311,6 +311,7 @@ | |||
|       padding: 10px; | ||||
|       padding-top: 15px; | ||||
|       color: $color3; | ||||
|       word-wrap: break-word; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -21,7 +21,7 @@ | |||
|   text-decoration: none; | ||||
|   transition: all 100ms ease-in; | ||||
| 
 | ||||
|   &:hover { | ||||
|   &:hover, &:active, &:focus { | ||||
|     background-color: lighten($color4, 7%); | ||||
|     transition: all 200ms ease-out; | ||||
|   } | ||||
|  | @ -54,7 +54,7 @@ | |||
|   cursor: pointer; | ||||
|   transition: all 100ms ease-in; | ||||
| 
 | ||||
|   &:hover { | ||||
|   &:hover, &:active, &:focus { | ||||
|     color: lighten($color1, 33%); | ||||
|     transition: all 200ms ease-out; | ||||
|   } | ||||
|  | @ -79,7 +79,7 @@ | |||
|   &.inverted { | ||||
|     color: lighten($color1, 33%); | ||||
| 
 | ||||
|     &:hover { | ||||
|     &:hover, &:active, &:focus { | ||||
|       color: lighten($color1, 26%); | ||||
|     } | ||||
| 
 | ||||
|  | @ -105,7 +105,7 @@ | |||
|   outline: 0; | ||||
|   transition: all 100ms ease-in; | ||||
| 
 | ||||
|   &:hover { | ||||
|   &:hover, &:active, &:focus { | ||||
|     color: lighten($color1, 26%); | ||||
|     transition: all 200ms ease-out; | ||||
|   } | ||||
|  | @ -424,6 +424,7 @@ a.status__content__spoiler-link { | |||
| 
 | ||||
| .account__header__content { | ||||
|   word-wrap: break-word; | ||||
|   word-break: normal; | ||||
|   font-weight: 400; | ||||
|   overflow: hidden; | ||||
|   color: $color3; | ||||
|  | @ -764,8 +765,19 @@ a.status__content__spoiler-link { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| .drawer__pager { | ||||
|   box-sizing: border-box; | ||||
|   padding: 0; | ||||
|   flex-grow: 1; | ||||
|   position: relative; | ||||
|   overflow: hidden; | ||||
|   display: flex; | ||||
| } | ||||
| 
 | ||||
| .drawer__inner { | ||||
|   //background: linear-gradient(rgba(lighten($color1, 13%), 1), rgba(lighten($color1, 13%), 0.65)); | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   background: lighten($color1, 13%); | ||||
|   box-sizing: border-box; | ||||
|   padding: 0; | ||||
|  | @ -773,7 +785,12 @@ a.status__content__spoiler-link { | |||
|   flex-direction: column; | ||||
|   overflow: hidden; | ||||
|   overflow-y: auto; | ||||
|   flex-grow: 1; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
| 
 | ||||
|   &.darker { | ||||
|     background: $color1; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .drawer__header { | ||||
|  | @ -842,11 +859,25 @@ a.status__content__spoiler-link { | |||
|   font-size:12px; | ||||
|   font-weight: 500; | ||||
|   border-bottom: 2px solid lighten($color1, 8%); | ||||
|   transition: all 200ms linear; | ||||
| 
 | ||||
|   .fa { | ||||
|     font-weight: 400; | ||||
|   } | ||||
| 
 | ||||
|   &.active { | ||||
|     border-bottom: 2px solid $color4; | ||||
|     color: $color4; | ||||
|   } | ||||
| 
 | ||||
|   &:hover, &:focus, &:active { | ||||
|     background: lighten($color1, 14%); | ||||
|     transition: all 100ms linear; | ||||
|   } | ||||
| 
 | ||||
|   span { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @media screen and (min-width: 360px) { | ||||
|  | @ -854,6 +885,22 @@ a.status__content__spoiler-link { | |||
|     margin: 10px; | ||||
|     margin-bottom: 0; | ||||
|   } | ||||
| 
 | ||||
|   .search { | ||||
|     margin-bottom: 10px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @media screen and (min-width: 600px) { | ||||
|   .tabs-bar__link { | ||||
|     .fa { | ||||
|       margin-right: 5px; | ||||
|     } | ||||
| 
 | ||||
|     span { | ||||
|       display: inline; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @media screen and (min-width: 1025px) { | ||||
|  | @ -1102,11 +1149,9 @@ a.status__content__spoiler-link { | |||
| 
 | ||||
| .getting-started { | ||||
|   box-sizing: border-box; | ||||
|   overflow-y: auto; | ||||
|   padding-bottom: 235px; | ||||
|   background: image-url('mastodon-getting-started.png') no-repeat bottom left; | ||||
|   height: auto; | ||||
|   min-height: 100%; | ||||
|   background: image-url('mastodon-getting-started.png') no-repeat 0 100% local; | ||||
|   flex: 1 0 auto; | ||||
| 
 | ||||
|   p { | ||||
|     color: $color2; | ||||
|  | @ -1224,26 +1269,6 @@ button.active i.fa-retweet { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| .search { | ||||
|   .fa { | ||||
|     color: $color3; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .search__input { | ||||
|   box-sizing: border-box; | ||||
|   display: block; | ||||
|   width: 100%; | ||||
|   border: none; | ||||
|   padding: 10px; | ||||
|   padding-right: 30px; | ||||
|   font-family: inherit; | ||||
|   background: $color1; | ||||
|   color: $color3; | ||||
|   font-size: 14px; | ||||
|   margin: 0; | ||||
| } | ||||
| 
 | ||||
| .loading-indicator { | ||||
|   color: $color2; | ||||
| } | ||||
|  | @ -1286,7 +1311,7 @@ button.active i.fa-retweet { | |||
|   color: $color3; | ||||
| } | ||||
| 
 | ||||
| .modal-container--nav { | ||||
| .modal-container__nav { | ||||
|   color: $color5; | ||||
| } | ||||
| 
 | ||||
|  | @ -1640,7 +1665,7 @@ button.active i.fa-retweet { | |||
|     margin-top: 2px; | ||||
|   } | ||||
| 
 | ||||
|   &:hover { | ||||
|   &:hover, &:active, &:focus { | ||||
|     img { | ||||
|       opacity: 1; | ||||
|       filter: none; | ||||
|  | @ -1723,3 +1748,147 @@ button.active i.fa-retweet { | |||
|     box-shadow: 2px 4px 6px rgba($color8, 0.1); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .search { | ||||
|   position: relative; | ||||
| } | ||||
| 
 | ||||
| .search__input { | ||||
|   padding-right: 30px; | ||||
|   color: $color2; | ||||
|   outline: 0; | ||||
|   box-sizing: border-box; | ||||
|   display: block; | ||||
|   width: 100%; | ||||
|   border: none; | ||||
|   padding: 10px; | ||||
|   padding-right: 30px; | ||||
|   font-family: inherit; | ||||
|   background: $color1; | ||||
|   color: $color3; | ||||
|   font-size: 14px; | ||||
|   margin: 0; | ||||
| 
 | ||||
|   &::-moz-focus-inner { | ||||
|     border: 0; | ||||
|   } | ||||
| 
 | ||||
|   &::-moz-focus-inner, &:focus, &:active { | ||||
|     outline: 0 !important; | ||||
|   } | ||||
| 
 | ||||
|   &:focus { | ||||
|     background: lighten($color1, 4%); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .search__icon { | ||||
|   .fa { | ||||
|     position: absolute; | ||||
|     top: 10px; | ||||
|     right: 10px; | ||||
|     z-index: 2; | ||||
|     display: inline-block; | ||||
|     opacity: 0; | ||||
|     transition: all 100ms linear; | ||||
|     font-size: 18px; | ||||
|     width: 18px; | ||||
|     height: 18px; | ||||
|     color: $color2; | ||||
|     cursor: default; | ||||
|     pointer-events: none; | ||||
| 
 | ||||
|     &.active { | ||||
|       pointer-events: auto; | ||||
|       opacity: 0.3; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .fa-search { | ||||
|     transform: translateZ(0) rotate(90deg); | ||||
| 
 | ||||
|     &.active { | ||||
|       pointer-events: none; | ||||
|       transform: translateZ(0) rotate(0deg); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .fa-times-circle { | ||||
|     top: 11px; | ||||
|     transform: translateZ(0) rotate(0deg); | ||||
|     cursor: pointer; | ||||
| 
 | ||||
|     &.active { | ||||
|       transform: translateZ(0) rotate(90deg); | ||||
|     } | ||||
| 
 | ||||
|     &:hover { | ||||
|       color: $color5; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .search-results__header { | ||||
|   color: lighten($color1, 26%); | ||||
|   background: lighten($color1, 2%); | ||||
|   border-bottom: 1px solid darken($color1, 4%); | ||||
|   padding: 15px 10px; | ||||
|   font-size: 14px; | ||||
|   font-weight: 500; | ||||
| } | ||||
| 
 | ||||
| .search-results__hashtag { | ||||
|   display: block; | ||||
|   padding: 10px; | ||||
|   color: $color2; | ||||
|   text-decoration: none; | ||||
| 
 | ||||
|   &:hover, &:active, &:focus { | ||||
|     color: lighten($color2, 4%); | ||||
|     text-decoration: underline; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .modal-root__overlay { | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   bottom: 0; | ||||
|   z-index: 9999; | ||||
|   opacity: 0; | ||||
|   background: rgba($color8, 0.7); | ||||
| } | ||||
| 
 | ||||
| .modal-root__container { | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   align-content: space-around; | ||||
|   z-index: 9999; | ||||
|   opacity: 0; | ||||
|   pointer-events: none; | ||||
|   user-select: none; | ||||
| } | ||||
| 
 | ||||
| .modal-root__modal { | ||||
|   pointer-events: auto; | ||||
|   display: flex; | ||||
| } | ||||
| 
 | ||||
| .media-modal { | ||||
|   max-width: 80vw; | ||||
|   max-height: 80vh; | ||||
|   position: relative; | ||||
| 
 | ||||
|   img, video { | ||||
|     max-width: 80vw; | ||||
|     max-height: 80vh; | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -97,6 +97,15 @@ | |||
|       a { | ||||
|         color: $color4; | ||||
|       } | ||||
| 
 | ||||
|       a.status__content__spoiler-link { | ||||
|         color: $color5; | ||||
|         background: $color3; | ||||
| 
 | ||||
|         &:hover { | ||||
|           background: lighten($color3, 8%); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     .status__attachments { | ||||
|  | @ -163,6 +172,15 @@ | |||
|       a { | ||||
|         color: $color4; | ||||
|       } | ||||
| 
 | ||||
|       a.status__content__spoiler-link { | ||||
|         color: $color5; | ||||
|         background: $color3; | ||||
| 
 | ||||
|         &:hover { | ||||
|           background: lighten($color3, 8%); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     .detailed-status__meta { | ||||
|  |  | |||
|  | @ -5,6 +5,9 @@ class AboutController < ApplicationController | |||
| 
 | ||||
|   def index | ||||
|     @description = Setting.site_description | ||||
| 
 | ||||
|     @user = User.new | ||||
|     @user.build_account | ||||
|   end | ||||
| 
 | ||||
|   def more | ||||
|  |  | |||
|  | @ -9,6 +9,24 @@ class Admin::DomainBlocksController < ApplicationController | |||
|     @blocks = DomainBlock.paginate(page: params[:page], per_page: 40) | ||||
|   end | ||||
| 
 | ||||
|   def new | ||||
|     @domain_block = DomainBlock.new | ||||
|   end | ||||
| 
 | ||||
|   def create | ||||
|     @domain_block = DomainBlock.new(resource_params) | ||||
| 
 | ||||
|     if @domain_block.save | ||||
|       DomainBlockWorker.perform_async(@domain_block.id) | ||||
|       redirect_to admin_domain_blocks_path, notice: 'Domain block is now being processed' | ||||
|     else | ||||
|       render action: :new | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def resource_params | ||||
|     params.require(:domain_block).permit(:domain, :severity) | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ class Admin::ReportsController < ApplicationController | |||
|   layout 'admin' | ||||
| 
 | ||||
|   def index | ||||
|     @reports = Report.includes(:account, :target_account).paginate(page: params[:page], per_page: 40) | ||||
|     @reports = Report.includes(:account, :target_account).order('id desc').paginate(page: params[:page], per_page: 40) | ||||
|     @reports = params[:action_taken].present? ? @reports.resolved : @reports.unresolved | ||||
|   end | ||||
| 
 | ||||
|  | @ -16,19 +16,19 @@ class Admin::ReportsController < ApplicationController | |||
|   end | ||||
| 
 | ||||
|   def resolve | ||||
|     @report.update(action_taken: true) | ||||
|     @report.update(action_taken: true, action_taken_by_account_id: current_account.id) | ||||
|     redirect_to admin_report_path(@report) | ||||
|   end | ||||
| 
 | ||||
|   def suspend | ||||
|     Admin::SuspensionWorker.perform_async(@report.target_account.id) | ||||
|     @report.update(action_taken: true) | ||||
|     Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id) | ||||
|     redirect_to admin_report_path(@report) | ||||
|   end | ||||
| 
 | ||||
|   def silence | ||||
|     @report.target_account.update(silenced: true) | ||||
|     @report.update(action_taken: true) | ||||
|     Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id) | ||||
|     redirect_to admin_report_path(@report) | ||||
|   end | ||||
| 
 | ||||
|  |  | |||
|  | @ -4,6 +4,12 @@ class Api::V1::AppsController < ApiController | |||
|   respond_to :json | ||||
| 
 | ||||
|   def create | ||||
|     @app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes), website: params[:website]) | ||||
|     @app = Doorkeeper::Application.create!(name: app_params[:client_name], redirect_uri: app_params[:redirect_uris], scopes: (app_params[:scopes] || Doorkeeper.configuration.default_scopes), website: app_params[:website]) | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def app_params | ||||
|     params.permit(:client_name, :redirect_uris, :scopes, :website) | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ class Api::V1::FollowsController < ApiController | |||
|   respond_to :json | ||||
| 
 | ||||
|   def create | ||||
|     raise ActiveRecord::RecordNotFound if params[:uri].blank? | ||||
|     raise ActiveRecord::RecordNotFound if follow_params[:uri].blank? | ||||
| 
 | ||||
|     @account = FollowService.new.call(current_user.account, target_uri).try(:target_account) | ||||
|     render action: :show | ||||
|  | @ -16,6 +16,10 @@ class Api::V1::FollowsController < ApiController | |||
|   private | ||||
| 
 | ||||
|   def target_uri | ||||
|     params[:uri].strip.gsub(/\A@/, '') | ||||
|     follow_params[:uri].strip.gsub(/\A@/, '') | ||||
|   end | ||||
| 
 | ||||
|   def follow_params | ||||
|     params.permit(:uri) | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -10,10 +10,16 @@ class Api::V1::MediaController < ApiController | |||
|   respond_to :json | ||||
| 
 | ||||
|   def create | ||||
|     @media = MediaAttachment.create!(account: current_user.account, file: params[:file]) | ||||
|     @media = MediaAttachment.create!(account: current_user.account, file: media_params[:file]) | ||||
|   rescue Paperclip::Errors::NotIdentifiedByImageMagickError | ||||
|     render json: { error: 'File type of uploaded media could not be verified' }, status: 422 | ||||
|   rescue Paperclip::Error | ||||
|     render json: { error: 'Error processing thumbnail for uploaded media' }, status: 500 | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def media_params | ||||
|     params.permit(:file) | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -12,13 +12,19 @@ class Api::V1::ReportsController < ApiController | |||
|   end | ||||
| 
 | ||||
|   def create | ||||
|     status_ids = params[:status_ids].is_a?(Enumerable) ? params[:status_ids] : [params[:status_ids]] | ||||
|     status_ids = report_params[:status_ids].is_a?(Enumerable) ? report_params[:status_ids] : [report_params[:status_ids]] | ||||
| 
 | ||||
|     @report = Report.create!(account: current_account, | ||||
|                              target_account: Account.find(params[:account_id]), | ||||
|                              target_account: Account.find(report_params[:account_id]), | ||||
|                              status_ids: Status.find(status_ids).pluck(:id), | ||||
|                              comment: params[:comment]) | ||||
|                              comment: report_params[:comment]) | ||||
| 
 | ||||
|     render :show | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def report_params | ||||
|     params.permit(:account_id, :comment, status_ids: []) | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -62,11 +62,11 @@ 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], | ||||
|                                                                                                                                                              spoiler_text: params[:spoiler_text], | ||||
|                                                                                                                                                              visibility: params[:visibility], | ||||
|                                                                                                                                                              application: doorkeeper_token.application) | ||||
|     @status = PostStatusService.new.call(current_user.account, status_params[:status], status_params[:in_reply_to_id].blank? ? nil : Status.find(status_params[:in_reply_to_id]), media_ids: status_params[:media_ids], | ||||
|                                                                                                                                                                                   sensitive: status_params[:sensitive], | ||||
|                                                                                                                                                                                   spoiler_text: status_params[:spoiler_text], | ||||
|                                                                                                                                                                                   visibility: status_params[:visibility], | ||||
|                                                                                                                                                                                   application: doorkeeper_token.application) | ||||
|     render action: :show | ||||
|   end | ||||
| 
 | ||||
|  | @ -111,4 +111,8 @@ class Api::V1::StatusesController < ApiController | |||
|     @status = Status.find(params[:id]) | ||||
|     raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account) | ||||
|   end | ||||
| 
 | ||||
|   def status_params | ||||
|     params.permit(:status, :in_reply_to_id, :sensitive, :spoiler_text, :visibility, media_ids: []) | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -11,8 +11,8 @@ class Api::V1::TimelinesController < ApiController | |||
|     @statuses = cache_collection(@statuses) | ||||
| 
 | ||||
|     set_maps(@statuses) | ||||
|     set_counters_maps(@statuses) | ||||
|     set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) | ||||
|     # set_counters_maps(@statuses) | ||||
|     # set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) | ||||
| 
 | ||||
|     next_path = api_v1_home_timeline_url(max_id: @statuses.last.id)    unless @statuses.empty? | ||||
|     prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) unless @statuses.empty? | ||||
|  | @ -27,8 +27,8 @@ class Api::V1::TimelinesController < ApiController | |||
|     @statuses = cache_collection(@statuses) | ||||
| 
 | ||||
|     set_maps(@statuses) | ||||
|     set_counters_maps(@statuses) | ||||
|     set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) | ||||
|     # set_counters_maps(@statuses) | ||||
|     # set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) | ||||
| 
 | ||||
|     next_path = api_v1_public_timeline_url(max_id: @statuses.last.id)    unless @statuses.empty? | ||||
|     prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) unless @statuses.empty? | ||||
|  | @ -44,8 +44,8 @@ class Api::V1::TimelinesController < ApiController | |||
|     @statuses = cache_collection(@statuses) | ||||
| 
 | ||||
|     set_maps(@statuses) | ||||
|     set_counters_maps(@statuses) | ||||
|     set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) | ||||
|     # set_counters_maps(@statuses) | ||||
|     # set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) | ||||
| 
 | ||||
|     next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id)    unless @statuses.empty? | ||||
|     prev_path = api_v1_hashtag_timeline_url(params[:id], since_id: @statuses.first.id) unless @statuses.empty? | ||||
|  |  | |||
|  | @ -39,7 +39,14 @@ class ApplicationController < ActionController::Base | |||
|   end | ||||
| 
 | ||||
|   def set_user_activity | ||||
|     current_user.touch(:current_sign_in_at) if !current_user.nil? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < 24.hours.ago) | ||||
|     return unless !current_user.nil? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < 24.hours.ago) | ||||
| 
 | ||||
|     # Mark user as signed-in today | ||||
|     current_user.update_tracked_fields(request) | ||||
| 
 | ||||
|     # If the sign in is after a two week break, we need to regenerate their feed | ||||
|     RegenerationWorker.perform_async(current_user.account_id) if current_user.last_sign_in_at < 14.days.ago | ||||
|     return | ||||
|   end | ||||
| 
 | ||||
|   def check_suspension | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ | |||
| class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController | ||||
|   skip_before_action :authenticate_resource_owner! | ||||
| 
 | ||||
|   before_action :set_locale | ||||
|   before_action :store_current_location | ||||
|   before_action :authenticate_resource_owner! | ||||
| 
 | ||||
|  | @ -11,4 +12,10 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController | |||
|   def store_current_location | ||||
|     store_location_for(:user, request.url) | ||||
|   end | ||||
| 
 | ||||
|   def set_locale | ||||
|     I18n.locale = current_user.try(:locale) || I18n.default_locale | ||||
|   rescue I18n::InvalidLocale | ||||
|     I18n.locale = I18n.default_locale | ||||
|   end | ||||
| end | ||||
|  |  | |||
							
								
								
									
										34
									
								
								app/controllers/settings/imports_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								app/controllers/settings/imports_controller.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,34 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class Settings::ImportsController < ApplicationController | ||||
|   layout 'admin' | ||||
| 
 | ||||
|   before_action :authenticate_user! | ||||
|   before_action :set_account | ||||
| 
 | ||||
|   def show | ||||
|     @import = Import.new | ||||
|   end | ||||
| 
 | ||||
|   def create | ||||
|     @import = Import.new(import_params) | ||||
|     @import.account = @account | ||||
| 
 | ||||
|     if @import.save | ||||
|       ImportWorker.perform_async(@import.id) | ||||
|       redirect_to settings_import_path, notice: I18n.t('imports.success') | ||||
|     else | ||||
|       render action: :show | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def set_account | ||||
|     @account = current_user.account | ||||
|   end | ||||
| 
 | ||||
|   def import_params | ||||
|     params.require(:import).permit(:data, :type) | ||||
|   end | ||||
| end | ||||
|  | @ -10,6 +10,7 @@ module SettingsHelper | |||
|     hu: 'Magyar', | ||||
|     uk: 'Українська', | ||||
|     'zh-CN': '简体中文', | ||||
|     fi: 'Suomi', | ||||
|   }.freeze | ||||
| 
 | ||||
|   def human_locale(locale) | ||||
|  |  | |||
|  | @ -4,4 +4,5 @@ module Mastodon | |||
|   class Error < StandardError; end | ||||
|   class NotPermittedError < Error; end | ||||
|   class ValidationError < Error; end | ||||
|   class RaceConditionError < Error; end | ||||
| end | ||||
|  |  | |||
|  | @ -52,7 +52,7 @@ class FeedManager | |||
|     timeline_key = key(:home, into_account.id) | ||||
| 
 | ||||
|     from_account.statuses.limit(MAX_ITEMS).each do |status| | ||||
|       next if filter?(:home, status, into_account) | ||||
|       next if status.direct_visibility? || filter?(:home, status, into_account) | ||||
|       redis.zadd(timeline_key, status.id, status.id) | ||||
|     end | ||||
| 
 | ||||
|  |  | |||
|  | @ -10,17 +10,9 @@ class Feed | |||
|     max_id     = '+inf' if max_id.blank? | ||||
|     since_id   = '-inf' if since_id.blank? | ||||
|     unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:last).map(&:to_i) | ||||
|     status_map = Status.where(id: unhydrated).cache_ids.map { |s| [s.id, s] }.to_h | ||||
| 
 | ||||
|     # If we're after most recent items and none are there, we need to precompute the feed | ||||
|     if unhydrated.empty? && max_id == '+inf' && since_id == '-inf' | ||||
|       RegenerationWorker.perform_async(@account.id, @type) | ||||
|       @statuses = Status.send("as_#{@type}_timeline", @account).cache_ids.paginate_by_max_id(limit, nil, nil) | ||||
|     else | ||||
|       status_map = Status.where(id: unhydrated).cache_ids.map { |s| [s.id, s] }.to_h | ||||
|       @statuses  = unhydrated.map { |id| status_map[id] }.compact | ||||
|     end | ||||
| 
 | ||||
|     @statuses | ||||
|     unhydrated.map { |id| status_map[id] }.compact | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
|  |  | |||
							
								
								
									
										14
									
								
								app/models/import.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								app/models/import.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class Import < ApplicationRecord | ||||
|   self.inheritance_column = false | ||||
| 
 | ||||
|   enum type: [:following, :blocking] | ||||
| 
 | ||||
|   belongs_to :account | ||||
| 
 | ||||
|   FILE_TYPES = ['text/plain', 'text/csv'].freeze | ||||
| 
 | ||||
|   has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV['PAPERCLIP_SECRET'] | ||||
|   validates_attachment_content_type :data, content_type: FILE_TYPES | ||||
| end | ||||
|  | @ -3,6 +3,7 @@ | |||
| class Report < ApplicationRecord | ||||
|   belongs_to :account | ||||
|   belongs_to :target_account, class_name: 'Account' | ||||
|   belongs_to :action_taken_by_account, class_name: 'Account' | ||||
| 
 | ||||
|   scope :unresolved, -> { where(action_taken: false) } | ||||
|   scope :resolved,   -> { where(action_taken: true) } | ||||
|  |  | |||
|  | @ -188,7 +188,7 @@ class Status < ApplicationRecord | |||
|   end | ||||
| 
 | ||||
|   before_validation do | ||||
|     text.strip! | ||||
|     text&.strip! | ||||
|     spoiler_text&.strip! | ||||
| 
 | ||||
|     self.reply                  = !(in_reply_to_id.nil? && thread.nil?) unless reply | ||||
|  |  | |||
|  | @ -1,13 +1,11 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class BlockDomainService < BaseService | ||||
|   def call(domain, severity) | ||||
|     DomainBlock.where(domain: domain).first_or_create!(domain: domain, severity: severity) | ||||
| 
 | ||||
|     if severity == :silence | ||||
|       Account.where(domain: domain).update_all(silenced: true) | ||||
|   def call(domain_block) | ||||
|     if domain_block.silence? | ||||
|       Account.where(domain: domain_block.domain).update_all(silenced: true) | ||||
|     else | ||||
|       Account.where(domain: domain).find_each do |account| | ||||
|       Account.where(domain: domain_block.domain).find_each do |account| | ||||
|         account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed? | ||||
|         SuspendAccountService.new.call(account) | ||||
|       end | ||||
|  |  | |||
|  | @ -4,9 +4,15 @@ class FanOutOnWriteService < BaseService | |||
|   # Push a status into home and mentions feeds | ||||
|   # @param [Status] status | ||||
|   def call(status) | ||||
|     raise Mastodon::RaceConditionError if status.visibility.nil? | ||||
| 
 | ||||
|     deliver_to_self(status) if status.account.local? | ||||
| 
 | ||||
|     status.direct_visibility? ? deliver_to_mentioned_followers(status) : deliver_to_followers(status) | ||||
|     if status.direct_visibility? | ||||
|       deliver_to_mentioned_followers(status) | ||||
|     else | ||||
|       deliver_to_followers(status) | ||||
|     end | ||||
| 
 | ||||
|     return if status.account.silenced? || !status.public_visibility? || status.reblog? | ||||
| 
 | ||||
|  |  | |||
|  | @ -4,10 +4,10 @@ class PrecomputeFeedService < BaseService | |||
|   # Fill up a user's home/mentions feed from DB and return a subset | ||||
|   # @param [Symbol] type :home or :mentions | ||||
|   # @param [Account] account | ||||
|   def call(type, account) | ||||
|     Status.send("as_#{type}_timeline", account).limit(FeedManager::MAX_ITEMS).each do |status| | ||||
|       next if FeedManager.instance.filter?(type, status, account) | ||||
|       redis.zadd(FeedManager.instance.key(type, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id) | ||||
|   def call(_, account) | ||||
|     Status.as_home_timeline(account).limit(FeedManager::MAX_ITEMS).each do |status| | ||||
|       next if status.direct_visibility? || FeedManager.instance.filter?(:home, status, account) | ||||
|       redis.zadd(FeedManager.instance.key(:home, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|  |  | |||
|  | @ -2,10 +2,10 @@ | |||
| 
 | ||||
| class SearchService < BaseService | ||||
|   def call(query, limit, resolve = false, account = nil) | ||||
|     return if query.blank? | ||||
| 
 | ||||
|     results = { accounts: [], hashtags: [], statuses: [] } | ||||
| 
 | ||||
|     return results if query.blank? | ||||
| 
 | ||||
|     if query =~ /\Ahttps?:\/\// | ||||
|       resource = FetchRemoteResourceService.new.call(query) | ||||
| 
 | ||||
|  |  | |||
|  | @ -24,7 +24,7 @@ | |||
|   .screenshot-with-signup | ||||
|     .mascot= image_tag 'fluffy-elephant-friend.png' | ||||
| 
 | ||||
|     = simple_form_for(:user, url: user_registration_path) do |f| | ||||
|     = simple_form_for(@user, url: user_registration_path) do |f| | ||||
|       = f.simple_fields_for :account do |ff| | ||||
|         = ff.input :username, autofocus: true, placeholder: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username') } | ||||
| 
 | ||||
|  |  | |||
|  | @ -23,12 +23,12 @@ | |||
|       .counter{ class: active_nav_class(short_account_url(@account)) } | ||||
|         = link_to short_account_url(@account), class: 'u-url u-uid' do | ||||
|           %span.counter-label= t('accounts.posts') | ||||
|           %span.counter-number= number_with_delimiter @account.statuses.count | ||||
|           %span.counter-number= number_with_delimiter @account.statuses_count | ||||
|       .counter{ class: active_nav_class(following_account_url(@account)) } | ||||
|         = link_to following_account_url(@account) do | ||||
|           %span.counter-label= t('accounts.following') | ||||
|           %span.counter-number= number_with_delimiter @account.following.count | ||||
|           %span.counter-number= number_with_delimiter @account.following_count | ||||
|       .counter{ class: active_nav_class(followers_account_url(@account)) } | ||||
|         = link_to followers_account_url(@account) do | ||||
|           %span.counter-label= t('accounts.followers') | ||||
|           %span.counter-number= number_with_delimiter @account.followers.count | ||||
|           %span.counter-number= number_with_delimiter @account.followers_count | ||||
|  |  | |||
|  | @ -47,13 +47,13 @@ | |||
| 
 | ||||
|     %tr | ||||
|       %th Follows | ||||
|       %td= @account.following.count | ||||
|       %td= @account.following_count | ||||
|     %tr | ||||
|       %th Followers | ||||
|       %td= @account.followers.count | ||||
|       %td= @account.followers_count | ||||
|     %tr | ||||
|       %th Statuses | ||||
|       %td= @account.statuses.count | ||||
|       %td= @account.statuses_count | ||||
|     %tr | ||||
|       %th Media attachments | ||||
|       %td | ||||
|  |  | |||
|  | @ -14,3 +14,4 @@ | |||
|         %td= block.severity | ||||
| 
 | ||||
| = will_paginate @blocks, pagination_options | ||||
| = link_to 'Add new', new_admin_domain_block_path, class: 'button' | ||||
|  |  | |||
							
								
								
									
										18
									
								
								app/views/admin/domain_blocks/new.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/views/admin/domain_blocks/new.html.haml
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | |||
| - content_for :page_title do | ||||
|   New domain block | ||||
| 
 | ||||
| = simple_form_for @domain_block, url: admin_domain_blocks_path do |f| | ||||
|   = render 'shared/error_messages', object: @domain_block | ||||
| 
 | ||||
|   %p.hint The domain block will not prevent creation of account entries in the database, but will retroactively and automatically apply specific moderation methods on those accounts. | ||||
| 
 | ||||
|   = f.input :domain, placeholder: 'Domain' | ||||
|   = f.input :severity, collection: DomainBlock.severities.keys, wrapper: :with_label, include_blank: false | ||||
| 
 | ||||
|   %p.hint | ||||
|     %strong Silence | ||||
|     will make the account's posts invisible to anyone who isn't following them. | ||||
|     %strong Suspend | ||||
|     will remove all of the account's content, media, and profile data. | ||||
|   .actions | ||||
|     = f.button :button, 'Create block', type: :submit | ||||
|  | @ -8,20 +8,25 @@ | |||
|       %li= filter_link_to 'Unresolved', action_taken: nil | ||||
|       %li= filter_link_to 'Resolved', action_taken: '1' | ||||
| 
 | ||||
| %table.table | ||||
|   %thead | ||||
|     %tr | ||||
|       %th ID | ||||
|       %th Target | ||||
|       %th Reported by | ||||
|       %th Comment | ||||
|       %th | ||||
|   %tbody | ||||
|     - @reports.each do |report| | ||||
| = form_tag do | ||||
| 
 | ||||
|   %table.table | ||||
|     %thead | ||||
|       %tr | ||||
|         %td= "##{report.id}" | ||||
|         %td= link_to report.target_account.acct, admin_account_path(report.target_account.id) | ||||
|         %td= link_to report.account.acct, admin_account_path(report.account.id) | ||||
|         %td= truncate(report.comment, length: 30, separator: ' ') | ||||
|         %td= table_link_to 'circle', 'View', admin_report_path(report) | ||||
|         %th | ||||
|         %th ID | ||||
|         %th Target | ||||
|         %th Reported by | ||||
|         %th Comment | ||||
|         %th | ||||
|     %tbody | ||||
|       - @reports.each do |report| | ||||
|         %tr | ||||
|           %td= check_box_tag 'select', report.id | ||||
|           %td= "##{report.id}" | ||||
|           %td= link_to report.target_account.acct, admin_account_path(report.target_account.id) | ||||
|           %td= link_to report.account.acct, admin_account_path(report.account.id) | ||||
|           %td= truncate(report.comment, length: 30, separator: ' ') | ||||
|           %td= table_link_to 'circle', 'View', admin_report_path(report) | ||||
| 
 | ||||
| = will_paginate @reports, pagination_options | ||||
|  |  | |||
|  | @ -27,7 +27,7 @@ | |||
|         = link_to remove_admin_report_path(@report, status_id: status.id), method: :post, class: 'icon-button', style: 'font-size: 24px; width: 24px; height: 24px', title: 'Delete' do | ||||
|           = fa_icon 'trash' | ||||
| 
 | ||||
| - unless @report.action_taken? | ||||
| - if !@report.action_taken? | ||||
|   %hr/ | ||||
| 
 | ||||
|   %div{ style: 'overflow: hidden' } | ||||
|  | @ -36,3 +36,9 @@ | |||
|       = link_to 'Suspend account', suspend_admin_report_path(@report), method: :post, class: 'button' | ||||
|     %div{ style: 'float: left' } | ||||
|       = link_to 'Mark as resolved', resolve_admin_report_path(@report), method: :post, class: 'button' | ||||
| - elsif !@report.action_taken_by_account.nil? | ||||
|   %hr/ | ||||
| 
 | ||||
|   %p | ||||
|     %strong Action taken by: | ||||
|     = @report.action_taken_by_account.acct | ||||
|  |  | |||
|  | @ -6,6 +6,6 @@ node(:note)            { |account| Formatter.instance.simplified_format(account) | |||
| node(:url)             { |account| TagManager.instance.url_for(account) } | ||||
| node(:avatar)          { |account| full_asset_url(account.avatar.url(:original)) } | ||||
| node(:header)          { |account| full_asset_url(account.header.url(:original)) } | ||||
| node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : (account.try(:followers_count) || account.followers.count) } | ||||
| node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : (account.try(:following_count) || account.following.count) } | ||||
| node(:statuses_count)  { |account| defined?(@statuses_counts_map)  ? (@statuses_counts_map[account.id]  || 0) : (account.try(:statuses_count)  || account.statuses.count) } | ||||
| node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : account.followers_count } | ||||
| node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : account.following_count } | ||||
| node(:statuses_count)  { |account| defined?(@statuses_counts_map)  ? (@statuses_counts_map[account.id]  || 0) : account.statuses_count } | ||||
|  |  | |||
|  | @ -3,8 +3,8 @@ attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, :sensitiv | |||
| node(:uri)              { |status| TagManager.instance.uri_for(status) } | ||||
| node(:content)          { |status| Formatter.instance.format(status) } | ||||
| node(:url)              { |status| TagManager.instance.url_for(status) } | ||||
| node(:reblogs_count)    { |status| defined?(@reblogs_counts_map)    ? (@reblogs_counts_map[status.id]    || 0) : (status.try(:reblogs_count) || status.reblogs.count) } | ||||
| node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : (status.try(:favourites_count) || status.favourites.count) } | ||||
| node(:reblogs_count)    { |status| defined?(@reblogs_counts_map)    ? (@reblogs_counts_map[status.id]    || 0) : status.reblogs_count } | ||||
| node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : status.favourites_count } | ||||
| 
 | ||||
| child :application do | ||||
|   extends 'api/v1/apps/show' | ||||
|  |  | |||
|  | @ -12,6 +12,15 @@ | |||
|     .content-wrapper | ||||
|       .content | ||||
|         %h2= yield :page_title | ||||
| 
 | ||||
|         - if flash[:notice] | ||||
|           .flash-message.notice | ||||
|             %strong= flash[:notice] | ||||
| 
 | ||||
|         - if flash[:alert] | ||||
|           .flash-message.alert | ||||
|             %strong= flash[:alert] | ||||
| 
 | ||||
|         = yield | ||||
| 
 | ||||
| = render template: "layouts/application", locals: { body_classes: 'admin' } | ||||
|  |  | |||
							
								
								
									
										11
									
								
								app/views/settings/imports/show.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/views/settings/imports/show.html.haml
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | |||
| - content_for :page_title do | ||||
|   = t('settings.import') | ||||
| 
 | ||||
| %p.hint= t('imports.preface') | ||||
| 
 | ||||
| = simple_form_for @import, url: settings_import_path do |f| | ||||
|   = f.input :type, collection: Import.types.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |type| I18n.t("imports.types.#{type}") } | ||||
|   = f.input :data, wrapper: :with_label, hint: t('simple_form.hints.imports.data') | ||||
| 
 | ||||
|   .actions | ||||
|     = f.button :button, t('imports.upload'), type: :submit | ||||
|  | @ -9,8 +9,10 @@ | |||
| 
 | ||||
|   .status__content.e-content.p-name.emojify< | ||||
|     - unless status.spoiler_text.blank? | ||||
|       %p= status.spoiler_text | ||||
|     %div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) | ||||
|       %p{ style: 'margin-bottom: 0' }< | ||||
|         %span>= "#{status.spoiler_text} " | ||||
|         %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') | ||||
|     %div{ style: "display: #{status.spoiler_text.blank? ? 'block' : 'none'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) | ||||
| 
 | ||||
|   - unless status.media_attachments.empty? | ||||
|     - if status.media_attachments.first.video? | ||||
|  | @ -39,11 +41,11 @@ | |||
|       · | ||||
|     %span< | ||||
|       = fa_icon('retweet') | ||||
|       %span= status.reblogs.count | ||||
|       %span= status.reblogs_count | ||||
|     · | ||||
|     %span< | ||||
|       = fa_icon('star') | ||||
|       %span= status.favourites.count | ||||
|       %span= status.favourites_count | ||||
| 
 | ||||
|     - if user_signed_in? | ||||
|       · | ||||
|  |  | |||
|  | @ -14,8 +14,10 @@ | |||
| 
 | ||||
|   .status__content.e-content.p-name.emojify< | ||||
|     - unless status.spoiler_text.blank? | ||||
|       %p= status.spoiler_text | ||||
|     %div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) | ||||
|       %p{ style: 'margin-bottom: 0' }< | ||||
|         %span>= "#{status.spoiler_text} " | ||||
|         %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') | ||||
|     %div{ style: "display: #{status.spoiler_text.blank? ? 'block' : 'none'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) | ||||
| 
 | ||||
|   - unless status.media_attachments.empty? | ||||
|     .status__attachments | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
| class AfterRemoteFollowRequestWorker | ||||
|   include Sidekiq::Worker | ||||
| 
 | ||||
|   sidekiq_options retry: 5 | ||||
|   sidekiq_options queue: 'pull', retry: 5 | ||||
| 
 | ||||
|   def perform(follow_request_id) | ||||
|     follow_request  = FollowRequest.find(follow_request_id) | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
| class AfterRemoteFollowWorker | ||||
|   include Sidekiq::Worker | ||||
| 
 | ||||
|   sidekiq_options retry: 5 | ||||
|   sidekiq_options queue: 'pull', retry: 5 | ||||
| 
 | ||||
|   def perform(follow_id) | ||||
|     follow          = Follow.find(follow_id) | ||||
|  |  | |||
							
								
								
									
										11
									
								
								app/workers/domain_block_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/workers/domain_block_worker.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class DomainBlockWorker | ||||
|   include Sidekiq::Worker | ||||
| 
 | ||||
|   def perform(domain_block_id) | ||||
|     BlockDomainService.new.call(DomainBlock.find(domain_block_id)) | ||||
|   rescue ActiveRecord::RecordNotFound | ||||
|     true | ||||
|   end | ||||
| end | ||||
							
								
								
									
										54
									
								
								app/workers/import_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								app/workers/import_worker.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,54 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'csv' | ||||
| 
 | ||||
| class ImportWorker | ||||
|   include Sidekiq::Worker | ||||
| 
 | ||||
|   sidekiq_options queue: 'pull', retry: false | ||||
| 
 | ||||
|   def perform(import_id) | ||||
|     import = Import.find(import_id) | ||||
| 
 | ||||
|     case import.type | ||||
|     when 'blocking' | ||||
|       process_blocks(import) | ||||
|     when 'following' | ||||
|       process_follows(import) | ||||
|     end | ||||
| 
 | ||||
|     import.destroy | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def process_blocks(import) | ||||
|     from_account = import.account | ||||
| 
 | ||||
|     CSV.foreach(import.data.path) do |row| | ||||
|       next if row.size != 1 | ||||
| 
 | ||||
|       begin | ||||
|         target_account = FollowRemoteAccountService.new.call(row[0]) | ||||
|         next if target_account.nil? | ||||
|         BlockService.new.call(from_account, target_account) | ||||
|       rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError | ||||
|         next | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   def process_follows(import) | ||||
|     from_account = import.account | ||||
| 
 | ||||
|     CSV.foreach(import.data.path) do |row| | ||||
|       next if row.size != 1 | ||||
| 
 | ||||
|       begin | ||||
|         FollowService.new.call(from_account, row[0]) | ||||
|       rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError | ||||
|         next | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -3,7 +3,7 @@ | |||
| class LinkCrawlWorker | ||||
|   include Sidekiq::Worker | ||||
| 
 | ||||
|   sidekiq_options retry: false | ||||
|   sidekiq_options queue: 'pull', retry: false | ||||
| 
 | ||||
|   def perform(status_id) | ||||
|     FetchLinkCardService.new.call(Status.find(status_id)) | ||||
|  |  | |||
|  | @ -3,6 +3,8 @@ | |||
| class MergeWorker | ||||
|   include Sidekiq::Worker | ||||
| 
 | ||||
|   sidekiq_options queue: 'pull' | ||||
| 
 | ||||
|   def perform(from_account_id, into_account_id) | ||||
|     FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id)) | ||||
|   end | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
| class NotificationWorker | ||||
|   include Sidekiq::Worker | ||||
| 
 | ||||
|   sidekiq_options retry: 5 | ||||
|   sidekiq_options queue: 'push', retry: 5 | ||||
| 
 | ||||
|   def perform(xml, source_account_id, target_account_id) | ||||
|     SendInteractionService.new.call(xml, Account.find(source_account_id), Account.find(target_account_id)) | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
| class ProcessingWorker | ||||
|   include Sidekiq::Worker | ||||
| 
 | ||||
|   sidekiq_options backtrace: true | ||||
|   sidekiq_options queue: 'pull', backtrace: true | ||||
| 
 | ||||
|   def perform(account_id, body) | ||||
|     ProcessFeedService.new.call(body, Account.find(account_id)) | ||||
|  |  | |||
|  | @ -3,7 +3,9 @@ | |||
| class RegenerationWorker | ||||
|   include Sidekiq::Worker | ||||
| 
 | ||||
|   def perform(account_id, timeline_type) | ||||
|     PrecomputeFeedService.new.call(timeline_type, Account.find(account_id)) | ||||
|   sidekiq_options queue: 'pull', backtrace: true | ||||
| 
 | ||||
|   def perform(account_id, _ = :home) | ||||
|     PrecomputeFeedService.new.call(:home, Account.find(account_id)) | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
| class SalmonWorker | ||||
|   include Sidekiq::Worker | ||||
| 
 | ||||
|   sidekiq_options backtrace: true | ||||
|   sidekiq_options queue: 'pull', backtrace: true | ||||
| 
 | ||||
|   def perform(account_id, body) | ||||
|     ProcessInteractionService.new.call(body, Account.find(account_id)) | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
| class ThreadResolveWorker | ||||
|   include Sidekiq::Worker | ||||
| 
 | ||||
|   sidekiq_options retry: false | ||||
|   sidekiq_options queue: 'pull', retry: false | ||||
| 
 | ||||
|   def perform(child_status_id, parent_url) | ||||
|     child_status  = Status.find(child_status_id) | ||||
|  |  | |||
|  | @ -3,6 +3,8 @@ | |||
| class UnmergeWorker | ||||
|   include Sidekiq::Worker | ||||
| 
 | ||||
|   sidekiq_options queue: 'pull' | ||||
| 
 | ||||
|   def perform(from_account_id, into_account_id) | ||||
|     FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id)) | ||||
|   end | ||||
|  |  | |||
|  | @ -24,7 +24,7 @@ module Mastodon | |||
| 
 | ||||
|     # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. | ||||
|     # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] | ||||
|     config.i18n.available_locales = [:en, :de, :es, :pt, :fr, :hu, :uk, 'zh-CN'] | ||||
|     config.i18n.available_locales = [:en, :de, :es, :pt, :fr, :hu, :uk, 'zh-CN', :fi] | ||||
|     config.i18n.default_locale    = :en | ||||
| 
 | ||||
|     # config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb') | ||||
|  |  | |||
|  | @ -1,4 +1,6 @@ | |||
| Rack::Timeout::Logger.disable | ||||
| Rack::Timeout.service_timeout = false | ||||
| 
 | ||||
| if Rails.env.production? | ||||
|   Rack::Timeout.service_timeout = 90 | ||||
|   Rack::Timeout::Logger.disable | ||||
| end | ||||
|  |  | |||
							
								
								
									
										61
									
								
								config/locales/devise.fi.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								config/locales/devise.fi.yml
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,61 @@ | |||
| --- | ||||
| fi: | ||||
|   devise: | ||||
|     confirmations: | ||||
|       confirmed: Sähköpostisi on onnistuneesti vahvistettu. | ||||
|       send_instructions: Saat kohta sähköpostiisi ohjeet kuinka voit aktivoida tilisi. | ||||
|       send_paranoid_instructions: Jos sähköpostisi on meidän tietokannassa, saat pian ohjeet sen varmentamiseen. | ||||
|     failure: | ||||
|       already_authenticated: Olet jo kirjautunut sisään. | ||||
|       inactive: Tiliäsi ei ole viellä aktivoitu. | ||||
|       invalid: Virheellinen %{authentication_keys} tai salasana. | ||||
|       last_attempt: Sinulla on yksi yritys jäljellä tai tili lukitaan. | ||||
|       locked: Tili on lukittu. | ||||
|       not_found_in_database: Virheellinen %{authentication_keys} tai salasana. | ||||
|       timeout: Sessiosi on umpeutunut. Kirjaudu sisään jatkaaksesi. | ||||
|       unauthenticated: Sinun tarvitsee kirjautua sisään tai rekisteröityä jatkaaksesi. | ||||
|       unconfirmed: Sinun tarvitsee varmentaa sähköpostisi jatkaaksesi. | ||||
|     mailer: | ||||
|       confirmation_instructions: | ||||
|         subject: 'Mastodon: Varmistus ohjeet' | ||||
|       password_change: | ||||
|         subject: 'Mastodon: Salasana vaihdettu' | ||||
|       reset_password_instructions: | ||||
|         subject: 'Mastodon: Salasanan vaihto ohjeet' | ||||
|       unlock_instructions: | ||||
|         subject: 'Mastodon: Avauksen ohjeet' | ||||
|     omniauth_callbacks: | ||||
|       failure: Varmennus %{kind} epäonnistui koska "%{reason}". | ||||
|       success: Onnistuneesti varmennettu %{kind} tilillä. | ||||
|     passwords: | ||||
|       no_token: Et pääse tälle sivulle ilman salasanan vaihto sähköpostia. Jos tulet tämmöisestä postista, varmista että sinulla on täydellinen URL. | ||||
|       send_instructions: Saat sähköpostitse ohjeet salasanan palautukseen muutaman minuutin kuluessa. | ||||
|       send_paranoid_instructions: Jos sähköpostisi on meidän tietokannassa, saat pian ohjeet salasanan palautukseen. | ||||
|       updated: Salasanasi vaihdettu onnistuneesti. Olet nyt kirjautunut sisään. | ||||
|       updated_not_active: Salasanasi vaihdettu onnistuneesti. | ||||
|     registrations: | ||||
|       destroyed: Näkemiin! Tilisi on onnistuneesti peruttu. Toivottavasti näemme joskus uudestaan. | ||||
|       signed_up: Tervetuloa! Rekisteröitymisesi onnistu. | ||||
|       signed_up_but_inactive: Olet onnistuneesti rekisteröitynyt, mutta emme voi kirjata sinua sisään koska tiliäsi ei ole viellä aktivoitu. | ||||
|       signed_up_but_locked: Olet onnistuneesti rekisteröitynyt, mutta emme voi kirjata sinua sisään koska tilisi on lukittu. | ||||
|       signed_up_but_unconfirmed: Varmistuslinkki on lähetty sähköpostiisi. Seuraa sitä jotta tilisi voidaan aktivoida. | ||||
|       update_needs_confirmation: Tilisi on onnistuneesti päivitetty, mutta meidän tarvitsee vahvistaa sinun uusi sähköpostisi. Tarkista sähköpostisi ja seuraa viestissä tullutta linkkiä varmistaaksesi uuden osoitteen.. | ||||
|       updated: Tilisi on onnistuneesti päivitetty. | ||||
|     sessions: | ||||
|       already_signed_out: Ulos kirjautuminen onnistui. | ||||
|       signed_in: Sisäänkirjautuminen onnistui. | ||||
|       signed_out: Ulos kirjautuminen onnistui. | ||||
|     unlocks: | ||||
|       send_instructions: Saat sähköpostiisi pian ohjeet, jolla voit avata tilisi uudestaan. | ||||
|       send_paranoid_instructions: Jos tilisi on olemassa, saat sähköpostiisi pian ohjeet tilisi avaamiseen. | ||||
|       unlocked: Tilisi on avattu onnistuneesti. Kirjaudu normaalisti sisään. | ||||
|   errors: | ||||
|     messages: | ||||
|       already_confirmed: on jo varmistettu. Yritä kirjautua sisään | ||||
|       confirmation_period_expired: pitää varmistaa %{period} sisällä, ole hyvä ja pyydä uusi | ||||
|       expired: on erääntynyt, ole hyvä ja pyydä uusi | ||||
|       not_found: ei löydy | ||||
|       not_locked: ei ollut lukittu | ||||
|       not_saved: | ||||
|         one: '1 virhe esti %{resource} tallennuksen:' | ||||
|         other: "%{count} virhettä esti %{resource} tallennuksen:" | ||||
Some files were not shown because too many files have changed in this diff Show more
		Loading…
	
		Reference in a new issue