Merge branch 'master' into development

th-downstream
halna_Tanaguru 8 years ago committed by GitHub
commit c7e14e496b

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

@ -1,24 +1,31 @@
FROM ruby:2.3.1
FROM ruby:2.3.1-alpine
ENV RAILS_ENV=production
ENV NODE_ENV=production
RUN echo 'deb http://httpredir.debian.org/debian jessie-backports main contrib non-free' >> /etc/apt/sources.list
RUN curl -sL https://deb.nodesource.com/setup_4.x | bash -
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev libxml2-dev libxslt1-dev nodejs ffmpeg && rm -rf /var/lib/apt/lists/*
RUN npm install -g npm@3 && npm install -g yarn
RUN mkdir /mastodon
ENV RAILS_ENV=production \
NODE_ENV=production
WORKDIR /mastodon
ADD Gemfile /mastodon/Gemfile
ADD Gemfile.lock /mastodon/Gemfile.lock
RUN bundle install --deployment --without test development
ADD package.json /mastodon/package.json
ADD yarn.lock /mastodon/yarn.lock
RUN yarn
COPY . /mastodon
ADD . /mastodon
RUN BUILD_DEPS=" \
postgresql-dev \
libxml2-dev \
libxslt-dev \
build-base" \
&& apk -U upgrade && apk add \
$BUILD_DEPS \
nodejs \
libpq \
libxml2 \
libxslt \
ffmpeg \
file \
imagemagick \
&& npm install -g npm@3 && npm install -g yarn \
&& bundle install --deployment --without test development \
&& yarn \
&& npm cache clean \
&& apk del $BUILD_DEPS \
&& rm -rf /tmp/* /var/cache/apk/*
VOLUME ["/mastodon/public/system", "/mastodon/public/assets"]
VOLUME /mastodon/public/system /mastodon/public/assets

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

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

@ -7,7 +7,7 @@ Mastodon
[travis]: https://travis-ci.org/tootsuite/mastodon
[code_climate]: https://codeclimate.com/github/tootsuite/mastodon
Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly.
Mastodon is a free, open-source social network server. A decentralized solution to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly.
An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 59 KiB

@ -579,15 +579,18 @@ export function expandFollowingFail(id, error) {
};
};
export function fetchRelationships(account_ids) {
export function fetchRelationships(accountIds) {
return (dispatch, getState) => {
if (account_ids.length === 0) {
const loadedRelationships = getState().get('relationships');
const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null);
if (newAccountIds.length === 0) {
return;
}
dispatch(fetchRelationshipsRequest(account_ids));
dispatch(fetchRelationshipsRequest(newAccountIds));
api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
dispatch(fetchRelationshipsSuccess(response.data));
}).catch(error => {
dispatch(fetchRelationshipsFail(error));

@ -1,14 +1,11 @@
export const MEDIA_OPEN = 'MEDIA_OPEN';
export const MODAL_OPEN = 'MODAL_OPEN';
export const MODAL_CLOSE = 'MODAL_CLOSE';
export const MODAL_INDEX_DECREASE = 'MODAL_INDEX_DECREASE';
export const MODAL_INDEX_INCREASE = 'MODAL_INDEX_INCREASE';
export function openMedia(media, index) {
export function openModal(type, props) {
return {
type: MEDIA_OPEN,
media,
index
type: MODAL_OPEN,
modalType: type,
modalProps: props
};
};
@ -17,15 +14,3 @@ export function closeModal() {
type: MODAL_CLOSE
};
};
export function decreaseIndexInModal() {
return {
type: MODAL_INDEX_DECREASE
};
};
export function increaseIndexInModal() {
return {
type: MODAL_INDEX_INCREASE
};
};

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

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

@ -1,82 +0,0 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from './icon_button';
import { Motion, spring } from 'react-motion';
import { injectIntl } from 'react-intl';
const overlayStyle = {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '100%',
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
justifyContent: 'center',
alignContent: 'center',
flexDirection: 'row',
zIndex: '9999'
};
const dialogStyle = {
color: '#282c37',
boxShadow: '0 0 30px rgba(0, 0, 0, 0.8)',
margin: 'auto',
position: 'relative'
};
const closeStyle = {
position: 'absolute',
top: '4px',
right: '4px'
};
const Lightbox = React.createClass({
propTypes: {
isVisible: React.PropTypes.bool,
onOverlayClicked: React.PropTypes.func,
onCloseClicked: React.PropTypes.func,
intl: React.PropTypes.object.isRequired,
children: React.PropTypes.node
},
mixins: [PureRenderMixin],
componentDidMount () {
this._listener = e => {
if (this.props.isVisible && e.key === 'Escape') {
this.props.onCloseClicked();
}
};
window.addEventListener('keyup', this._listener);
},
componentWillUnmount () {
window.removeEventListener('keyup', this._listener);
},
stopPropagation (e) {
e.stopPropagation();
},
render () {
const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props;
return (
<Motion defaultStyle={{ backgroundOpacity: 0, opacity: 0, y: -400 }} style={{ backgroundOpacity: spring(isVisible ? 50 : 0), opacity: isVisible ? spring(200) : 0, y: spring(isVisible ? 0 : -400, { stiffness: 150, damping: 12 }) }}>
{({ backgroundOpacity, opacity, y }) =>
<div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex', pointerEvents: !isVisible ? 'none' : 'auto'}} onClick={onOverlayClicked}>
<div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }} onClick={this.stopPropagation}>
<IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
{children}
</div>
</div>
}
</Motion>
);
}
});
export default injectIntl(Lightbox);

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

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

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

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

@ -4,6 +4,7 @@ import emojify from '../../../emoji';
import escapeTextContentForBrowser from 'escape-html';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from '../../../components/icon_button';
import { Motion, spring } from 'react-motion';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@ -11,6 +12,47 @@ const messages = defineMessages({
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
});
const Avatar = React.createClass({
propTypes: {
account: ImmutablePropTypes.map.isRequired
},
getInitialState () {
return {
isHovered: false
};
},
mixins: [PureRenderMixin],
handleMouseOver () {
if (this.state.isHovered) return;
this.setState({ isHovered: true });
},
handleMouseOut () {
if (!this.state.isHovered) return;
this.setState({ isHovered: false });
},
render () {
const { account } = this.props;
const { isHovered } = this.state;
return (
<Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
{({ radius }) =>
<a href={account.get('url')} className='account__header__avatar' target='_blank' rel='noopener' style={{ display: 'block', width: '90px', height: '90px', margin: '0 auto', marginBottom: '10px', borderRadius: `${radius}px`, overflow: 'hidden' }} onMouseOver={this.handleMouseOver} onMouseOut={this.handleMouseOut}>
<img src={account.get('avatar')} alt={account.get('acct')} style={{ display: 'block', width: '90px', height: '90px' }} />
</a>
}
</Motion>
);
}
});
const Header = React.createClass({
propTypes: {
@ -68,14 +110,9 @@ const Header = React.createClass({
return (
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
<div style={{ padding: '20px 10px' }}>
<a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
<div className='account__header__avatar' style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}>
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
</div>
<Avatar account={account} />
<span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
</a>
<span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
<div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />

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

@ -1,44 +0,0 @@
import { Link } from 'react-router';
import { injectIntl, defineMessages } from 'react-intl';
const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' },
community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
});
const Drawer = ({ children, withHeader, intl }) => {
let header = '';
if (withHeader) {
header = (
<div className='drawer__header'>
<Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
<Link title={intl.formatMessage(messages.community)} className='drawer__tab' to='/timelines/public/local'><i className='fa fa-fw fa-users' /></Link>
<Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
<a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
<a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
</div>
);
}
return (
<div className='drawer'>
{header}
<div className='drawer__inner'>
{children}
</div>
</div>
);
};
Drawer.propTypes = {
withHeader: React.PropTypes.bool,
children: React.PropTypes.node,
intl: React.PropTypes.object
};
export default injectIntl(Drawer);

@ -1,123 +1,68 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Autosuggest from 'react-autosuggest';
import AutosuggestAccountContainer from '../containers/autosuggest_account_container';
import AutosuggestStatusContainer from '../containers/autosuggest_status_container';
import { debounce } from 'react-decoration';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }
});
const getSuggestionValue = suggestion => suggestion.value;
const renderSuggestion = suggestion => {
if (suggestion.type === 'account') {
return <AutosuggestAccountContainer id={suggestion.id} />;
} else if (suggestion.type === 'hashtag') {
return <span>#{suggestion.id}</span>;
} else {
return <AutosuggestStatusContainer id={suggestion.id} />;
}
};
const renderSectionTitle = section => (
<strong><FormattedMessage id={`search.${section.title}`} defaultMessage={section.title} /></strong>
);
const getSectionSuggestions = section => section.items;
const outerStyle = {
padding: '10px',
lineHeight: '20px',
position: 'relative'
};
const iconStyle = {
position: 'absolute',
top: '18px',
right: '20px',
fontSize: '18px',
pointerEvents: 'none'
};
const Search = React.createClass({
contextTypes: {
router: React.PropTypes.object
},
propTypes: {
suggestions: React.PropTypes.array.isRequired,
value: React.PropTypes.string.isRequired,
submitted: React.PropTypes.bool,
onChange: React.PropTypes.func.isRequired,
onSubmit: React.PropTypes.func.isRequired,
onClear: React.PropTypes.func.isRequired,
onFetch: React.PropTypes.func.isRequired,
onReset: React.PropTypes.func.isRequired,
onShow: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
onChange (_, { newValue }) {
if (typeof newValue !== 'string') {
return;
}
this.props.onChange(newValue);
handleChange (e) {
this.props.onChange(e.target.value);
},
onSuggestionsClearRequested () {
handleClear (e) {
e.preventDefault();
this.props.onClear();
},
@debounce(500)
onSuggestionsFetchRequested ({ value }) {
value = value.replace('#', '');
this.props.onFetch(value.trim());
handleKeyDown (e) {
if (e.key === 'Enter') {
e.preventDefault();
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 () {
const inputProps = {
placeholder: this.props.intl.formatMessage(messages.placeholder),
value: this.props.value,
onChange: this.onChange,
className: 'search__input'
};
const { intl, value, submitted } = this.props;
const hasValue = value.length > 0 || submitted;
return (
<div className='search' style={outerStyle}>
<Autosuggest
multiSection={true}
suggestions={this.props.suggestions}
focusFirstSuggestion={true}
focusInputOnSuggestionClick={false}
alwaysRenderSuggestions={false}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
renderSectionTitle={renderSectionTitle}
getSectionSuggestions={getSectionSuggestions}
inputProps={inputProps}
<div className='search'>
<input
className='search__input'
type='text'
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={this.handleChange}
onKeyUp={this.handleKeyDown}
onFocus={this.handleFocus}
/>
<div style={iconStyle}><i className='fa fa-search' /></div>
<div className='search__icon'>
<i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
<i className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} onClick={this.handleClear} />
</div>
</div>
);
},
}
});

@ -0,0 +1,68 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container';
import { Link } from 'react-router';
const SearchResults = React.createClass({
propTypes: {
results: ImmutablePropTypes.map.isRequired
},
mixins: [PureRenderMixin],
render () {
const { results } = this.props;
let accounts, statuses, hashtags;
let count = 0;
if (results.get('accounts') && results.get('accounts').size > 0) {
count += results.get('accounts').size;
accounts = (
<div className='search-results__section'>
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
</div>
);
}
if (results.get('statuses') && results.get('statuses').size > 0) {
count += results.get('statuses').size;
statuses = (
<div className='search-results__section'>
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
</div>
);
}
if (results.get('hashtags') && results.get('hashtags').size > 0) {
count += results.get('hashtags').size;
hashtags = (
<div className='search-results__section'>
{results.get('hashtags').map(hashtag =>
<Link className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
#{hashtag}
</Link>
)}
</div>
);
}
return (
<div className='search-results'>
<div className='search-results__header'>
<FormattedMessage id='search_results.total' defaultMessage='{count} {count, plural, one {result} other {results}}' values={{ count }} />
</div>
{accounts}
{statuses}
{hashtags}
</div>
);
}
});
export default SearchResults;

@ -1,31 +0,0 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle';
import Collapsable from '../../../components/collapsable';
const SensitiveToggle = React.createClass({
propTypes: {
hasMedia: React.PropTypes.bool,
isSensitive: React.PropTypes.bool,
onChange: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
render () {
const { hasMedia, isSensitive, onChange } = this.props;
return (
<Collapsable isVisible={hasMedia} fullHeight={39.5}>
<label className='compose-form__label'>
<Toggle checked={isSensitive} onChange={onChange} />
<span className='compose-form__label__text'><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark media as sensitive' /></span>
</label>
</Collapsable>
);
}
});
export default SensitiveToggle;

@ -1,27 +0,0 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import { FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle';
const SpoilerToggle = React.createClass({
propTypes: {
isSpoiler: React.PropTypes.bool,
onChange: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
render () {
const { isSpoiler, onChange } = this.props;
return (
<label className='compose-form__label with-border' style={{ marginTop: '10px' }}>
<Toggle checked={isSpoiler} onChange={onChange} />
<span className='compose-form__label__text'><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span>
</label>
);
}
});
export default SpoilerToggle;

@ -1,15 +1,15 @@
import { connect } from 'react-redux';
import {
changeSearch,
clearSearchSuggestions,
fetchSearchSuggestions,
resetSearch
clearSearch,
submitSearch,
showSearch
} from '../../../actions/search';
import Search from '../components/search';
const mapStateToProps = state => ({
suggestions: state.getIn(['search', 'suggestions']),
value: state.getIn(['search', 'value'])
value: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted'])
});
const mapDispatchToProps = dispatch => ({
@ -19,15 +19,15 @@ const mapDispatchToProps = dispatch => ({
},
onClear () {
dispatch(clearSearchSuggestions());
dispatch(clearSearch());
},
onFetch (value) {
dispatch(fetchSearchSuggestions(value));
onSubmit () {
dispatch(submitSearch());
},
onReset () {
dispatch(resetSearch());
onShow () {
dispatch(showSearch());
}
});

@ -0,0 +1,8 @@
import { connect } from 'react-redux';
import SearchResults from '../components/search_results';
const mapStateToProps = state => ({
results: state.getIn(['search', 'results'])
});
export default connect(mapStateToProps)(SearchResults);

@ -1,17 +1,34 @@
import Drawer from './components/drawer';
import ComposeFormContainer from './containers/compose_form_container';
import UploadFormContainer from './containers/upload_form_container';
import NavigationContainer from './containers/navigation_container';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import SearchContainer from './containers/search_container';
import { connect } from 'react-redux';
import { mountCompose, unmountCompose } from '../../actions/compose';
import { Link } from 'react-router';
import { injectIntl, defineMessages } from 'react-intl';
import SearchContainer from './containers/search_container';
import { Motion, spring } from 'react-motion';
import SearchResultsContainer from './containers/search_results_container';
const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' },
community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
});
const mapStateToProps = state => ({
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden'])
});
const Compose = React.createClass({
propTypes: {
dispatch: React.PropTypes.func.isRequired,
withHeader: React.PropTypes.bool
withHeader: React.PropTypes.bool,
showSearch: React.PropTypes.bool,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
@ -25,15 +42,46 @@ const Compose = React.createClass({
},
render () {
const { withHeader, showSearch, intl } = this.props;
let header = '';
if (withHeader) {
header = (
<div className='drawer__header'>
<Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
<Link title={intl.formatMessage(messages.community)} className='drawer__tab' to='/timelines/public/local'><i className='fa fa-fw fa-users' /></Link>
<Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
<a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
<a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
</div>
);
}
return (
<Drawer withHeader={this.props.withHeader}>
<div className='drawer'>
{header}
<SearchContainer />
<div className='drawer__pager'>
<div className='drawer__inner'>
<NavigationContainer />
<ComposeFormContainer />
</Drawer>
</div>
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) =>
<div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
<SearchResultsContainer />
</div>
}
</Motion>
</div>
</div>
);
}
});
export default connect()(Compose);
export default connect(mapStateToProps)(injectIntl(Compose));

@ -43,9 +43,7 @@ const GettingStarted = ({ intl, me }) => {
<div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}>
<div className='static-content getting-started'>
<p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
<p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
<p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p>
<p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p>
</div>
</div>
</Column>

@ -6,7 +6,7 @@ import SettingToggle from '../../notifications/components/setting_toggle';
import SettingText from './setting_text';
const messages = defineMessages({
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter by regular expressions' }
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }
});
const outerStyle = {
@ -44,7 +44,7 @@ const ColumnSettings = React.createClass({
<span className='column-settings--section' style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
<div style={rowStyle}>
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reblogs' />} />
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} />
</div>
<div style={rowStyle}>

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

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

@ -0,0 +1,133 @@
import LoadingIndicator from '../../../components/loading_indicator';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ExtendedVideoPlayer from '../../../components/extended_video_player';
import ImageLoader from 'react-imageloader';
import { defineMessages, injectIntl } from 'react-intl';
import IconButton from '../../../components/icon_button';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' }
});
const leftNavStyle = {
position: 'absolute',
background: 'rgba(0, 0, 0, 0.5)',
padding: '30px 15px',
cursor: 'pointer',
fontSize: '24px',
top: '0',
left: '-61px',
boxSizing: 'border-box',
height: '100%',
display: 'flex',
alignItems: 'center'
};
const rightNavStyle = {
position: 'absolute',
background: 'rgba(0, 0, 0, 0.5)',
padding: '30px 15px',
cursor: 'pointer',
fontSize: '24px',
top: '0',
right: '-61px',
boxSizing: 'border-box',
height: '100%',
display: 'flex',
alignItems: 'center'
};
const closeStyle = {
position: 'absolute',
top: '4px',
right: '4px'
};
const MediaModal = React.createClass({
propTypes: {
media: ImmutablePropTypes.list.isRequired,
index: React.PropTypes.number.isRequired,
onClose: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired
},
getInitialState () {
return {
index: null
};
},
mixins: [PureRenderMixin],
handleNextClick () {
this.setState({ index: (this.getIndex() + 1) % this.props.media.size});
},
handlePrevClick () {
this.setState({ index: (this.getIndex() - 1) % this.props.media.size});
},
handleKeyUp (e) {
switch(e.key) {
case 'ArrowLeft':
this.handlePrevClick();
break;
case 'ArrowRight':
this.handleNextClick();
break;
}
},
componentDidMount () {
window.addEventListener('keyup', this.handleKeyUp, false);
},
componentWillUnmount () {
window.removeEventListener('keyup', this.handleKeyUp);
},
getIndex () {
return this.state.index !== null ? this.state.index : this.props.index;
},
render () {
const { media, intl, onClose } = this.props;
const index = this.getIndex();
const attachment = media.get(index);
const url = attachment.get('url');
let leftNav, rightNav, content;
leftNav = rightNav = content = '';
if (media.size > 1) {
leftNav = <div style={leftNavStyle} className='modal-container__nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
rightNav = <div style={rightNavStyle} className='modal-container__nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
}
if (attachment.get('type') === 'image') {
content = <ImageLoader src={url} imgProps={{ style: { display: 'block' } }} />;
} else if (attachment.get('type') === 'gifv') {
content = <ExtendedVideoPlayer src={url} />;
}
return (
<div className='modal-root__modal media-modal'>
{leftNav}
<div>
<IconButton title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} style={closeStyle} />
{content}
</div>
{rightNav}
</div>
);
}
});
export default injectIntl(MediaModal);

@ -0,0 +1,80 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import MediaModal from './media_modal';
import { TransitionMotion, spring } from 'react-motion';
const MODAL_COMPONENTS = {
'MEDIA': MediaModal
};
const ModalRoot = React.createClass({
propTypes: {
type: React.PropTypes.string,
props: React.PropTypes.object,
onClose: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
handleKeyUp (e) {
if (e.key === 'Escape' && !!this.props.type) {
this.props.onClose();
}
},
componentDidMount () {
window.addEventListener('keyup', this.handleKeyUp, false);
},
componentWillUnmount () {
window.removeEventListener('keyup', this.handleKeyUp);
},
willEnter () {
return { opacity: 0, scale: 0.98 };
},
willLeave () {
return { opacity: spring(0), scale: spring(0.98) };
},
render () {
const { type, props, onClose } = this.props;
const items = [];
if (!!type) {
items.push({
key: type,
data: { type, props },
style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) }
});
}
return (
<TransitionMotion
styles={items}
willEnter={this.willEnter}
willLeave={this.willLeave}>
{interpolatedStyles =>
<div className='modal-root'>
{interpolatedStyles.map(({ key, data: { type, props }, style }) => {
const SpecificComponent = MODAL_COMPONENTS[type];
return (
<div key={key}>
<div className='modal-root__overlay' style={{ opacity: style.opacity, transform: `translateZ(0px)` }} onClick={onClose} />
<div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
<SpecificComponent {...props} onClose={onClose} />
</div>
</div>
);
})}
</div>
}
</TransitionMotion>
);
}
});
export default ModalRoot;

@ -1,15 +1,23 @@
import { Link } from 'react-router';
import { FormattedMessage } from 'react-intl';
const TabsBar = () => {
const TabsBar = React.createClass({
render () {
return (
<div className='tabs-bar'>
<Link className='tabs-bar__link' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /> <FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
<Link className='tabs-bar__link' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /> <FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
<Link className='tabs-bar__link' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /> <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
<Link className='tabs-bar__link' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link>
<Link className='tabs-bar__link primary' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
<Link className='tabs-bar__link primary' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
<Link className='tabs-bar__link primary' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
<Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public/local'><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></Link>
<Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public'><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></Link>
<Link className='tabs-bar__link primary' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link>
</div>
);
};
}
});
export default TabsBar;

@ -1,170 +1,16 @@
import { connect } from 'react-redux';
import {
closeModal,
decreaseIndexInModal,
increaseIndexInModal
} from '../../../actions/modal';
import Lightbox from '../../../components/lightbox';
import ImageLoader from 'react-imageloader';
import LoadingIndicator from '../../../components/loading_indicator';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ExtendedVideoPlayer from '../../../components/extended_video_player';
import { closeModal } from '../../../actions/modal';
import ModalRoot from '../components/modal_root';
const mapStateToProps = state => ({
media: state.getIn(['modal', 'media']),
index: state.getIn(['modal', 'index']),
isVisible: state.getIn(['modal', 'open'])
type: state.get('modal').modalType,
props: state.get('modal').modalProps
});
const mapDispatchToProps = dispatch => ({
onCloseClicked () {
onClose () {
dispatch(closeModal());
},
onOverlayClicked () {
dispatch(closeModal());
},
onNextClicked () {
dispatch(increaseIndexInModal());
},
onPrevClicked () {
dispatch(decreaseIndexInModal());
}
});
const imageStyle = {
display: 'block',
maxWidth: '80vw',
maxHeight: '80vh'
};
const loadingStyle = {
width: '400px',
paddingBottom: '120px'
};
const preloader = () => (
<div className='modal-container--preloader' style={loadingStyle}>
<LoadingIndicator />
</div>
);
const leftNavStyle = {
position: 'absolute',
background: 'rgba(0, 0, 0, 0.5)',
padding: '30px 15px',
cursor: 'pointer',
fontSize: '24px',
top: '0',
left: '-61px',
boxSizing: 'border-box',
height: '100%',
display: 'flex',
alignItems: 'center'
};
const rightNavStyle = {
position: 'absolute',
background: 'rgba(0, 0, 0, 0.5)',
padding: '30px 15px',
cursor: 'pointer',
fontSize: '24px',
top: '0',
right: '-61px',
boxSizing: 'border-box',
height: '100%',
display: 'flex',
alignItems: 'center'
};
const Modal = React.createClass({
propTypes: {
media: ImmutablePropTypes.list,
index: React.PropTypes.number.isRequired,
isVisible: React.PropTypes.bool,
onCloseClicked: React.PropTypes.func,
onOverlayClicked: React.PropTypes.func,
onNextClicked: React.PropTypes.func,
onPrevClicked: React.PropTypes.func
},
mixins: [PureRenderMixin],
handleNextClick () {
this.props.onNextClicked();
},
handlePrevClick () {
this.props.onPrevClicked();
},
componentDidMount () {
this._listener = e => {
if (!this.props.isVisible) {
return;
}
switch(e.key) {
case 'ArrowLeft':
this.props.onPrevClicked();
break;
case 'ArrowRight':
this.props.onNextClicked();
break;
}
};
window.addEventListener('keyup', this._listener);
},
componentWillUnmount () {
window.removeEventListener('keyup', this._listener);
},
render () {
const { media, index, ...other } = this.props;
if (!media) {
return null;
}
const attachment = media.get(index);
const url = attachment.get('url');
let leftNav, rightNav, content;
leftNav = rightNav = content = '';
if (media.size > 1) {
leftNav = <div style={leftNavStyle} className='modal-container--nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
rightNav = <div style={rightNavStyle} className='modal-container--nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
}
if (attachment.get('type') === 'image') {
content = (
<ImageLoader
src={url}
preloader={preloader}
imgProps={{ style: imageStyle }}
/>
);
} else if (attachment.get('type') === 'gifv') {
content = <ExtendedVideoPlayer src={url} />;
}
return (
<Lightbox {...other}>
{leftNav}
{content}
{rightNav}
</Lightbox>
);
}
});
export default connect(mapStateToProps, mapDispatchToProps)(Modal);
export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot);

@ -36,15 +36,33 @@ const UI = React.createClass({
this.setState({ width: window.innerWidth });
},
handleDragEnter (e) {
e.preventDefault();
if (!this.dragTargets) {
this.dragTargets = [];
}
if (this.dragTargets.indexOf(e.target) === -1) {
this.dragTargets.push(e.target);
}
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
this.setState({ draggingOver: true });
}
},
handleDragOver (e) {
e.preventDefault();
e.stopPropagation();
try {
e.dataTransfer.dropEffect = 'copy';
} catch (err) {
if (e.dataTransfer.effectAllowed === 'all' || e.dataTransfer.effectAllowed === 'uninitialized') {
this.setState({ draggingOver: true });
}
return false;
},
handleDrop (e) {
@ -57,14 +75,25 @@ const UI = React.createClass({
}
},
handleDragLeave () {
handleDragLeave (e) {
e.preventDefault();
e.stopPropagation();
this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
if (this.dragTargets.length > 0) {
return;
}
this.setState({ draggingOver: false });
},
componentWillMount () {
window.addEventListener('resize', this.handleResize, { passive: true });
window.addEventListener('dragover', this.handleDragOver);
window.addEventListener('drop', this.handleDrop);
document.addEventListener('dragenter', this.handleDragEnter, false);
document.addEventListener('dragover', this.handleDragOver, false);
document.addEventListener('drop', this.handleDrop, false);
document.addEventListener('dragleave', this.handleDragLeave, false);
this.props.dispatch(refreshTimeline('home'));
this.props.dispatch(refreshNotifications());
@ -72,8 +101,14 @@ const UI = React.createClass({
componentWillUnmount () {
window.removeEventListener('resize', this.handleResize);
window.removeEventListener('dragover', this.handleDragOver);
window.removeEventListener('drop', this.handleDrop);
document.removeEventListener('dragenter', this.handleDragEnter);
document.removeEventListener('dragover', this.handleDragOver);
document.removeEventListener('drop', this.handleDrop);
document.removeEventListener('dragleave', this.handleDragLeave);
},
setRef (c) {
this.node = c;
},
render () {
@ -100,7 +135,7 @@ const UI = React.createClass({
}
return (
<div className='ui' onDragLeave={this.handleDragLeave}>
<div className='ui' ref={this.setRef}>
<TabsBar />
{mountedColumns}

@ -25,7 +25,7 @@ const en = {
"getting_started.heading": "Getting started",
"getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.",
"getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.",
"getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}. {apps}.",
"getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.",
"column.home": "Home",
"column.community": "Local timeline",
"column.public": "Federated timeline",
@ -40,7 +40,7 @@ const en = {
"compose_form.sensitive": "Mark media as sensitive",
"compose_form.spoiler": "Hide text behind warning",
"compose_form.private": "Mark as private",
"compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}} to not leak your status?",
"compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
"compose_form.unlisted": "Do not display on public timelines",
"navigation_bar.edit_profile": "Edit profile",
"navigation_bar.preferences": "Preferences",

@ -0,0 +1,68 @@
const fi = {
"column_back_button.label": "Takaisin",
"lightbox.close": "Sulje",
"loading_indicator.label": "Ladataan...",
"status.mention": "Mainitse @{name}",
"status.delete": "Poista",
"status.reply": "Vastaa",
"status.reblog": "Boostaa",
"status.favourite": "Tykkää",
"status.reblogged_by": "{name} boostattu",
"status.sensitive_warning": "Arkaluontoista sisältöä",
"status.sensitive_toggle": "Klikkaa nähdäksesi",
"video_player.toggle_sound": "Äänet päälle/pois",
"account.mention": "Mainitse @{name}",
"account.edit_profile": "Muokkaa",
"account.unblock": "Salli @{name}",
"account.unfollow": "Lopeta seuraaminen",
"account.block": "Estä @{name}",
"account.follow": "Seuraa",
"account.posts": "Postit",
"account.follows": "Seuraa",
"account.followers": "Seuraajia",
"account.follows_you": "Seuraa sinua",
"account.requested": "Odottaa hyväksyntää",
"getting_started.heading": "Päästä alkuun",
"getting_started.about_addressing": "Voit seurata ihmisiä jos tiedät heidän käyttäjänimensä ja domainin missä he ovat syöttämällä e-mail-esque osoitteen Etsi kenttään.",
"getting_started.about_shortcuts": "Jos etsimäsi henkilö on samassa domainissa kuin sinä, pelkkä käyttäjänimi kelpaa. Sama pätee kun mainitset ihmisiä statuksessasi",
"getting_started.open_source_notice": "Mastodon Mastodon on avoimen lähdekoodin ohjelma. Voit avustaa tai raportoida ongelmia githubissa {github}. {apps}.",
"column.home": "Koti",
"column.community": "Paikallinen aikajana",
"column.public": "Yhdistetty aikajana",
"column.notifications": "Ilmoitukset",
"tabs_bar.compose": "Luo",
"tabs_bar.home": "Koti",
"tabs_bar.mentions": "Maininnat",
"tabs_bar.public": "Yleinen aikajana",
"tabs_bar.notifications": "Ilmoitukset",
"compose_form.placeholder": "Mitä sinulla on mielessä?",
"compose_form.publish": "Toot",
"compose_form.sensitive": "Merkitse media herkäksi",
"compose_form.spoiler": "Piiloita teksti varoituksen taakse",
"compose_form.private": "Merkitse yksityiseksi",
"compose_form.privacy_disclaimer": "Sinun yksityinen status toimitetaan mainitsemallesi käyttäjille domaineissa {domains}. Luotatko {domainsCount, plural, one {tähän palvelimeen} other {näihin palvelimiin}}? Postauksen yksityisyys toimii van Mastodon palvelimilla. Jos {domains} {domainsCount, plural, one {ei ole Mastodon palvelin} other {eivät ole Mastodon palvelin}}, viestiin ei tule Yksityinen-merkintää, ja sitä voidaan boostata tai muuten tehdä näkyväksi muille vastaanottajille.",
"compose_form.unlisted": "Älä näytä julkisilla aikajanoilla",
"navigation_bar.edit_profile": "Muokkaa profiilia",
"navigation_bar.preferences": "Ominaisuudet",
"navigation_bar.community_timeline": "Paikallinen aikajana",
"navigation_bar.public_timeline": "Yleinen aikajana",
"navigation_bar.logout": "Kirjaudu ulos",
"reply_indicator.cancel": "Peruuta",
"search.placeholder": "Hae",
"search.account": "Tili",
"search.hashtag": "Hashtag",
"upload_button.label": "Lisää mediaa",
"upload_form.undo": "Peru",
"notification.follow": "{name} seurasi sinua",
"notification.favourite": "{name} tykkäsi statuksestasi",
"notification.reblog": "{name} boostasi statustasi",
"notification.mention": "{name} mainitsi sinut",
"notifications.column_settings.alert": "Työpöytä ilmoitukset",
"notifications.column_settings.show": "Näytä sarakkeessa",
"notifications.column_settings.follow": "Uusia seuraajia:",
"notifications.column_settings.favourite": "Tykkäyksiä:",
"notifications.column_settings.mention": "Mainintoja:",
"notifications.column_settings.reblog": "Boosteja:",
};
export default fi;

@ -1,68 +1,91 @@
const fr = {
"account.block": "Bloquer",
"column_back_button.label": "Retour",
"lightbox.close": "Fermer",
"loading_indicator.label": "Chargement…",
"status.mention": "Mentionner",
"status.delete": "Effacer",
"status.reply": "Répondre",
"status.reblog": "Partager",
"status.favourite": "Ajouter aux favoris",
"status.reblogged_by": "{name} a partagé :",
"status.sensitive_warning": "Contenu délicat",
"status.sensitive_toggle": "Cliquer pour dévoiler",
"video_player.toggle_sound": "Mettre/Couper le son",
"account.mention": "Mentionner",
"account.edit_profile": "Modifier le profil",
"account.followers": "Abonnés",
"account.follows": "Abonnements",
"account.unblock": "Débloquer",
"account.unfollow": "Ne plus suivre",
"account.block": "Bloquer",
"account.mute": "Masquer",
"account.unmute": "Ne plus masquer",
"account.follow": "Suivre",
"account.follows_you": "Vous suit",
"account.mention": "Mentionner",
"account.posts": "Statuts",
"account.follows": "Abonnements",
"account.followers": "Abonnés",
"account.follows_you": "Vous suit",
"account.requested": "Invitation envoyée",
"account.unblock": "Débloquer",
"account.unfollow": "Ne plus suivre",
"column_back_button.label": "Retour",
"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.mentions": "Mentions",
"column.community": "Fil public local",
"column.public": "Fil public global",
"column.notifications": "Notifications",
"column.public": "Fil public",
"column.blocks": "Utilisateurs bloqués",
"column.favourites": "Favoris",
"tabs_bar.compose": "Composer",
"tabs_bar.home": "Accueil",
"tabs_bar.mentions": "Mentions",
"tabs_bar.public": "Fil public global",
"tabs_bar.notifications": "Notifications",
"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",
"loading_indicator.label": "Chargement…",
"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.logout": "Déconnexion",
"navigation_bar.preferences": "Préférences",
"navigation_bar.public_timeline": "Fil public",
"navigation_bar.community_timeline": "Fil public local",
"navigation_bar.public_timeline": "Fil public global",
"navigation_bar.blocks": "Utilisateurs bloqués",
"navigation_bar.favourites": "Favoris",
"navigation_bar.info": "Plus d'informations",
"notification.favourite": "{name} a ajouté à ses favoris :",
"navigation_bar.logout": "Déconnexion",
"reply_indicator.cancel": "Annuler",
"search.placeholder": "Chercher",
"search.account": "Compte",
"search.hashtag": "Mot-clé",
"search_results.total": "{count} {count, plural, one {résultat} other {résultats}}",
"upload_button.label": "Joindre un média",
"upload_form.undo": "Annuler",
"notification.follow": "{name} vous suit.",
"notification.mention": "{name} vous a mentionné⋅e :",
"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.favourite": "Favoris :",
"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 :",
"notifications.column_settings.show": "Afficher dans la colonne",
"reply_indicator.cancel": "Annuler",
"search.account": "Compte",
"search.hashtag": "Mot-clé",
"search.placeholder": "Chercher",
"status.delete": "Effacer",
"status.favourite": "Ajouter aux favoris",
"status.mention": "Mentionner",
"status.reblogged_by": "{name} a partagé :",
"status.reblog": "Partager",
"status.reply": "Répondre",
"status.sensitive_toggle": "Cliquer pour dévoiler",
"status.sensitive_warning": "Contenu délicat",
"tabs_bar.compose": "Composer",
"tabs_bar.home": "Accueil",
"tabs_bar.mentions": "Mentions",
"tabs_bar.notifications": "Notifications",
"tabs_bar.public": "Public",
"upload_button.label": "Joindre un média",
"upload_form.undo": "Annuler",
"video_player.toggle_sound": "Mettre/Couper le son",
"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;

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

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

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

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

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

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

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

@ -24,4 +24,17 @@ $(() => {
window.location.href = $(e.target).attr('href');
}
});
$('.status__content__spoiler-link').on('click', e => {
e.preventDefault();
const contentEl = $(e.target).parent().parent().find('div');
if (contentEl.is(':visible')) {
contentEl.hide();
$(e.target).parent().attr('style', 'margin-bottom: 0');
} else {
contentEl.show();
$(e.target).parent().attr('style', null);
}
});
});

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

@ -21,7 +21,7 @@
text-decoration: none;
transition: all 100ms ease-in;
&:hover {
&:hover, &:active, &:focus {
background-color: lighten($color4, 7%);
transition: all 200ms ease-out;
}
@ -54,7 +54,7 @@
cursor: pointer;
transition: all 100ms ease-in;
&:hover {
&:hover, &:active, &:focus {
color: lighten($color1, 33%);
transition: all 200ms ease-out;
}
@ -79,7 +79,7 @@
&.inverted {
color: lighten($color1, 33%);
&:hover {
&:hover, &:active, &:focus {
color: lighten($color1, 26%);
}
@ -105,7 +105,7 @@
outline: 0;
transition: all 100ms ease-in;
&:hover {
&:hover, &:active, &:focus {
color: lighten($color1, 26%);
transition: all 200ms ease-out;
}
@ -424,6 +424,7 @@ a.status__content__spoiler-link {
.account__header__content {
word-wrap: break-word;
word-break: normal;
font-weight: 400;
overflow: hidden;
color: $color3;
@ -764,8 +765,19 @@ a.status__content__spoiler-link {
}
}
.drawer__pager {
box-sizing: border-box;
padding: 0;
flex-grow: 1;
position: relative;
overflow: hidden;
display: flex;
}
.drawer__inner {
//background: linear-gradient(rgba(lighten($color1, 13%), 1), rgba(lighten($color1, 13%), 0.65));
position: absolute;
top: 0;
left: 0;
background: lighten($color1, 13%);
box-sizing: border-box;
padding: 0;
@ -773,7 +785,12 @@ a.status__content__spoiler-link {
flex-direction: column;
overflow: hidden;
overflow-y: auto;
flex-grow: 1;
width: 100%;
height: 100%;
&.darker {
background: $color1;
}
}
.drawer__header {
@ -842,11 +859,25 @@ a.status__content__spoiler-link {
font-size:12px;
font-weight: 500;
border-bottom: 2px solid lighten($color1, 8%);
transition: all 200ms linear;
.fa {
font-weight: 400;
}
&.active {
border-bottom: 2px solid $color4;
color: $color4;
}
&:hover, &:focus, &:active {
background: lighten($color1, 14%);
transition: all 100ms linear;
}
span {
display: none;
}
}
@media screen and (min-width: 360px) {
@ -854,6 +885,22 @@ a.status__content__spoiler-link {
margin: 10px;
margin-bottom: 0;
}
.search {
margin-bottom: 10px;
}
}
@media screen and (min-width: 600px) {
.tabs-bar__link {
.fa {
margin-right: 5px;
}
span {
display: inline;
}
}
}
@media screen and (min-width: 1025px) {
@ -1102,11 +1149,9 @@ a.status__content__spoiler-link {
.getting-started {
box-sizing: border-box;
overflow-y: auto;
padding-bottom: 235px;
background: image-url('mastodon-getting-started.png') no-repeat bottom left;
height: auto;
min-height: 100%;
background: image-url('mastodon-getting-started.png') no-repeat 0 100% local;
flex: 1 0 auto;
p {
color: $color2;
@ -1224,26 +1269,6 @@ button.active i.fa-retweet {
}
}
.search {
.fa {
color: $color3;
}
}
.search__input {
box-sizing: border-box;
display: block;
width: 100%;
border: none;
padding: 10px;
padding-right: 30px;
font-family: inherit;
background: $color1;
color: $color3;
font-size: 14px;
margin: 0;
}
.loading-indicator {
color: $color2;
}
@ -1286,7 +1311,7 @@ button.active i.fa-retweet {
color: $color3;
}
.modal-container--nav {
.modal-container__nav {
color: $color5;
}
@ -1640,7 +1665,7 @@ button.active i.fa-retweet {
margin-top: 2px;
}
&:hover {
&:hover, &:active, &:focus {
img {
opacity: 1;
filter: none;
@ -1723,3 +1748,147 @@ button.active i.fa-retweet {
box-shadow: 2px 4px 6px rgba($color8, 0.1);
}
}
.search {
position: relative;
}
.search__input {
padding-right: 30px;
color: $color2;
outline: 0;
box-sizing: border-box;
display: block;
width: 100%;
border: none;
padding: 10px;
padding-right: 30px;
font-family: inherit;
background: $color1;
color: $color3;
font-size: 14px;
margin: 0;
&::-moz-focus-inner {
border: 0;
}
&::-moz-focus-inner, &:focus, &:active {
outline: 0 !important;
}
&:focus {
background: lighten($color1, 4%);
}
}
.search__icon {
.fa {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
display: inline-block;
opacity: 0;
transition: all 100ms linear;
font-size: 18px;
width: 18px;
height: 18px;
color: $color2;
cursor: default;
pointer-events: none;
&.active {
pointer-events: auto;
opacity: 0.3;
}
}
.fa-search {
transform: translateZ(0) rotate(90deg);
&.active {
pointer-events: none;
transform: translateZ(0) rotate(0deg);
}
}
.fa-times-circle {
top: 11px;
transform: translateZ(0) rotate(0deg);
cursor: pointer;
&.active {
transform: translateZ(0) rotate(90deg);
}
&:hover {
color: $color5;
}
}
}
.search-results__header {
color: lighten($color1, 26%);
background: lighten($color1, 2%);
border-bottom: 1px solid darken($color1, 4%);
padding: 15px 10px;
font-size: 14px;
font-weight: 500;
}
.search-results__hashtag {
display: block;
padding: 10px;
color: $color2;
text-decoration: none;
&:hover, &:active, &:focus {
color: lighten($color2, 4%);
text-decoration: underline;
}
}
.modal-root__overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
opacity: 0;
background: rgba($color8, 0.7);
}
.modal-root__container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
align-content: space-around;
z-index: 9999;
opacity: 0;
pointer-events: none;
user-select: none;
}
.modal-root__modal {
pointer-events: auto;
display: flex;
}
.media-modal {
max-width: 80vw;
max-height: 80vh;
position: relative;
img, video {
max-width: 80vw;
max-height: 80vh;
}
}

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

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

@ -9,6 +9,24 @@ class Admin::DomainBlocksController < ApplicationController
@blocks = DomainBlock.paginate(page: params[:page], per_page: 40)
end
def new
@domain_block = DomainBlock.new
end
def create
@domain_block = DomainBlock.new(resource_params)
if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id)
redirect_to admin_domain_blocks_path, notice: 'Domain block is now being processed'
else
render action: :new
end
end
private
def resource_params
params.require(:domain_block).permit(:domain, :severity)
end
end

@ -7,7 +7,7 @@ class Admin::ReportsController < ApplicationController
layout 'admin'
def index
@reports = Report.includes(:account, :target_account).paginate(page: params[:page], per_page: 40)
@reports = Report.includes(:account, :target_account).order('id desc').paginate(page: params[:page], per_page: 40)
@reports = params[:action_taken].present? ? @reports.resolved : @reports.unresolved
end
@ -16,19 +16,19 @@ class Admin::ReportsController < ApplicationController
end
def resolve
@report.update(action_taken: true)
@report.update(action_taken: true, action_taken_by_account_id: current_account.id)
redirect_to admin_report_path(@report)
end
def suspend
Admin::SuspensionWorker.perform_async(@report.target_account.id)
@report.update(action_taken: true)
Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id)
redirect_to admin_report_path(@report)
end
def silence
@report.target_account.update(silenced: true)
@report.update(action_taken: true)
Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id)
redirect_to admin_report_path(@report)
end

@ -4,6 +4,12 @@ class Api::V1::AppsController < ApiController
respond_to :json
def create
@app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes), website: params[:website])
@app = Doorkeeper::Application.create!(name: app_params[:client_name], redirect_uri: app_params[:redirect_uris], scopes: (app_params[:scopes] || Doorkeeper.configuration.default_scopes), website: app_params[:website])
end
private
def app_params
params.permit(:client_name, :redirect_uris, :scopes, :website)
end
end

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

@ -10,10 +10,16 @@ class Api::V1::MediaController < ApiController
respond_to :json
def create
@media = MediaAttachment.create!(account: current_user.account, file: params[:file])
@media = MediaAttachment.create!(account: current_user.account, file: media_params[:file])
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
render json: { error: 'File type of uploaded media could not be verified' }, status: 422
rescue Paperclip::Error
render json: { error: 'Error processing thumbnail for uploaded media' }, status: 500
end
private
def media_params
params.permit(:file)
end
end

@ -12,13 +12,19 @@ class Api::V1::ReportsController < ApiController
end
def create
status_ids = params[:status_ids].is_a?(Enumerable) ? params[:status_ids] : [params[:status_ids]]
status_ids = report_params[:status_ids].is_a?(Enumerable) ? report_params[:status_ids] : [report_params[:status_ids]]
@report = Report.create!(account: current_account,
target_account: Account.find(params[:account_id]),
target_account: Account.find(report_params[:account_id]),
status_ids: Status.find(status_ids).pluck(:id),
comment: params[:comment])
comment: report_params[:comment])
render :show
end
private
def report_params
params.permit(:account_id, :comment, status_ids: [])
end
end

@ -62,10 +62,10 @@ class Api::V1::StatusesController < ApiController
end
def create
@status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids],
sensitive: params[:sensitive],
spoiler_text: params[:spoiler_text],
visibility: params[:visibility],
@status = PostStatusService.new.call(current_user.account, status_params[:status], status_params[:in_reply_to_id].blank? ? nil : Status.find(status_params[:in_reply_to_id]), media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive],
spoiler_text: status_params[:spoiler_text],
visibility: status_params[:visibility],
application: doorkeeper_token.application)
render action: :show
end
@ -111,4 +111,8 @@ class Api::V1::StatusesController < ApiController
@status = Status.find(params[:id])
raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account)
end
def status_params
params.permit(:status, :in_reply_to_id, :sensitive, :spoiler_text, :visibility, media_ids: [])
end
end

@ -11,8 +11,8 @@ class Api::V1::TimelinesController < ApiController
@statuses = cache_collection(@statuses)
set_maps(@statuses)
set_counters_maps(@statuses)
set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
# set_counters_maps(@statuses)
# set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
next_path = api_v1_home_timeline_url(max_id: @statuses.last.id) unless @statuses.empty?
prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
@ -27,8 +27,8 @@ class Api::V1::TimelinesController < ApiController
@statuses = cache_collection(@statuses)
set_maps(@statuses)
set_counters_maps(@statuses)
set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
# set_counters_maps(@statuses)
# set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
next_path = api_v1_public_timeline_url(max_id: @statuses.last.id) unless @statuses.empty?
prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
@ -44,8 +44,8 @@ class Api::V1::TimelinesController < ApiController
@statuses = cache_collection(@statuses)
set_maps(@statuses)
set_counters_maps(@statuses)
set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
# set_counters_maps(@statuses)
# set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id) unless @statuses.empty?
prev_path = api_v1_hashtag_timeline_url(params[:id], since_id: @statuses.first.id) unless @statuses.empty?

@ -39,7 +39,14 @@ class ApplicationController < ActionController::Base
end
def set_user_activity
current_user.touch(:current_sign_in_at) if !current_user.nil? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < 24.hours.ago)
return unless !current_user.nil? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < 24.hours.ago)
# Mark user as signed-in today
current_user.update_tracked_fields(request)
# If the sign in is after a two week break, we need to regenerate their feed
RegenerationWorker.perform_async(current_user.account_id) if current_user.last_sign_in_at < 14.days.ago
return
end
def check_suspension

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

@ -0,0 +1,34 @@
# frozen_string_literal: true
class Settings::ImportsController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_account
def show
@import = Import.new
end
def create
@import = Import.new(import_params)
@import.account = @account
if @import.save
ImportWorker.perform_async(@import.id)
redirect_to settings_import_path, notice: I18n.t('imports.success')
else
render action: :show
end
end
private
def set_account
@account = current_user.account
end
def import_params
params.require(:import).permit(:data, :type)
end
end

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

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

@ -52,7 +52,7 @@ class FeedManager
timeline_key = key(:home, into_account.id)
from_account.statuses.limit(MAX_ITEMS).each do |status|
next if filter?(:home, status, into_account)
next if status.direct_visibility? || filter?(:home, status, into_account)
redis.zadd(timeline_key, status.id, status.id)
end

@ -10,17 +10,9 @@ class Feed
max_id = '+inf' if max_id.blank?
since_id = '-inf' if since_id.blank?
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:last).map(&:to_i)
# If we're after most recent items and none are there, we need to precompute the feed
if unhydrated.empty? && max_id == '+inf' && since_id == '-inf'
RegenerationWorker.perform_async(@account.id, @type)
@statuses = Status.send("as_#{@type}_timeline", @account).cache_ids.paginate_by_max_id(limit, nil, nil)
else
status_map = Status.where(id: unhydrated).cache_ids.map { |s| [s.id, s] }.to_h
@statuses = unhydrated.map { |id| status_map[id] }.compact
end
@statuses
unhydrated.map { |id| status_map[id] }.compact
end
private

@ -0,0 +1,14 @@
# frozen_string_literal: true
class Import < ApplicationRecord
self.inheritance_column = false
enum type: [:following, :blocking]
belongs_to :account
FILE_TYPES = ['text/plain', 'text/csv'].freeze
has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV['PAPERCLIP_SECRET']
validates_attachment_content_type :data, content_type: FILE_TYPES
end

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

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

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

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

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

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

@ -24,7 +24,7 @@
.screenshot-with-signup
.mascot= image_tag 'fluffy-elephant-friend.png'
= simple_form_for(:user, url: user_registration_path) do |f|
= simple_form_for(@user, url: user_registration_path) do |f|
= f.simple_fields_for :account do |ff|
= ff.input :username, autofocus: true, placeholder: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username') }

@ -23,12 +23,12 @@
.counter{ class: active_nav_class(short_account_url(@account)) }
= link_to short_account_url(@account), class: 'u-url u-uid' do
%span.counter-label= t('accounts.posts')
%span.counter-number= number_with_delimiter @account.statuses.count
%span.counter-number= number_with_delimiter @account.statuses_count
.counter{ class: active_nav_class(following_account_url(@account)) }
= link_to following_account_url(@account) do
%span.counter-label= t('accounts.following')
%span.counter-number= number_with_delimiter @account.following.count
%span.counter-number= number_with_delimiter @account.following_count
.counter{ class: active_nav_class(followers_account_url(@account)) }
= link_to followers_account_url(@account) do
%span.counter-label= t('accounts.followers')
%span.counter-number= number_with_delimiter @account.followers.count
%span.counter-number= number_with_delimiter @account.followers_count

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

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

@ -0,0 +1,18 @@
- content_for :page_title do
New domain block
= simple_form_for @domain_block, url: admin_domain_blocks_path do |f|
= render 'shared/error_messages', object: @domain_block
%p.hint The domain block will not prevent creation of account entries in the database, but will retroactively and automatically apply specific moderation methods on those accounts.
= f.input :domain, placeholder: 'Domain'
= f.input :severity, collection: DomainBlock.severities.keys, wrapper: :with_label, include_blank: false
%p.hint
%strong Silence
will make the account's posts invisible to anyone who isn't following them.
%strong Suspend
will remove all of the account's content, media, and profile data.
.actions
= f.button :button, 'Create block', type: :submit

@ -8,9 +8,12 @@
%li= filter_link_to 'Unresolved', action_taken: nil
%li= filter_link_to 'Resolved', action_taken: '1'
= form_tag do
%table.table
%thead
%tr
%th
%th ID
%th Target
%th Reported by
@ -19,9 +22,11 @@
%tbody
- @reports.each do |report|
%tr
%td= check_box_tag 'select', report.id
%td= "##{report.id}"
%td= link_to report.target_account.acct, admin_account_path(report.target_account.id)
%td= link_to report.account.acct, admin_account_path(report.account.id)
%td= truncate(report.comment, length: 30, separator: ' ')
%td= table_link_to 'circle', 'View', admin_report_path(report)
= will_paginate @reports, pagination_options

@ -27,7 +27,7 @@
= link_to remove_admin_report_path(@report, status_id: status.id), method: :post, class: 'icon-button', style: 'font-size: 24px; width: 24px; height: 24px', title: 'Delete' do
= fa_icon 'trash'
- unless @report.action_taken?
- if !@report.action_taken?
%hr/
%div{ style: 'overflow: hidden' }
@ -36,3 +36,9 @@
= link_to 'Suspend account', suspend_admin_report_path(@report), method: :post, class: 'button'
%div{ style: 'float: left' }
= link_to 'Mark as resolved', resolve_admin_report_path(@report), method: :post, class: 'button'
- elsif !@report.action_taken_by_account.nil?
%hr/
%p
%strong Action taken by:
= @report.action_taken_by_account.acct

@ -6,6 +6,6 @@ node(:note) { |account| Formatter.instance.simplified_format(account)
node(:url) { |account| TagManager.instance.url_for(account) }
node(:avatar) { |account| full_asset_url(account.avatar.url(:original)) }
node(:header) { |account| full_asset_url(account.header.url(:original)) }
node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : (account.try(:followers_count) || account.followers.count) }
node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : (account.try(:following_count) || account.following.count) }
node(:statuses_count) { |account| defined?(@statuses_counts_map) ? (@statuses_counts_map[account.id] || 0) : (account.try(:statuses_count) || account.statuses.count) }
node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : account.followers_count }
node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : account.following_count }
node(:statuses_count) { |account| defined?(@statuses_counts_map) ? (@statuses_counts_map[account.id] || 0) : account.statuses_count }

@ -3,8 +3,8 @@ attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, :sensitiv
node(:uri) { |status| TagManager.instance.uri_for(status) }
node(:content) { |status| Formatter.instance.format(status) }
node(:url) { |status| TagManager.instance.url_for(status) }
node(:reblogs_count) { |status| defined?(@reblogs_counts_map) ? (@reblogs_counts_map[status.id] || 0) : (status.try(:reblogs_count) || status.reblogs.count) }
node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : (status.try(:favourites_count) || status.favourites.count) }
node(:reblogs_count) { |status| defined?(@reblogs_counts_map) ? (@reblogs_counts_map[status.id] || 0) : status.reblogs_count }
node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : status.favourites_count }
child :application do
extends 'api/v1/apps/show'

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

@ -0,0 +1,11 @@
- content_for :page_title do
= t('settings.import')
%p.hint= t('imports.preface')
= simple_form_for @import, url: settings_import_path do |f|
= f.input :type, collection: Import.types.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |type| I18n.t("imports.types.#{type}") }
= f.input :data, wrapper: :with_label, hint: t('simple_form.hints.imports.data')
.actions
= f.button :button, t('imports.upload'), type: :submit

@ -9,8 +9,10 @@
.status__content.e-content.p-name.emojify<
- unless status.spoiler_text.blank?
%p= status.spoiler_text
%div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
%p{ style: 'margin-bottom: 0' }<
%span>= "#{status.spoiler_text} "
%a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
%div{ style: "display: #{status.spoiler_text.blank? ? 'block' : 'none'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
- unless status.media_attachments.empty?
- if status.media_attachments.first.video?
@ -39,11 +41,11 @@
·
%span<
= fa_icon('retweet')
%span= status.reblogs.count
%span= status.reblogs_count
·
%span<
= fa_icon('star')
%span= status.favourites.count
%span= status.favourites_count
- if user_signed_in?
·

@ -14,8 +14,10 @@
.status__content.e-content.p-name.emojify<
- unless status.spoiler_text.blank?
%p= status.spoiler_text
%div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
%p{ style: 'margin-bottom: 0' }<
%span>= "#{status.spoiler_text} "
%a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
%div{ style: "display: #{status.spoiler_text.blank? ? 'block' : 'none'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
- unless status.media_attachments.empty?
.status__attachments

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

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

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

@ -0,0 +1,54 @@
# frozen_string_literal: true
require 'csv'
class ImportWorker
include Sidekiq::Worker
sidekiq_options queue: 'pull', retry: false
def perform(import_id)
import = Import.find(import_id)
case import.type
when 'blocking'
process_blocks(import)
when 'following'
process_follows(import)
end
import.destroy
end
private
def process_blocks(import)
from_account = import.account
CSV.foreach(import.data.path) do |row|
next if row.size != 1
begin
target_account = FollowRemoteAccountService.new.call(row[0])
next if target_account.nil?
BlockService.new.call(from_account, target_account)
rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError
next
end
end
end
def process_follows(import)
from_account = import.account
CSV.foreach(import.data.path) do |row|
next if row.size != 1
begin
FollowService.new.call(from_account, row[0])
rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError
next
end
end
end
end

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

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

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

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

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

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

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

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

@ -24,7 +24,7 @@ module Mastodon
# The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
# config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
config.i18n.available_locales = [:en, :de, :es, :pt, :fr, :hu, :uk, 'zh-CN']
config.i18n.available_locales = [:en, :de, :es, :pt, :fr, :hu, :uk, 'zh-CN', :fi]
config.i18n.default_locale = :en
# config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb')

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

@ -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:"

@ -58,3 +58,4 @@ fr:
not_locked: n'était pas verrouillé(e)
not_saved:
one: '1 erreur a empêché ce(tte) %{resource} d''être sauvegardé(e) :'
other: '%{count} erreurs ont empêché ce(tte) %{resource} d''être sauvegardé(e): '

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save