Merge branch 'master' into development

lolsob-rspec
halna_Tanaguru 2017-04-04 12:06:53 +02:00 committed by GitHub
commit c7e14e496b
147 changed files with 3101 additions and 1089 deletions

View File

@ -13,7 +13,7 @@ Below are the guidelines for working on pull requests:
## General ## General
- 2 spaces indendation - 2 spaces indentation
## Documentation ## Documentation

View File

@ -1,24 +1,31 @@
FROM ruby:2.3.1 FROM ruby:2.3.1-alpine
ENV RAILS_ENV=production ENV RAILS_ENV=production \
ENV NODE_ENV=production 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
WORKDIR /mastodon WORKDIR /mastodon
ADD Gemfile /mastodon/Gemfile COPY . /mastodon
ADD Gemfile.lock /mastodon/Gemfile.lock
RUN bundle install --deployment --without test development
ADD package.json /mastodon/package.json RUN BUILD_DEPS=" \
ADD yarn.lock /mastodon/yarn.lock postgresql-dev \
RUN yarn 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"]

View File

@ -50,6 +50,8 @@ gem 'rails-settings-cached'
gem 'simple-navigation' gem 'simple-navigation'
gem 'statsd-instrument' gem 'statsd-instrument'
gem 'ruby-oembed', require: 'oembed' gem 'ruby-oembed', require: 'oembed'
gem 'rack-timeout'
gem 'tzinfo-data'
gem 'react-rails' gem 'react-rails'
gem 'browserify-rails' gem 'browserify-rails'
@ -89,5 +91,4 @@ group :production do
gem 'rails_12factor' gem 'rails_12factor'
gem 'redis-rails' gem 'redis-rails'
gem 'lograge' gem 'lograge'
gem 'rack-timeout'
end end

View File

@ -423,6 +423,8 @@ GEM
unf (~> 0.1.0) unf (~> 0.1.0)
tzinfo (1.2.2) tzinfo (1.2.2)
thread_safe (~> 0.1) thread_safe (~> 0.1)
tzinfo-data (1.2017.2)
tzinfo (>= 1.0.0)
uglifier (3.0.1) uglifier (3.0.1)
execjs (>= 0.3.0, < 3) execjs (>= 0.3.0, < 3)
unf (0.1.4) unf (0.1.4)
@ -513,6 +515,7 @@ DEPENDENCIES
simplecov simplecov
statsd-instrument statsd-instrument
twitter-text twitter-text
tzinfo-data
uglifier (>= 1.3.0) uglifier (>= 1.3.0)
webmock webmock
will_paginate will_paginate

View File

@ -7,7 +7,7 @@ Mastodon
[travis]: https://travis-ci.org/tootsuite/mastodon [travis]: https://travis-ci.org/tootsuite/mastodon
[code_climate]: https://codeclimate.com/github/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. 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

View File

@ -579,15 +579,18 @@ export function expandFollowingFail(id, error) {
}; };
}; };
export function fetchRelationships(account_ids) { export function fetchRelationships(accountIds) {
return (dispatch, getState) => { 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; 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)); dispatch(fetchRelationshipsSuccess(response.data));
}).catch(error => { }).catch(error => {
dispatch(fetchRelationshipsFail(error)); dispatch(fetchRelationshipsFail(error));

View File

@ -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_CLOSE = 'MODAL_CLOSE';
export const MODAL_INDEX_DECREASE = 'MODAL_INDEX_DECREASE'; export function openModal(type, props) {
export const MODAL_INDEX_INCREASE = 'MODAL_INDEX_INCREASE';
export function openMedia(media, index) {
return { return {
type: MEDIA_OPEN, type: MODAL_OPEN,
media, modalType: type,
index modalProps: props
}; };
}; };
@ -17,15 +14,3 @@ export function closeModal() {
type: MODAL_CLOSE type: MODAL_CLOSE
}; };
}; };
export function decreaseIndexInModal() {
return {
type: MODAL_INDEX_DECREASE
};
};
export function increaseIndexInModal() {
return {
type: MODAL_INDEX_INCREASE
};
};

View File

@ -1,9 +1,12 @@
import api from '../api' import api from '../api'
export const SEARCH_CHANGE = 'SEARCH_CHANGE'; export const SEARCH_CHANGE = 'SEARCH_CHANGE';
export const SEARCH_SUGGESTIONS_CLEAR = 'SEARCH_SUGGESTIONS_CLEAR'; export const SEARCH_CLEAR = 'SEARCH_CLEAR';
export const SEARCH_SUGGESTIONS_READY = 'SEARCH_SUGGESTIONS_READY'; export const SEARCH_SHOW = 'SEARCH_SHOW';
export const SEARCH_RESET = 'SEARCH_RESET';
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) { export function changeSearch(value) {
return { return {
@ -12,42 +15,59 @@ export function changeSearch(value) {
}; };
}; };
export function clearSearchSuggestions() { export function clearSearch() {
return { return {
type: SEARCH_SUGGESTIONS_CLEAR type: SEARCH_CLEAR
}; };
}; };
export function readySearchSuggestions(value, { accounts, hashtags, statuses }) { export function submitSearch() {
return {
type: SEARCH_SUGGESTIONS_READY,
value,
accounts,
hashtags,
statuses
};
};
export function fetchSearchSuggestions(value) {
return (dispatch, getState) => { return (dispatch, getState) => {
if (getState().getIn(['search', 'loaded_value']) === value) { const value = getState().getIn(['search', 'value']);
if (value.length === 0) {
return; return;
} }
dispatch(fetchSearchRequest());
api(getState).get('/api/v1/search', { api(getState).get('/api/v1/search', {
params: { params: {
q: value, q: value,
resolve: true, resolve: true
limit: 4
} }
}).then(response => { }).then(response => {
dispatch(readySearchSuggestions(value, response.data)); dispatch(fetchSearchSuccess(response.data));
}).catch(error => {
dispatch(fetchSearchFail(error));
}); });
}; };
}; };
export function resetSearch() { export function fetchSearchRequest() {
return { 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
}; };
}; };

View File

@ -14,6 +14,9 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; 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) { export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
return { return {
type: TIMELINE_REFRESH_SUCCESS, type: TIMELINE_REFRESH_SUCCESS,
@ -76,6 +79,11 @@ export function refreshTimeline(timeline, id = null) {
let skipLoading = false; let skipLoading = false;
if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded']) && (id === null || getState().getIn(['timelines', timeline, 'id']) === id)) { 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 }; params = { ...params, since_id: newestId };
skipLoading = true; skipLoading = true;
} }
@ -162,3 +170,17 @@ export function scrollTopTimeline(timeline, top) {
top top
}; };
}; };
export function connectTimeline(timeline) {
return {
type: TIMELINE_CONNECT,
timeline
};
};
export function disconnectTimeline(timeline) {
return {
type: TIMELINE_DISCONNECT,
timeline
};
};

View File

@ -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);

View File

@ -7,6 +7,7 @@ import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' }, reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Reblog' }, reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
@ -28,6 +29,7 @@ const StatusActionBar = React.createClass({
onReblog: React.PropTypes.func, onReblog: React.PropTypes.func,
onDelete: React.PropTypes.func, onDelete: React.PropTypes.func,
onMention: React.PropTypes.func, onMention: React.PropTypes.func,
onMute: React.PropTypes.func,
onBlock: React.PropTypes.func, onBlock: React.PropTypes.func,
onReport: React.PropTypes.func, onReport: React.PropTypes.func,
me: React.PropTypes.number.isRequired, me: React.PropTypes.number.isRequired,
@ -56,6 +58,10 @@ const StatusActionBar = React.createClass({
this.props.onMention(this.props.status.get('account'), this.context.router); this.props.onMention(this.props.status.get('account'), this.context.router);
}, },
handleMuteClick () {
this.props.onMute(this.props.status.get('account'));
},
handleBlockClick () { handleBlockClick () {
this.props.onBlock(this.props.status.get('account')); this.props.onBlock(this.props.status.get('account'));
}, },
@ -81,6 +87,7 @@ const StatusActionBar = React.createClass({
} else { } else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push(null); 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.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
} }

View File

@ -23,6 +23,8 @@ const muteStyle = {
position: 'absolute', position: 'absolute',
top: '10px', top: '10px',
right: '10px', right: '10px',
color: 'white',
textShadow: "0px 1px 1px black, 1px 0px 1px black",
opacity: '0.8', opacity: '0.8',
zIndex: '5' zIndex: '5'
}; };
@ -54,6 +56,8 @@ const spoilerButtonStyle = {
position: 'absolute', position: 'absolute',
top: '6px', top: '6px',
left: '8px', left: '8px',
color: 'white',
textShadow: "0px 1px 1px black, 1px 0px 1px black",
zIndex: '100' zIndex: '100'
}; };

View File

@ -4,7 +4,9 @@ import {
refreshTimelineSuccess, refreshTimelineSuccess,
updateTimeline, updateTimeline,
deleteFromTimelines, deleteFromTimelines,
refreshTimeline refreshTimeline,
connectTimeline,
disconnectTimeline
} from '../actions/timelines'; } from '../actions/timelines';
import { updateNotifications, refreshNotifications } from '../actions/notifications'; import { updateNotifications, refreshNotifications } from '../actions/notifications';
import createBrowserHistory from 'history/lib/createBrowserHistory'; 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 pt from 'react-intl/locale-data/pt';
import hu from 'react-intl/locale-data/hu'; import hu from 'react-intl/locale-data/hu';
import uk from 'react-intl/locale-data/uk'; import uk from 'react-intl/locale-data/uk';
import fi from 'react-intl/locale-data/fi';
import getMessagesForLocale from '../locales'; import getMessagesForLocale from '../locales';
import { hydrateStore } from '../actions/store'; import { hydrateStore } from '../actions/store';
import createStream from '../stream'; import createStream from '../stream';
@ -56,7 +59,7 @@ const browserHistory = useRouterHistory(createBrowserHistory)({
basename: '/web' basename: '/web'
}); });
addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk]); addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk, ...fi]);
const Mastodon = React.createClass({ const Mastodon = React.createClass({
@ -70,6 +73,14 @@ const Mastodon = React.createClass({
this.subscription = createStream(accessToken, 'user', { this.subscription = createStream(accessToken, 'user', {
connected () {
store.dispatch(connectTimeline('home'));
},
disconnected () {
store.dispatch(disconnectTimeline('home'));
},
received (data) { received (data) {
switch(data.event) { switch(data.event) {
case 'update': case 'update':
@ -85,6 +96,7 @@ const Mastodon = React.createClass({
}, },
reconnected () { reconnected () {
store.dispatch(connectTimeline('home'));
store.dispatch(refreshTimeline('home')); store.dispatch(refreshTimeline('home'));
store.dispatch(refreshNotifications()); store.dispatch(refreshNotifications());
} }

View File

@ -17,7 +17,7 @@ import {
} from '../actions/accounts'; } from '../actions/accounts';
import { deleteStatus } from '../actions/statuses'; import { deleteStatus } from '../actions/statuses';
import { initReport } from '../actions/reports'; import { initReport } from '../actions/reports';
import { openMedia } from '../actions/modal'; import { openModal } from '../actions/modal';
import { createSelector } from 'reselect' import { createSelector } from 'reselect'
import { isMobile } from '../is_mobile' import { isMobile } from '../is_mobile'
@ -63,7 +63,7 @@ const mapDispatchToProps = (dispatch) => ({
}, },
onOpenMedia (media, index) { onOpenMedia (media, index) {
dispatch(openMedia(media, index)); dispatch(openModal('MEDIA', { media, index }));
}, },
onBlock (account) { onBlock (account) {

View File

@ -4,6 +4,7 @@ import emojify from '../../../emoji';
import escapeTextContentForBrowser from 'escape-html'; import escapeTextContentForBrowser from 'escape-html';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from '../../../components/icon_button'; import IconButton from '../../../components/icon_button';
import { Motion, spring } from 'react-motion';
const messages = defineMessages({ const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@ -11,6 +12,47 @@ const messages = defineMessages({
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' } 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({ const Header = React.createClass({
propTypes: { propTypes: {
@ -68,14 +110,9 @@ const Header = React.createClass({
return ( return (
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
<div style={{ padding: '20px 10px' }}> <div style={{ padding: '20px 10px' }}>
<a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}> <Avatar account={account} />
<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>
<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> <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} /> <div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />

View File

@ -5,7 +5,9 @@ import Column from '../ui/components/column';
import { import {
refreshTimeline, refreshTimeline,
updateTimeline, updateTimeline,
deleteFromTimelines deleteFromTimelines,
connectTimeline,
disconnectTimeline
} from '../../actions/timelines'; } from '../../actions/timelines';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import ColumnBackButtonSlim from '../../components/column_back_button_slim';
@ -44,6 +46,18 @@ const CommunityTimeline = React.createClass({
subscription = createStream(accessToken, 'public:local', { subscription = createStream(accessToken, 'public:local', {
connected () {
dispatch(connectTimeline('community'));
},
reconnected () {
dispatch(connectTimeline('community'));
},
disconnected () {
dispatch(disconnectTimeline('community'));
},
received (data) { received (data) {
switch(data.event) { switch(data.event) {
case 'update': case 'update':

View File

@ -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);

View File

@ -1,123 +1,68 @@
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes'; 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'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' } 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({ const Search = React.createClass({
contextTypes: {
router: React.PropTypes.object
},
propTypes: { propTypes: {
suggestions: React.PropTypes.array.isRequired,
value: React.PropTypes.string.isRequired, value: React.PropTypes.string.isRequired,
submitted: React.PropTypes.bool,
onChange: React.PropTypes.func.isRequired, onChange: React.PropTypes.func.isRequired,
onSubmit: React.PropTypes.func.isRequired,
onClear: React.PropTypes.func.isRequired, onClear: React.PropTypes.func.isRequired,
onFetch: React.PropTypes.func.isRequired, onShow: React.PropTypes.func.isRequired,
onReset: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired intl: React.PropTypes.object.isRequired
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
onChange (_, { newValue }) { handleChange (e) {
if (typeof newValue !== 'string') { this.props.onChange(e.target.value);
return;
}
this.props.onChange(newValue);
}, },
onSuggestionsClearRequested () { handleClear (e) {
e.preventDefault();
this.props.onClear(); this.props.onClear();
}, },
@debounce(500) handleKeyDown (e) {
onSuggestionsFetchRequested ({ value }) { if (e.key === 'Enter') {
value = value.replace('#', ''); e.preventDefault();
this.props.onFetch(value.trim()); this.props.onSubmit();
},
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}`);
} }
}, },
handleFocus () {
this.props.onShow();
},
render () { render () {
const inputProps = { const { intl, value, submitted } = this.props;
placeholder: this.props.intl.formatMessage(messages.placeholder), const hasValue = value.length > 0 || submitted;
value: this.props.value,
onChange: this.onChange,
className: 'search__input'
};
return ( return (
<div className='search' style={outerStyle}> <div className='search'>
<Autosuggest <input
multiSection={true} className='search__input'
suggestions={this.props.suggestions} type='text'
focusFirstSuggestion={true} placeholder={intl.formatMessage(messages.placeholder)}
focusInputOnSuggestionClick={false} value={value}
alwaysRenderSuggestions={false} onChange={this.handleChange}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onKeyUp={this.handleKeyDown}
onSuggestionsClearRequested={this.onSuggestionsClearRequested} onFocus={this.handleFocus}
onSuggestionSelected={this.onSuggestionSelected}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
renderSectionTitle={renderSectionTitle}
getSectionSuggestions={getSectionSuggestions}
inputProps={inputProps}
/> />
<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> </div>
); );
}, }
}); });

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -1,15 +1,15 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { import {
changeSearch, changeSearch,
clearSearchSuggestions, clearSearch,
fetchSearchSuggestions, submitSearch,
resetSearch showSearch
} from '../../../actions/search'; } from '../../../actions/search';
import Search from '../components/search'; import Search from '../components/search';
const mapStateToProps = state => ({ 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 => ({ const mapDispatchToProps = dispatch => ({
@ -19,15 +19,15 @@ const mapDispatchToProps = dispatch => ({
}, },
onClear () { onClear () {
dispatch(clearSearchSuggestions()); dispatch(clearSearch());
}, },
onFetch (value) { onSubmit () {
dispatch(fetchSearchSuggestions(value)); dispatch(submitSearch());
}, },
onReset () { onShow () {
dispatch(resetSearch()); dispatch(showSearch());
} }
}); });

View File

@ -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);

View File

@ -1,17 +1,34 @@
import Drawer from './components/drawer';
import ComposeFormContainer from './containers/compose_form_container'; import ComposeFormContainer from './containers/compose_form_container';
import UploadFormContainer from './containers/upload_form_container'; import UploadFormContainer from './containers/upload_form_container';
import NavigationContainer from './containers/navigation_container'; import NavigationContainer from './containers/navigation_container';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import SearchContainer from './containers/search_container';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { mountCompose, unmountCompose } from '../../actions/compose'; 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({ const Compose = React.createClass({
propTypes: { propTypes: {
dispatch: React.PropTypes.func.isRequired, dispatch: React.PropTypes.func.isRequired,
withHeader: React.PropTypes.bool withHeader: React.PropTypes.bool,
showSearch: React.PropTypes.bool,
intl: React.PropTypes.object.isRequired
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
@ -25,15 +42,46 @@ const Compose = React.createClass({
}, },
render () { 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 ( return (
<Drawer withHeader={this.props.withHeader}> <div className='drawer'>
{header}
<SearchContainer /> <SearchContainer />
<NavigationContainer />
<ComposeFormContainer /> <div className='drawer__pager'>
</Drawer> <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));

View File

@ -43,9 +43,7 @@ const GettingStarted = ({ intl, me }) => {
<div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}> <div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}>
<div className='static-content getting-started'> <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.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.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>
</div> </div>
</div> </div>
</Column> </Column>

View File

@ -6,7 +6,7 @@ import SettingToggle from '../../notifications/components/setting_toggle';
import SettingText from './setting_text'; import SettingText from './setting_text';
const messages = defineMessages({ 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 = { 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> <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
<div style={rowStyle}> <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>
<div style={rowStyle}> <div style={rowStyle}>

View File

@ -5,7 +5,9 @@ import Column from '../ui/components/column';
import { import {
refreshTimeline, refreshTimeline,
updateTimeline, updateTimeline,
deleteFromTimelines deleteFromTimelines,
connectTimeline,
disconnectTimeline
} from '../../actions/timelines'; } from '../../actions/timelines';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import ColumnBackButtonSlim from '../../components/column_back_button_slim';
@ -44,6 +46,18 @@ const PublicTimeline = React.createClass({
subscription = createStream(accessToken, 'public', { subscription = createStream(accessToken, 'public', {
connected () {
dispatch(connectTimeline('public'));
},
reconnected () {
dispatch(connectTimeline('public'));
},
disconnected () {
dispatch(disconnectTimeline('public'));
},
received (data) { received (data) {
switch(data.event) { switch(data.event) {
case 'update': case 'update':

View File

@ -28,7 +28,7 @@ import {
import { ScrollContainer } from 'react-router-scroll'; import { ScrollContainer } from 'react-router-scroll';
import ColumnBackButton from '../../components/column_back_button'; import ColumnBackButton from '../../components/column_back_button';
import StatusContainer from '../../containers/status_container'; import StatusContainer from '../../containers/status_container';
import { openMedia } from '../../actions/modal'; import { openModal } from '../../actions/modal';
import { isMobile } from '../../is_mobile' import { isMobile } from '../../is_mobile'
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
@ -99,7 +99,7 @@ const Status = React.createClass({
}, },
handleOpenMedia (media, index) { handleOpenMedia (media, index) {
this.props.dispatch(openMedia(media, index)); this.props.dispatch(openModal('MEDIA', { media, index }));
}, },
handleReport (status) { handleReport (status) {

View File

@ -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);

View File

@ -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;

View File

@ -1,15 +1,23 @@
import { Link } from 'react-router'; import { Link } from 'react-router';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
const TabsBar = () => { const TabsBar = React.createClass({
return (
<div className='tabs-bar'> render () {
<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> return (
<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> <div className='tabs-bar'>
<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 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' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></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>
</div> <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; export default TabsBar;

View File

@ -1,170 +1,16 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { import { closeModal } from '../../../actions/modal';
closeModal, import ModalRoot from '../components/modal_root';
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';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
media: state.getIn(['modal', 'media']), type: state.get('modal').modalType,
index: state.getIn(['modal', 'index']), props: state.get('modal').modalProps
isVisible: state.getIn(['modal', 'open'])
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
onCloseClicked () { onClose () {
dispatch(closeModal()); dispatch(closeModal());
}, },
onOverlayClicked () {
dispatch(closeModal());
},
onNextClicked () {
dispatch(increaseIndexInModal());
},
onPrevClicked () {
dispatch(decreaseIndexInModal());
}
}); });
const imageStyle = { export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot);
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);

View File

@ -36,15 +36,33 @@ const UI = React.createClass({
this.setState({ width: window.innerWidth }); 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) { handleDragOver (e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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) { 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 }); this.setState({ draggingOver: false });
}, },
componentWillMount () { componentWillMount () {
window.addEventListener('resize', this.handleResize, { passive: true }); window.addEventListener('resize', this.handleResize, { passive: true });
window.addEventListener('dragover', this.handleDragOver); document.addEventListener('dragenter', this.handleDragEnter, false);
window.addEventListener('drop', this.handleDrop); 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(refreshTimeline('home'));
this.props.dispatch(refreshNotifications()); this.props.dispatch(refreshNotifications());
@ -72,8 +101,14 @@ const UI = React.createClass({
componentWillUnmount () { componentWillUnmount () {
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
window.removeEventListener('dragover', this.handleDragOver); document.removeEventListener('dragenter', this.handleDragEnter);
window.removeEventListener('drop', this.handleDrop); document.removeEventListener('dragover', this.handleDragOver);
document.removeEventListener('drop', this.handleDrop);
document.removeEventListener('dragleave', this.handleDragLeave);
},
setRef (c) {
this.node = c;
}, },
render () { render () {
@ -100,7 +135,7 @@ const UI = React.createClass({
} }
return ( return (
<div className='ui' onDragLeave={this.handleDragLeave}> <div className='ui' ref={this.setRef}>
<TabsBar /> <TabsBar />
{mountedColumns} {mountedColumns}

View File

@ -25,7 +25,7 @@ const en = {
"getting_started.heading": "Getting started", "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_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.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.home": "Home",
"column.community": "Local timeline", "column.community": "Local timeline",
"column.public": "Federated timeline", "column.public": "Federated timeline",
@ -40,7 +40,7 @@ const en = {
"compose_form.sensitive": "Mark media as sensitive", "compose_form.sensitive": "Mark media as sensitive",
"compose_form.spoiler": "Hide text behind warning", "compose_form.spoiler": "Hide text behind warning",
"compose_form.private": "Mark as private", "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", "compose_form.unlisted": "Do not display on public timelines",
"navigation_bar.edit_profile": "Edit profile", "navigation_bar.edit_profile": "Edit profile",
"navigation_bar.preferences": "Preferences", "navigation_bar.preferences": "Preferences",

View 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;

View File

@ -1,68 +1,91 @@
const fr = { 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_back_button.label": "Retour",
"column.home": "Accueil",
"column.mentions": "Mentions",
"column.notifications": "Notifications",
"column.public": "Fil public",
"compose_form.placeholder": "Quavez-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 quelquun en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière dune adresse courriel.",
"getting_started.about_developer": "Pour suivre le développeur de ce projet, cest Gargron@mastodon.social",
"getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, lidentifiant suffit. Cest le même principe pour mentionner quelquun 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", "lightbox.close": "Fermer",
"loading_indicator.label": "Chargement…", "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.mention": "Mentionner",
"status.reblogged_by": "{name} a partagé :", "status.delete": "Effacer",
"status.reblog": "Partager",
"status.reply": "Répondre", "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_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 quelquun en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière dune adresse courriel.",
"getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, lidentifiant suffit. Cest le même principe pour mentionner quelquun dans vos statuts.",
"getting_started.about_developer": "Pour suivre le développeur de ce projet, cest 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.compose": "Composer",
"tabs_bar.home": "Accueil", "tabs_bar.home": "Accueil",
"tabs_bar.mentions": "Mentions", "tabs_bar.mentions": "Mentions",
"tabs_bar.public": "Fil public global",
"tabs_bar.notifications": "Notifications", "tabs_bar.notifications": "Notifications",
"tabs_bar.public": "Public", "compose_form.placeholder": "Quavez-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_button.label": "Joindre un média",
"upload_form.undo": "Annuler", "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": "Nafficher que pour vos abonné⋅e⋅s",
"privacy.direct.short": "Direct",
"privacy.direct.long": "Nafficher que pour les personnes mentionné⋅e⋅s",
"privacy.change": "Ajuster la confidentialité du message",
}; };
export default fr; export default fr;

View File

@ -5,6 +5,7 @@ import hu from './hu';
import fr from './fr'; import fr from './fr';
import pt from './pt'; import pt from './pt';
import uk from './uk'; import uk from './uk';
import fi from './fi';
const locales = { const locales = {
en, en,
@ -13,7 +14,8 @@ const locales = {
hu, hu,
fr, fr,
pt, pt,
uk uk,
fi
}; };
export default function getMessagesForLocale (locale) { export default function getMessagesForLocale (locale) {

View File

@ -33,7 +33,7 @@ import {
STATUS_FETCH_SUCCESS, STATUS_FETCH_SUCCESS,
CONTEXT_FETCH_SUCCESS CONTEXT_FETCH_SUCCESS
} from '../actions/statuses'; } from '../actions/statuses';
import { SEARCH_SUGGESTIONS_READY } from '../actions/search'; import { SEARCH_FETCH_SUCCESS } from '../actions/search';
import { import {
NOTIFICATIONS_UPDATE, NOTIFICATIONS_UPDATE,
NOTIFICATIONS_REFRESH_SUCCESS, NOTIFICATIONS_REFRESH_SUCCESS,
@ -97,7 +97,7 @@ export default function accounts(state = initialState, action) {
return normalizeAccounts(state, action.accounts); return normalizeAccounts(state, action.accounts);
case NOTIFICATIONS_REFRESH_SUCCESS: case NOTIFICATIONS_REFRESH_SUCCESS:
case NOTIFICATIONS_EXPAND_SUCCESS: case NOTIFICATIONS_EXPAND_SUCCESS:
case SEARCH_SUGGESTIONS_READY: case SEARCH_FETCH_SUCCESS:
return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
case TIMELINE_REFRESH_SUCCESS: case TIMELINE_REFRESH_SUCCESS:
case TIMELINE_EXPAND_SUCCESS: case TIMELINE_EXPAND_SUCCESS:

View File

@ -1,31 +1,17 @@
import { import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal';
MEDIA_OPEN,
MODAL_CLOSE,
MODAL_INDEX_DECREASE,
MODAL_INDEX_INCREASE
} from '../actions/modal';
import Immutable from 'immutable'; import Immutable from 'immutable';
const initialState = Immutable.Map({ const initialState = {
media: null, modalType: null,
index: 0, modalProps: {}
open: false };
});
export default function modal(state = initialState, action) { export default function modal(state = initialState, action) {
switch(action.type) { switch(action.type) {
case MEDIA_OPEN: case MODAL_OPEN:
return state.withMutations(map => { return { modalType: action.modalType, modalProps: action.modalProps };
map.set('media', action.media);
map.set('index', action.index);
map.set('open', true);
});
case MODAL_CLOSE: case MODAL_CLOSE:
return state.set('open', false); return initialState;
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);
default: default:
return state; return state;
} }

View File

@ -23,16 +23,16 @@ const initialState = Immutable.Map();
export default function relationships(state = initialState, action) { export default function relationships(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ACCOUNT_FOLLOW_SUCCESS: case ACCOUNT_FOLLOW_SUCCESS:
case ACCOUNT_UNFOLLOW_SUCCESS: case ACCOUNT_UNFOLLOW_SUCCESS:
case ACCOUNT_BLOCK_SUCCESS: case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_UNBLOCK_SUCCESS: case ACCOUNT_UNBLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS: case ACCOUNT_MUTE_SUCCESS:
case ACCOUNT_UNMUTE_SUCCESS: case ACCOUNT_UNMUTE_SUCCESS:
return normalizeRelationship(state, action.relationship); return normalizeRelationship(state, action.relationship);
case RELATIONSHIPS_FETCH_SUCCESS: case RELATIONSHIPS_FETCH_SUCCESS:
return normalizeRelationships(state, action.relationships); return normalizeRelationships(state, action.relationships);
default: default:
return state; return state;
} }
}; };

View File

@ -1,14 +1,17 @@
import { import {
SEARCH_CHANGE, SEARCH_CHANGE,
SEARCH_SUGGESTIONS_READY, SEARCH_CLEAR,
SEARCH_RESET SEARCH_FETCH_SUCCESS,
SEARCH_SHOW
} from '../actions/search'; } from '../actions/search';
import { COMPOSE_MENTION, COMPOSE_REPLY } from '../actions/compose';
import Immutable from 'immutable'; import Immutable from 'immutable';
const initialState = Immutable.Map({ const initialState = Immutable.Map({
value: '', value: '',
loaded_value: '', submitted: false,
suggestions: [] hidden: false,
results: Immutable.Map()
}); });
const normalizeSuggestions = (state, value, accounts, hashtags, statuses) => { const normalizeSuggestions = (state, value, accounts, hashtags, statuses) => {
@ -69,14 +72,24 @@ export default function search(state = initialState, action) {
switch(action.type) { switch(action.type) {
case SEARCH_CHANGE: case SEARCH_CHANGE:
return state.set('value', action.value); return state.set('value', action.value);
case SEARCH_SUGGESTIONS_READY: case SEARCH_CLEAR:
return normalizeSuggestions(state, action.value, action.accounts, action.hashtags, action.statuses);
case SEARCH_RESET:
return state.withMutations(map => { return state.withMutations(map => {
map.set('suggestions', []);
map.set('value', ''); 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: default:
return state; return state;
} }

View File

@ -32,7 +32,7 @@ import {
FAVOURITED_STATUSES_FETCH_SUCCESS, FAVOURITED_STATUSES_FETCH_SUCCESS,
FAVOURITED_STATUSES_EXPAND_SUCCESS FAVOURITED_STATUSES_EXPAND_SUCCESS
} from '../actions/favourites'; } from '../actions/favourites';
import { SEARCH_SUGGESTIONS_READY } from '../actions/search'; import { SEARCH_FETCH_SUCCESS } from '../actions/search';
import Immutable from 'immutable'; import Immutable from 'immutable';
const normalizeStatus = (state, status) => { const normalizeStatus = (state, status) => {
@ -109,7 +109,7 @@ export default function statuses(state = initialState, action) {
case NOTIFICATIONS_EXPAND_SUCCESS: case NOTIFICATIONS_EXPAND_SUCCESS:
case FAVOURITED_STATUSES_FETCH_SUCCESS: case FAVOURITED_STATUSES_FETCH_SUCCESS:
case FAVOURITED_STATUSES_EXPAND_SUCCESS: case FAVOURITED_STATUSES_EXPAND_SUCCESS:
case SEARCH_SUGGESTIONS_READY: case SEARCH_FETCH_SUCCESS:
return normalizeStatuses(state, action.statuses); return normalizeStatuses(state, action.statuses);
case TIMELINE_DELETE: case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references); return deleteStatus(state, action.id, action.references);

View File

@ -7,7 +7,9 @@ import {
TIMELINE_EXPAND_SUCCESS, TIMELINE_EXPAND_SUCCESS,
TIMELINE_EXPAND_REQUEST, TIMELINE_EXPAND_REQUEST,
TIMELINE_EXPAND_FAIL, TIMELINE_EXPAND_FAIL,
TIMELINE_SCROLL_TOP TIMELINE_SCROLL_TOP,
TIMELINE_CONNECT,
TIMELINE_DISCONNECT
} from '../actions/timelines'; } from '../actions/timelines';
import { import {
REBLOG_SUCCESS, REBLOG_SUCCESS,
@ -35,6 +37,7 @@ const initialState = Immutable.Map({
path: () => '/api/v1/timelines/home', path: () => '/api/v1/timelines/home',
next: null, next: null,
isLoading: false, isLoading: false,
online: false,
loaded: false, loaded: false,
top: true, top: true,
unread: 0, unread: 0,
@ -45,6 +48,7 @@ const initialState = Immutable.Map({
path: () => '/api/v1/timelines/public', path: () => '/api/v1/timelines/public',
next: null, next: null,
isLoading: false, isLoading: false,
online: false,
loaded: false, loaded: false,
top: true, top: true,
unread: 0, unread: 0,
@ -56,6 +60,7 @@ const initialState = Immutable.Map({
next: null, next: null,
params: { local: true }, params: { local: true },
isLoading: false, isLoading: false,
online: false,
loaded: false, loaded: false,
top: true, top: true,
unread: 0, unread: 0,
@ -300,6 +305,10 @@ export default function timelines(state = initialState, action) {
return filterTimelines(state, action.relationship, action.statuses); return filterTimelines(state, action.relationship, action.statuses);
case TIMELINE_SCROLL_TOP: case TIMELINE_SCROLL_TOP:
return updateTop(state, action.timeline, action.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: default:
return state; return state;
} }

View File

@ -5,7 +5,7 @@ const getStatuses = state => state.get('statuses');
const getAccounts = state => state.get('accounts'); const getAccounts = state => state.get('accounts');
const getAccountBase = (state, id) => state.getIn(['accounts', id], null); 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 = () => { export const makeGetAccount = () => {
return createSelector([getAccountBase, getAccountRelationship], (base, relationship) => { return createSelector([getAccountBase, getAccountRelationship], (base, relationship) => {

View File

@ -24,4 +24,17 @@ $(() => {
window.location.href = $(e.target).attr('href'); 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);
}
});
}); });

View File

@ -311,6 +311,7 @@
padding: 10px; padding: 10px;
padding-top: 15px; padding-top: 15px;
color: $color3; color: $color3;
word-wrap: break-word;
} }
} }
} }

View File

@ -21,7 +21,7 @@
text-decoration: none; text-decoration: none;
transition: all 100ms ease-in; transition: all 100ms ease-in;
&:hover { &:hover, &:active, &:focus {
background-color: lighten($color4, 7%); background-color: lighten($color4, 7%);
transition: all 200ms ease-out; transition: all 200ms ease-out;
} }
@ -54,7 +54,7 @@
cursor: pointer; cursor: pointer;
transition: all 100ms ease-in; transition: all 100ms ease-in;
&:hover { &:hover, &:active, &:focus {
color: lighten($color1, 33%); color: lighten($color1, 33%);
transition: all 200ms ease-out; transition: all 200ms ease-out;
} }
@ -79,7 +79,7 @@
&.inverted { &.inverted {
color: lighten($color1, 33%); color: lighten($color1, 33%);
&:hover { &:hover, &:active, &:focus {
color: lighten($color1, 26%); color: lighten($color1, 26%);
} }
@ -105,7 +105,7 @@
outline: 0; outline: 0;
transition: all 100ms ease-in; transition: all 100ms ease-in;
&:hover { &:hover, &:active, &:focus {
color: lighten($color1, 26%); color: lighten($color1, 26%);
transition: all 200ms ease-out; transition: all 200ms ease-out;
} }
@ -424,6 +424,7 @@ a.status__content__spoiler-link {
.account__header__content { .account__header__content {
word-wrap: break-word; word-wrap: break-word;
word-break: normal;
font-weight: 400; font-weight: 400;
overflow: hidden; overflow: hidden;
color: $color3; 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 { .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%); background: lighten($color1, 13%);
box-sizing: border-box; box-sizing: border-box;
padding: 0; padding: 0;
@ -773,7 +785,12 @@ a.status__content__spoiler-link {
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
overflow-y: auto; overflow-y: auto;
flex-grow: 1; width: 100%;
height: 100%;
&.darker {
background: $color1;
}
} }
.drawer__header { .drawer__header {
@ -842,11 +859,25 @@ a.status__content__spoiler-link {
font-size:12px; font-size:12px;
font-weight: 500; font-weight: 500;
border-bottom: 2px solid lighten($color1, 8%); border-bottom: 2px solid lighten($color1, 8%);
transition: all 200ms linear;
.fa {
font-weight: 400;
}
&.active { &.active {
border-bottom: 2px solid $color4; border-bottom: 2px solid $color4;
color: $color4; color: $color4;
} }
&:hover, &:focus, &:active {
background: lighten($color1, 14%);
transition: all 100ms linear;
}
span {
display: none;
}
} }
@media screen and (min-width: 360px) { @media screen and (min-width: 360px) {
@ -854,6 +885,22 @@ a.status__content__spoiler-link {
margin: 10px; margin: 10px;
margin-bottom: 0; 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) { @media screen and (min-width: 1025px) {
@ -1102,11 +1149,9 @@ a.status__content__spoiler-link {
.getting-started { .getting-started {
box-sizing: border-box; box-sizing: border-box;
overflow-y: auto;
padding-bottom: 235px; padding-bottom: 235px;
background: image-url('mastodon-getting-started.png') no-repeat bottom left; background: image-url('mastodon-getting-started.png') no-repeat 0 100% local;
height: auto; flex: 1 0 auto;
min-height: 100%;
p { p {
color: $color2; 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 { .loading-indicator {
color: $color2; color: $color2;
} }
@ -1286,7 +1311,7 @@ button.active i.fa-retweet {
color: $color3; color: $color3;
} }
.modal-container--nav { .modal-container__nav {
color: $color5; color: $color5;
} }
@ -1640,7 +1665,7 @@ button.active i.fa-retweet {
margin-top: 2px; margin-top: 2px;
} }
&:hover { &:hover, &:active, &:focus {
img { img {
opacity: 1; opacity: 1;
filter: none; filter: none;
@ -1723,3 +1748,147 @@ button.active i.fa-retweet {
box-shadow: 2px 4px 6px rgba($color8, 0.1); 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;
}
}

View File

@ -97,6 +97,15 @@
a { a {
color: $color4; color: $color4;
} }
a.status__content__spoiler-link {
color: $color5;
background: $color3;
&:hover {
background: lighten($color3, 8%);
}
}
} }
.status__attachments { .status__attachments {
@ -163,6 +172,15 @@
a { a {
color: $color4; color: $color4;
} }
a.status__content__spoiler-link {
color: $color5;
background: $color3;
&:hover {
background: lighten($color3, 8%);
}
}
} }
.detailed-status__meta { .detailed-status__meta {

View File

@ -5,6 +5,9 @@ class AboutController < ApplicationController
def index def index
@description = Setting.site_description @description = Setting.site_description
@user = User.new
@user.build_account
end end
def more def more

View File

@ -9,6 +9,24 @@ class Admin::DomainBlocksController < ApplicationController
@blocks = DomainBlock.paginate(page: params[:page], per_page: 40) @blocks = DomainBlock.paginate(page: params[:page], per_page: 40)
end end
def new
@domain_block = DomainBlock.new
end
def create 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
end end

View File

@ -7,7 +7,7 @@ class Admin::ReportsController < ApplicationController
layout 'admin' layout 'admin'
def index 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 @reports = params[:action_taken].present? ? @reports.resolved : @reports.unresolved
end end
@ -16,19 +16,19 @@ class Admin::ReportsController < ApplicationController
end end
def resolve 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) redirect_to admin_report_path(@report)
end end
def suspend def suspend
Admin::SuspensionWorker.perform_async(@report.target_account.id) 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) redirect_to admin_report_path(@report)
end end
def silence def silence
@report.target_account.update(silenced: true) @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) redirect_to admin_report_path(@report)
end end

View File

@ -4,6 +4,12 @@ class Api::V1::AppsController < ApiController
respond_to :json respond_to :json
def create 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
end end

View File

@ -7,7 +7,7 @@ class Api::V1::FollowsController < ApiController
respond_to :json respond_to :json
def create 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) @account = FollowService.new.call(current_user.account, target_uri).try(:target_account)
render action: :show render action: :show
@ -16,6 +16,10 @@ class Api::V1::FollowsController < ApiController
private private
def target_uri def target_uri
params[:uri].strip.gsub(/\A@/, '') follow_params[:uri].strip.gsub(/\A@/, '')
end
def follow_params
params.permit(:uri)
end end
end end

View File

@ -10,10 +10,16 @@ class Api::V1::MediaController < ApiController
respond_to :json respond_to :json
def create 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 rescue Paperclip::Errors::NotIdentifiedByImageMagickError
render json: { error: 'File type of uploaded media could not be verified' }, status: 422 render json: { error: 'File type of uploaded media could not be verified' }, status: 422
rescue Paperclip::Error rescue Paperclip::Error
render json: { error: 'Error processing thumbnail for uploaded media' }, status: 500 render json: { error: 'Error processing thumbnail for uploaded media' }, status: 500
end end
private
def media_params
params.permit(:file)
end
end end

View File

@ -12,13 +12,19 @@ class Api::V1::ReportsController < ApiController
end end
def create 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, @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), status_ids: Status.find(status_ids).pluck(:id),
comment: params[:comment]) comment: report_params[:comment])
render :show render :show
end end
private
def report_params
params.permit(:account_id, :comment, status_ids: [])
end
end end

View File

@ -62,11 +62,11 @@ class Api::V1::StatusesController < ApiController
end end
def create 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], @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: params[:sensitive], sensitive: status_params[:sensitive],
spoiler_text: params[:spoiler_text], spoiler_text: status_params[:spoiler_text],
visibility: params[:visibility], visibility: status_params[:visibility],
application: doorkeeper_token.application) application: doorkeeper_token.application)
render action: :show render action: :show
end end
@ -111,4 +111,8 @@ class Api::V1::StatusesController < ApiController
@status = Status.find(params[:id]) @status = Status.find(params[:id])
raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account) raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account)
end end
def status_params
params.permit(:status, :in_reply_to_id, :sensitive, :spoiler_text, :visibility, media_ids: [])
end
end end

View File

@ -11,8 +11,8 @@ class Api::V1::TimelinesController < ApiController
@statuses = cache_collection(@statuses) @statuses = cache_collection(@statuses)
set_maps(@statuses) set_maps(@statuses)
set_counters_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_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? 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? 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) @statuses = cache_collection(@statuses)
set_maps(@statuses) set_maps(@statuses)
set_counters_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_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? 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? 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) @statuses = cache_collection(@statuses)
set_maps(@statuses) set_maps(@statuses)
set_counters_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_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? 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? prev_path = api_v1_hashtag_timeline_url(params[:id], since_id: @statuses.first.id) unless @statuses.empty?

View File

@ -39,7 +39,14 @@ class ApplicationController < ActionController::Base
end end
def set_user_activity 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 end
def check_suspension def check_suspension

View File

@ -3,6 +3,7 @@
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
skip_before_action :authenticate_resource_owner! skip_before_action :authenticate_resource_owner!
before_action :set_locale
before_action :store_current_location before_action :store_current_location
before_action :authenticate_resource_owner! before_action :authenticate_resource_owner!
@ -11,4 +12,10 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
def store_current_location def store_current_location
store_location_for(:user, request.url) store_location_for(:user, request.url)
end end
def set_locale
I18n.locale = current_user.try(:locale) || I18n.default_locale
rescue I18n::InvalidLocale
I18n.locale = I18n.default_locale
end
end end

View 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

View File

@ -10,6 +10,7 @@ module SettingsHelper
hu: 'Magyar', hu: 'Magyar',
uk: 'Українська', uk: 'Українська',
'zh-CN': '简体中文', 'zh-CN': '简体中文',
fi: 'Suomi',
}.freeze }.freeze
def human_locale(locale) def human_locale(locale)

View File

@ -4,4 +4,5 @@ module Mastodon
class Error < StandardError; end class Error < StandardError; end
class NotPermittedError < Error; end class NotPermittedError < Error; end
class ValidationError < Error; end class ValidationError < Error; end
class RaceConditionError < Error; end
end end

View File

@ -52,7 +52,7 @@ class FeedManager
timeline_key = key(:home, into_account.id) timeline_key = key(:home, into_account.id)
from_account.statuses.limit(MAX_ITEMS).each do |status| 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) redis.zadd(timeline_key, status.id, status.id)
end end

View File

@ -10,17 +10,9 @@ class Feed
max_id = '+inf' if max_id.blank? max_id = '+inf' if max_id.blank?
since_id = '-inf' if since_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) 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 unhydrated.map { |id| status_map[id] }.compact
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
end end
private private

14
app/models/import.rb Normal file
View 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

View File

@ -3,6 +3,7 @@
class Report < ApplicationRecord class Report < ApplicationRecord
belongs_to :account belongs_to :account
belongs_to :target_account, class_name: 'Account' belongs_to :target_account, class_name: 'Account'
belongs_to :action_taken_by_account, class_name: 'Account'
scope :unresolved, -> { where(action_taken: false) } scope :unresolved, -> { where(action_taken: false) }
scope :resolved, -> { where(action_taken: true) } scope :resolved, -> { where(action_taken: true) }

View File

@ -188,7 +188,7 @@ class Status < ApplicationRecord
end end
before_validation do before_validation do
text.strip! text&.strip!
spoiler_text&.strip! spoiler_text&.strip!
self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply

View File

@ -1,13 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
class BlockDomainService < BaseService class BlockDomainService < BaseService
def call(domain, severity) def call(domain_block)
DomainBlock.where(domain: domain).first_or_create!(domain: domain, severity: severity) if domain_block.silence?
Account.where(domain: domain_block.domain).update_all(silenced: true)
if severity == :silence
Account.where(domain: domain).update_all(silenced: true)
else 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? account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed?
SuspendAccountService.new.call(account) SuspendAccountService.new.call(account)
end end

View File

@ -4,9 +4,15 @@ class FanOutOnWriteService < BaseService
# Push a status into home and mentions feeds # Push a status into home and mentions feeds
# @param [Status] status # @param [Status] status
def call(status) def call(status)
raise Mastodon::RaceConditionError if status.visibility.nil?
deliver_to_self(status) if status.account.local? 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? return if status.account.silenced? || !status.public_visibility? || status.reblog?

View File

@ -4,10 +4,10 @@ class PrecomputeFeedService < BaseService
# Fill up a user's home/mentions feed from DB and return a subset # Fill up a user's home/mentions feed from DB and return a subset
# @param [Symbol] type :home or :mentions # @param [Symbol] type :home or :mentions
# @param [Account] account # @param [Account] account
def call(type, account) def call(_, account)
Status.send("as_#{type}_timeline", account).limit(FeedManager::MAX_ITEMS).each do |status| Status.as_home_timeline(account).limit(FeedManager::MAX_ITEMS).each do |status|
next if FeedManager.instance.filter?(type, status, account) next if status.direct_visibility? || FeedManager.instance.filter?(:home, status, account)
redis.zadd(FeedManager.instance.key(type, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id) redis.zadd(FeedManager.instance.key(:home, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
end end
end end

View File

@ -2,10 +2,10 @@
class SearchService < BaseService class SearchService < BaseService
def call(query, limit, resolve = false, account = nil) def call(query, limit, resolve = false, account = nil)
return if query.blank?
results = { accounts: [], hashtags: [], statuses: [] } results = { accounts: [], hashtags: [], statuses: [] }
return results if query.blank?
if query =~ /\Ahttps?:\/\// if query =~ /\Ahttps?:\/\//
resource = FetchRemoteResourceService.new.call(query) resource = FetchRemoteResourceService.new.call(query)

View File

@ -24,7 +24,7 @@
.screenshot-with-signup .screenshot-with-signup
.mascot= image_tag 'fluffy-elephant-friend.png' .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| = 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') } = ff.input :username, autofocus: true, placeholder: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username') }

View File

@ -23,12 +23,12 @@
.counter{ class: active_nav_class(short_account_url(@account)) } .counter{ class: active_nav_class(short_account_url(@account)) }
= link_to short_account_url(@account), class: 'u-url u-uid' do = link_to short_account_url(@account), class: 'u-url u-uid' do
%span.counter-label= t('accounts.posts') %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)) } .counter{ class: active_nav_class(following_account_url(@account)) }
= link_to following_account_url(@account) do = link_to following_account_url(@account) do
%span.counter-label= t('accounts.following') %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)) } .counter{ class: active_nav_class(followers_account_url(@account)) }
= link_to followers_account_url(@account) do = link_to followers_account_url(@account) do
%span.counter-label= t('accounts.followers') %span.counter-label= t('accounts.followers')
%span.counter-number= number_with_delimiter @account.followers.count %span.counter-number= number_with_delimiter @account.followers_count

View File

@ -47,13 +47,13 @@
%tr %tr
%th Follows %th Follows
%td= @account.following.count %td= @account.following_count
%tr %tr
%th Followers %th Followers
%td= @account.followers.count %td= @account.followers_count
%tr %tr
%th Statuses %th Statuses
%td= @account.statuses.count %td= @account.statuses_count
%tr %tr
%th Media attachments %th Media attachments
%td %td

View File

@ -14,3 +14,4 @@
%td= block.severity %td= block.severity
= will_paginate @blocks, pagination_options = will_paginate @blocks, pagination_options
= link_to 'Add new', new_admin_domain_block_path, class: 'button'

View 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

View File

@ -8,20 +8,25 @@
%li= filter_link_to 'Unresolved', action_taken: nil %li= filter_link_to 'Unresolved', action_taken: nil
%li= filter_link_to 'Resolved', action_taken: '1' %li= filter_link_to 'Resolved', action_taken: '1'
%table.table = form_tag do
%thead
%tr %table.table
%th ID %thead
%th Target
%th Reported by
%th Comment
%th
%tbody
- @reports.each do |report|
%tr %tr
%td= "##{report.id}" %th
%td= link_to report.target_account.acct, admin_account_path(report.target_account.id) %th ID
%td= link_to report.account.acct, admin_account_path(report.account.id) %th Target
%td= truncate(report.comment, length: 30, separator: ' ') %th Reported by
%td= table_link_to 'circle', 'View', admin_report_path(report) %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 = will_paginate @reports, pagination_options

View File

@ -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 = 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' = fa_icon 'trash'
- unless @report.action_taken? - if !@report.action_taken?
%hr/ %hr/
%div{ style: 'overflow: hidden' } %div{ style: 'overflow: hidden' }
@ -36,3 +36,9 @@
= link_to 'Suspend account', suspend_admin_report_path(@report), method: :post, class: 'button' = link_to 'Suspend account', suspend_admin_report_path(@report), method: :post, class: 'button'
%div{ style: 'float: left' } %div{ style: 'float: left' }
= link_to 'Mark as resolved', resolve_admin_report_path(@report), method: :post, class: 'button' = 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

View File

@ -6,6 +6,6 @@ node(:note) { |account| Formatter.instance.simplified_format(account)
node(:url) { |account| TagManager.instance.url_for(account) } node(:url) { |account| TagManager.instance.url_for(account) }
node(:avatar) { |account| full_asset_url(account.avatar.url(:original)) } node(:avatar) { |account| full_asset_url(account.avatar.url(:original)) }
node(:header) { |account| full_asset_url(account.header.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(: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.try(:following_count) || account.following.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.try(:statuses_count) || account.statuses.count) } node(:statuses_count) { |account| defined?(@statuses_counts_map) ? (@statuses_counts_map[account.id] || 0) : account.statuses_count }

View File

@ -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(:uri) { |status| TagManager.instance.uri_for(status) }
node(:content) { |status| Formatter.instance.format(status) } node(:content) { |status| Formatter.instance.format(status) }
node(:url) { |status| TagManager.instance.url_for(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(: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.try(:favourites_count) || status.favourites.count) } node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : status.favourites_count }
child :application do child :application do
extends 'api/v1/apps/show' extends 'api/v1/apps/show'

View File

@ -12,6 +12,15 @@
.content-wrapper .content-wrapper
.content .content
%h2= yield :page_title %h2= yield :page_title
- if flash[:notice]
.flash-message.notice
%strong= flash[:notice]
- if flash[:alert]
.flash-message.alert
%strong= flash[:alert]
= yield = yield
= render template: "layouts/application", locals: { body_classes: 'admin' } = render template: "layouts/application", locals: { body_classes: 'admin' }

View 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

View File

@ -9,8 +9,10 @@
.status__content.e-content.p-name.emojify< .status__content.e-content.p-name.emojify<
- unless status.spoiler_text.blank? - unless status.spoiler_text.blank?
%p= status.spoiler_text %p{ style: 'margin-bottom: 0' }<
%div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) %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? - unless status.media_attachments.empty?
- if status.media_attachments.first.video? - if status.media_attachments.first.video?
@ -39,11 +41,11 @@
· ·
%span< %span<
= fa_icon('retweet') = fa_icon('retweet')
%span= status.reblogs.count %span= status.reblogs_count
· ·
%span< %span<
= fa_icon('star') = fa_icon('star')
%span= status.favourites.count %span= status.favourites_count
- if user_signed_in? - if user_signed_in?
· ·

View File

@ -14,8 +14,10 @@
.status__content.e-content.p-name.emojify< .status__content.e-content.p-name.emojify<
- unless status.spoiler_text.blank? - unless status.spoiler_text.blank?
%p= status.spoiler_text %p{ style: 'margin-bottom: 0' }<
%div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) %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? - unless status.media_attachments.empty?
.status__attachments .status__attachments

View File

@ -3,7 +3,7 @@
class AfterRemoteFollowRequestWorker class AfterRemoteFollowRequestWorker
include Sidekiq::Worker include Sidekiq::Worker
sidekiq_options retry: 5 sidekiq_options queue: 'pull', retry: 5
def perform(follow_request_id) def perform(follow_request_id)
follow_request = FollowRequest.find(follow_request_id) follow_request = FollowRequest.find(follow_request_id)

View File

@ -3,7 +3,7 @@
class AfterRemoteFollowWorker class AfterRemoteFollowWorker
include Sidekiq::Worker include Sidekiq::Worker
sidekiq_options retry: 5 sidekiq_options queue: 'pull', retry: 5
def perform(follow_id) def perform(follow_id)
follow = Follow.find(follow_id) follow = Follow.find(follow_id)

View 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

View 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

View File

@ -3,7 +3,7 @@
class LinkCrawlWorker class LinkCrawlWorker
include Sidekiq::Worker include Sidekiq::Worker
sidekiq_options retry: false sidekiq_options queue: 'pull', retry: false
def perform(status_id) def perform(status_id)
FetchLinkCardService.new.call(Status.find(status_id)) FetchLinkCardService.new.call(Status.find(status_id))

View File

@ -3,6 +3,8 @@
class MergeWorker class MergeWorker
include Sidekiq::Worker include Sidekiq::Worker
sidekiq_options queue: 'pull'
def perform(from_account_id, into_account_id) def perform(from_account_id, into_account_id)
FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id)) FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id))
end end

View File

@ -3,7 +3,7 @@
class NotificationWorker class NotificationWorker
include Sidekiq::Worker include Sidekiq::Worker
sidekiq_options retry: 5 sidekiq_options queue: 'push', retry: 5
def perform(xml, source_account_id, target_account_id) def perform(xml, source_account_id, target_account_id)
SendInteractionService.new.call(xml, Account.find(source_account_id), Account.find(target_account_id)) SendInteractionService.new.call(xml, Account.find(source_account_id), Account.find(target_account_id))

View File

@ -3,7 +3,7 @@
class ProcessingWorker class ProcessingWorker
include Sidekiq::Worker include Sidekiq::Worker
sidekiq_options backtrace: true sidekiq_options queue: 'pull', backtrace: true
def perform(account_id, body) def perform(account_id, body)
ProcessFeedService.new.call(body, Account.find(account_id)) ProcessFeedService.new.call(body, Account.find(account_id))

View File

@ -3,7 +3,9 @@
class RegenerationWorker class RegenerationWorker
include Sidekiq::Worker include Sidekiq::Worker
def perform(account_id, timeline_type) sidekiq_options queue: 'pull', backtrace: true
PrecomputeFeedService.new.call(timeline_type, Account.find(account_id))
def perform(account_id, _ = :home)
PrecomputeFeedService.new.call(:home, Account.find(account_id))
end end
end end

View File

@ -3,7 +3,7 @@
class SalmonWorker class SalmonWorker
include Sidekiq::Worker include Sidekiq::Worker
sidekiq_options backtrace: true sidekiq_options queue: 'pull', backtrace: true
def perform(account_id, body) def perform(account_id, body)
ProcessInteractionService.new.call(body, Account.find(account_id)) ProcessInteractionService.new.call(body, Account.find(account_id))

View File

@ -3,7 +3,7 @@
class ThreadResolveWorker class ThreadResolveWorker
include Sidekiq::Worker include Sidekiq::Worker
sidekiq_options retry: false sidekiq_options queue: 'pull', retry: false
def perform(child_status_id, parent_url) def perform(child_status_id, parent_url)
child_status = Status.find(child_status_id) child_status = Status.find(child_status_id)

View File

@ -3,6 +3,8 @@
class UnmergeWorker class UnmergeWorker
include Sidekiq::Worker include Sidekiq::Worker
sidekiq_options queue: 'pull'
def perform(from_account_id, into_account_id) def perform(from_account_id, into_account_id)
FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id)) FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id))
end end

View File

@ -24,7 +24,7 @@ module Mastodon
# The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. # 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.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.i18n.default_locale = :en
# config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb') # config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb')

View File

@ -1,4 +1,6 @@
Rack::Timeout::Logger.disable
Rack::Timeout.service_timeout = false
if Rails.env.production? if Rails.env.production?
Rack::Timeout.service_timeout = 90 Rack::Timeout.service_timeout = 90
Rack::Timeout::Logger.disable
end end

View 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