Merge branch 'master' into development

This commit is contained in:
halna_Tanaguru 2017-04-04 12:06:53 +02:00 committed by GitHub
commit 3abb0f7bc7
147 changed files with 3101 additions and 1089 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,11 @@
export const MEDIA_OPEN = 'MEDIA_OPEN';
export const MODAL_OPEN = 'MODAL_OPEN';
export const MODAL_CLOSE = 'MODAL_CLOSE';
export const MODAL_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
};
};

View File

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

View File

@ -14,6 +14,9 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
export const TIMELINE_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
};
};

View File

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

View File

@ -7,6 +7,7 @@ import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
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 });
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,15 @@
import { connect } from 'react-redux';
import {
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());
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,68 +1,91 @@
const fr = {
"account.block": "Bloquer",
"account.edit_profile": "Modifier le profil",
"account.followers": "Abonnés",
"account.follows": "Abonnements",
"account.follow": "Suivre",
"account.follows_you": "Vous suit",
"account.mention": "Mentionner",
"account.posts": "Statuts",
"account.requested": "Invitation envoyée",
"account.unblock": "Débloquer",
"account.unfollow": "Ne plus suivre",
"column_back_button.label": "Retour",
"column.home": "Accueil",
"column.mentions": "Mentions",
"column.notifications": "Notifications",
"column.public": "Fil public",
"compose_form.placeholder": "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…",
"navigation_bar.edit_profile": "Modifier le profil",
"navigation_bar.logout": "Déconnexion",
"navigation_bar.preferences": "Préférences",
"navigation_bar.public_timeline": "Fil public",
"notification.favourite": "{name} a ajouté à ses favoris :",
"notification.follow": "{name} vous suit.",
"notification.mention": "{name} vous a mentionné⋅e :",
"notification.reblog": "{name} a partagé votre statut :",
"notifications.column_settings.alert": "Notifications locales",
"notifications.column_settings.favourite": "Favoris :",
"notifications.column_settings.follow": "Nouveaux abonnés :",
"notifications.column_settings.mention": "Mentions :",
"notifications.column_settings.reblog": "Partages :",
"notifications.column_settings.show": "Afficher dans la colonne",
"reply_indicator.cancel": "Annuler",
"search.account": "Compte",
"search.hashtag": "Mot-clé",
"search.placeholder": "Chercher",
"status.delete": "Effacer",
"status.favourite": "Ajouter aux favoris",
"status.mention": "Mentionner",
"status.reblogged_by": "{name} a partagé :",
"status.reblog": "Partager",
"status.delete": "Effacer",
"status.reply": "Répondre",
"status.sensitive_toggle": "Cliquer pour dévoiler",
"status.reblog": "Partager",
"status.favourite": "Ajouter aux favoris",
"status.reblogged_by": "{name} a partagé :",
"status.sensitive_warning": "Contenu délicat",
"status.sensitive_toggle": "Cliquer pour dévoiler",
"video_player.toggle_sound": "Mettre/Couper le son",
"account.mention": "Mentionner",
"account.edit_profile": "Modifier le profil",
"account.unblock": "Débloquer",
"account.unfollow": "Ne plus suivre",
"account.block": "Bloquer",
"account.mute": "Masquer",
"account.unmute": "Ne plus masquer",
"account.follow": "Suivre",
"account.posts": "Statuts",
"account.follows": "Abonnements",
"account.followers": "Abonnés",
"account.follows_you": "Vous suit",
"account.requested": "Invitation envoyée",
"account.report": "Signaler",
"account.disclaimer": "Ce compte est situé sur une autre instance. Les nombres peuvent être plus grands.",
"getting_started.heading": "Pour commencer",
"getting_started.about_addressing": "Vous pouvez suivre les statuts de quelquun en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière dune adresse courriel.",
"getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, lidentifiant suffit. Cest le même principe pour mentionner quelquun dans vos statuts.",
"getting_started.about_developer": "Pour suivre le développeur de ce projet, cest Gargron@mastodon.social",
"getting_started.open_source_notice": "Mastodon est un logiciel libre. Vous pouvez contribuer et envoyer vos commentaires et rapports de bogues via {github} sur GitHub.",
"column.home": "Accueil",
"column.community": "Fil public local",
"column.public": "Fil public global",
"column.notifications": "Notifications",
"column.public": "Fil public",
"column.blocks": "Utilisateurs bloqués",
"column.favourites": "Favoris",
"tabs_bar.compose": "Composer",
"tabs_bar.home": "Accueil",
"tabs_bar.mentions": "Mentions",
"tabs_bar.public": "Fil public global",
"tabs_bar.notifications": "Notifications",
"tabs_bar.public": "Public",
"compose_form.placeholder": "Quavez-vous en tête ?",
"compose_form.publish": "Pouet ",
"compose_form.sensitive": "Marquer le média comme délicat",
"compose_form.spoiler": "Masquer le texte par un avertissement",
"compose_form.private": "Rendre privé",
"compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ? Les statuts privés ne fonctionnent que sur les instances de Mastodons. Si {domains} {domainsCount, plural, one {n'est pas une instance de Mastodon} other {ne sont pas des instances de Mastodon}}, il n'y aura aucune indication que votre statut est privé, et il pourrait être partagé ou rendu visible d'une autre manière à d'autres personnes imprévues",
"compose_form.unlisted": "Ne pas afficher dans les fils publics",
"emoji_button.label": "Insérer un emoji",
"navigation_bar.edit_profile": "Modifier le profil",
"navigation_bar.preferences": "Préférences",
"navigation_bar.community_timeline": "Fil public local",
"navigation_bar.public_timeline": "Fil public global",
"navigation_bar.blocks": "Utilisateurs bloqués",
"navigation_bar.favourites": "Favoris",
"navigation_bar.info": "Plus d'informations",
"notification.favourite": "{name} a ajouté à ses favoris :",
"navigation_bar.logout": "Déconnexion",
"reply_indicator.cancel": "Annuler",
"search.placeholder": "Chercher",
"search.account": "Compte",
"search.hashtag": "Mot-clé",
"search_results.total": "{count} {count, plural, one {résultat} other {résultats}}",
"upload_button.label": "Joindre un média",
"upload_form.undo": "Annuler",
"video_player.toggle_sound": "Mettre/Couper le son",
"notification.follow": "{name} vous suit.",
"notification.favourite": "{name} a ajouté à ses favoris :",
"notification.reblog": "{name} a partagé votre statut :",
"notification.mention": "{name} vous a mentionné⋅e :",
"notifications.column_settings.alert": "Notifications locales",
"notifications.column_settings.show": "Afficher dans la colonne",
"notifications.column_settings.follow": "Nouveaux abonnés :",
"notifications.column_settings.favourite": "Favoris :",
"notifications.column_settings.mention": "Mentions :",
"notifications.column_settings.reblog": "Partages :",
"privacy.public.short": "Public",
"privacy.public.long": "Afficher dans les fils publics",
"privacy.unlisted.short": "Non-listé",
"privacy.unlisted.long": "Ne pas afficher dans les fils publics",
"privacy.private.short": "Privé",
"privacy.private.long": "Nafficher que pour vos abonné⋅e⋅s",
"privacy.direct.short": "Direct",
"privacy.direct.long": "Nafficher que pour les personnes mentionné⋅e⋅s",
"privacy.change": "Ajuster la confidentialité du message",
};
export default fr;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

14
app/models/import.rb Normal file
View File

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

View File

@ -3,6 +3,7 @@
class Report < ApplicationRecord
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) }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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') }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,7 +27,7 @@
= link_to remove_admin_report_path(@report, status_id: status.id), method: :post, class: 'icon-button', style: 'font-size: 24px; width: 24px; height: 24px', title: 'Delete' do
= 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

View File

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

View File

@ -3,8 +3,8 @@ attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, :sensitiv
node(:uri) { |status| TagManager.instance.uri_for(status) }
node(: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'

View File

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

View File

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

View File

@ -9,8 +9,10 @@
.status__content.e-content.p-name.emojify<
- 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?
·

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class DomainBlockWorker
include Sidekiq::Worker
def perform(domain_block_id)
BlockDomainService.new.call(DomainBlock.find(domain_block_id))
rescue ActiveRecord::RecordNotFound
true
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,61 @@
---
fi:
devise:
confirmations:
confirmed: Sähköpostisi on onnistuneesti vahvistettu.
send_instructions: Saat kohta sähköpostiisi ohjeet kuinka voit aktivoida tilisi.
send_paranoid_instructions: Jos sähköpostisi on meidän tietokannassa, saat pian ohjeet sen varmentamiseen.
failure:
already_authenticated: Olet jo kirjautunut sisään.
inactive: Tiliäsi ei ole viellä aktivoitu.
invalid: Virheellinen %{authentication_keys} tai salasana.
last_attempt: Sinulla on yksi yritys jäljellä tai tili lukitaan.
locked: Tili on lukittu.
not_found_in_database: Virheellinen %{authentication_keys} tai salasana.
timeout: Sessiosi on umpeutunut. Kirjaudu sisään jatkaaksesi.
unauthenticated: Sinun tarvitsee kirjautua sisään tai rekisteröityä jatkaaksesi.
unconfirmed: Sinun tarvitsee varmentaa sähköpostisi jatkaaksesi.
mailer:
confirmation_instructions:
subject: 'Mastodon: Varmistus ohjeet'
password_change:
subject: 'Mastodon: Salasana vaihdettu'
reset_password_instructions:
subject: 'Mastodon: Salasanan vaihto ohjeet'
unlock_instructions:
subject: 'Mastodon: Avauksen ohjeet'
omniauth_callbacks:
failure: Varmennus %{kind} epäonnistui koska "%{reason}".
success: Onnistuneesti varmennettu %{kind} tilillä.
passwords:
no_token: Et pääse tälle sivulle ilman salasanan vaihto sähköpostia. Jos tulet tämmöisestä postista, varmista että sinulla on täydellinen URL.
send_instructions: Saat sähköpostitse ohjeet salasanan palautukseen muutaman minuutin kuluessa.
send_paranoid_instructions: Jos sähköpostisi on meidän tietokannassa, saat pian ohjeet salasanan palautukseen.
updated: Salasanasi vaihdettu onnistuneesti. Olet nyt kirjautunut sisään.
updated_not_active: Salasanasi vaihdettu onnistuneesti.
registrations:
destroyed: Näkemiin! Tilisi on onnistuneesti peruttu. Toivottavasti näemme joskus uudestaan.
signed_up: Tervetuloa! Rekisteröitymisesi onnistu.
signed_up_but_inactive: Olet onnistuneesti rekisteröitynyt, mutta emme voi kirjata sinua sisään koska tiliäsi ei ole viellä aktivoitu.
signed_up_but_locked: Olet onnistuneesti rekisteröitynyt, mutta emme voi kirjata sinua sisään koska tilisi on lukittu.
signed_up_but_unconfirmed: Varmistuslinkki on lähetty sähköpostiisi. Seuraa sitä jotta tilisi voidaan aktivoida.
update_needs_confirmation: Tilisi on onnistuneesti päivitetty, mutta meidän tarvitsee vahvistaa sinun uusi sähköpostisi. Tarkista sähköpostisi ja seuraa viestissä tullutta linkkiä varmistaaksesi uuden osoitteen..
updated: Tilisi on onnistuneesti päivitetty.
sessions:
already_signed_out: Ulos kirjautuminen onnistui.
signed_in: Sisäänkirjautuminen onnistui.
signed_out: Ulos kirjautuminen onnistui.
unlocks:
send_instructions: Saat sähköpostiisi pian ohjeet, jolla voit avata tilisi uudestaan.
send_paranoid_instructions: Jos tilisi on olemassa, saat sähköpostiisi pian ohjeet tilisi avaamiseen.
unlocked: Tilisi on avattu onnistuneesti. Kirjaudu normaalisti sisään.
errors:
messages:
already_confirmed: on jo varmistettu. Yritä kirjautua sisään
confirmation_period_expired: pitää varmistaa %{period} sisällä, ole hyvä ja pyydä uusi
expired: on erääntynyt, ole hyvä ja pyydä uusi
not_found: ei löydy
not_locked: ei ollut lukittu
not_saved:
one: '1 virhe esti %{resource} tallennuksen:'
other: "%{count} virhettä esti %{resource} tallennuksen:"

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