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

View File

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

View File

@ -0,0 +1,61 @@
---
'no':
devise:
confirmations:
confirmed: Epostaddressen din er blitt bekreftet.
send_instructions: Du vil motta en epost med instruksjoner for hvordan bekrefte din epostaddresse om noen få minutter.
send_paranoid_instructions: Hvis din epostaddresse finnes i vår database vil du motta en epost med instruksjoner for hvordan bekrefte din epost om noen få minutter.
failure:
already_authenticated: Du er allerede innlogget.
inactive: Din konto er ikke blitt aktivert ennå.
invalid: Ugyldig %{authentication_keys} eller passord.
last_attempt: Du har ett forsøk igjen før kontoen din bli låst.
locked: Din konto er låst.
not_found_in_database: Ugyldig %{authentication_keys} eller passord.
timeout: Sesjonen din løp ut på tid. Logg inn på nytt for å fortsette.
unauthenticated: Du må logge inn eller registrere deg før du kan fortsette.
unconfirmed: Du må bekrefte epostadressen din før du kan fortsette.
mailer:
confirmation_instructions:
subject: 'Mastodon: Instruksjoner for å bekrefte epostadresse'
password_change:
subject: 'Mastodon: Passord endret'
reset_password_instructions:
subject: 'Mastodon: Hvordan nullstille passord?'
unlock_instructions:
subject: 'Mastodon: Instruksjoner for å gjenåpne konto'
omniauth_callbacks:
failure: Kunne ikke autentisere deg fra %{kind} fordi "%{reason}".
success: Vellykket autentisering fra %{kind}.
passwords:
no_token: Du har ingen tilgang til denne siden så lenge du ikke kommer fra en epost om nullstilling av passord. Hvis du kommer fra en passordnullstilling epost, dobbelsjekk at du brukte hele URLen.
send_instructions: Du vil motta en epost med instruksjoner for å nullstille passordet ditt om noen få minutter.
send_paranoid_instructions: Hvis epostadressen din finnes i databasen vår vil du motta en instruksjonsmail om passord nullstilling om noen få minutter.
updated: Passordet ditt har blitt endret. Du er nå logget inn.
updated_not_active: Passordet ditt har blitt endret.
registrations:
destroyed: Adjø! Kontoen din har blitt avsluttet. Vi håper at vi ser deg igjen snart.
signed_up: Velkommen! Registrasjonen var vellykket.
signed_up_but_inactive: Registrasjonen var vellykket. Vi kunne dessverre ikke logge deg inn fordi kontoen din ennå ikke har blitt aktivert.
signed_up_but_locked: Registrasjonen var vellykket. Vi kunne dessverre ikke logge deg inn fordi kontoen din har blitt låst.
signed_up_but_unconfirmed: En epostmelding med en bekreftelseslink har blitt sendt til din adresse. Klikk på linken i eposten for å aktivere kontoen din.
update_needs_confirmation: Du har oppdatert kontoen din, men vi må bekrefte din nye epostadresse. Sjekk eposten din og følg bekreftelseslinken for å bekrefte din nye epostadresse.
updated: Kontoen din ble oppdatert.
sessions:
already_signed_out: Logget ut.
signed_in: Logget inn.
signed_out: Logget ut.
unlocks:
send_instructions: Du vil motta en epost med instruksjoner for å åpne kontoen din om noen få minutter.
send_paranoid_instructions: Hvis kontoen din eksisterer vil du motta en epost med instruksjoner for å åpne kontoen din om noen få minutter.
unlocked: Kontoen din ble åpnet uten problemer. Logg på for å fortsette.
errors:
messages:
already_confirmed: har allerede blitt bekreftet, prøv å logg på istedet.
confirmation_period_expired: må bekreftes innen %{period}. Spør om en ny bekreftelsesmail istedet.
expired: har utløpt, spør om en ny en istedet
not_found: ikke funnet
not_locked: var ikke låst
not_saved:
one: '1 feil hindret denne %{resource} fra å bli lagret:'
other: "%{count} feil hindret denne %{resource} fra å bli lagret:"

View File

@ -0,0 +1,113 @@
---
fi:
activerecord:
attributes:
doorkeeper/application:
name: Nimi
redirect_uri: Uudelleenohjaus URI
errors:
models:
doorkeeper/application:
attributes:
redirect_uri:
fragment_present: ei voi sisältää osia.
invalid_uri: pitää olla validi URI.
relative_uri: pitää olla täydellinen URI.
secured_uri: pitää olla HTTPS/SSL URI.
doorkeeper:
applications:
buttons:
authorize: Valtuuta
cancel: Peruuta
destroy: Tuhoa
edit: Muokkaa
submit: Lähetä
confirmations:
destroy: Oletko varma?
edit:
title: Muokkaa applikaatiota
form:
error: Whoops! Tarkista lomakkeesi mahdollisten virheiden varalta
help:
native_redirect_uri: Käytä %{native_redirect_uri} paikallisiin testeihin
redirect_uri: Käytä yhtä riviä per URI
scopes: Erota scopet välilyönnein. Jätä tyhjäksi käyteksi oletus scopeja.
index:
callback_url: Callback URL
name: Nimi
new: Uusi applikaatio
title: Sinun applikaatiosi
new:
title: Uusi applikaatio
show:
actions: Toiminnot
application_id: Applikaation Id
callback_urls: Callback urls
scopes: Scopet
secret: Salainen avain
title: 'Applikaatio: %{name}'
authorizations:
buttons:
authorize: Valtuuta
deny: Evää
error:
title: Virhe on tapahtunut
new:
able_to: Se voi
prompt: Applikaatio %{client_name} pyytää lupaa tilillesi
title: Valtuutus vaaditaan
show:
title: Valtuutus koodi
authorized_applications:
buttons:
revoke: Evää
confirmations:
revoke: Oletko varma?
index:
application: Applikaatio
created_at: Valtuutettu
date_format: "%Y-%m-%d %H:%M:%S"
scopes: Scopet
title: Valtuuttamasi applikaatiot
errors:
messages:
access_denied: Resurssin omistaja tai valtuutus palvelin hylkäsi pyynnönr.
credential_flow_not_configured: Resurssin omistajan salasana epäonnistui koska Doorkeeper.configure.resource_owner_from_credentials ei ole konfiguroitu.
invalid_client: Asiakkaan valtuutus epäonnistui koska tuntematon asiakas, asiakas ei sisältänyt valtuutusta, tai tukematon valtuutus tapa
invalid_grant: Antamasi valtuutus lupa on joko väärä, erääntynyt, peruttu, ei vastaa uudelleenohjaus URI jota käytetään valtuutus pyynnössä, tai se myönnettin toiselle asiakkaalle.
invalid_redirect_uri: Uudelleenohjaus uri ei ole oikein.
invalid_request: Pyynnöstä puutti parametri, sisältää tukemattoman parametri arvonn, tai on korruptoitunut.
invalid_resource_owner: Annetut resurssin omistajan tunnnukset ovat väärät, tai resurssin omistajaa ei löydy
invalid_scope: Pyydetty scope on väärä, tuntemat, tai korruptoitunut.
invalid_token:
expired: Access token vanhentunut
revoked: Access token evätty
unknown: Access token väärä
resource_owner_authenticator_not_configured: Resurssin omistajan etsiminen epäonnistui koska Doorkeeper.configure.resource_owner_authenticator ei ole konfiguroitu.
server_error: Valtuutus palvelin kohtasi odottamattoman virheen joka esti sitä täyttämästä pyyntöä.
temporarily_unavailable: Valtuutus palvelin ei voi tällä hetkellä käsitellä pyyntöäsi joko väliaikaisen ruuhkan tai huollon takia.
unauthorized_client: Asiakas ei ole valtuutettu tekemään tätä pyyntöä käyttäen tätä metodia.
unsupported_grant_type: Valtuutus grant type ei ole tuettu valtuutus palvelimella.
unsupported_response_type: Valtuutus palvelin ei tue tätä vastaus tyyppiä.
flash:
applications:
create:
notice: Applikaatio luotu.
destroy:
notice: Applikaatio poistettu.
update:
notice: Applikaatio päivitetty.
authorized_applications:
destroy:
notice: Applikaatio tuhottu.
layouts:
admin:
nav:
applications: Applikaatiot
oauth2_provider: OAuth2 Provider
application:
title: OAuth valtuutus tarvitaan
scopes:
follow: seuraa, estä, peru esto ja lopeta tilien seuraaminen
read: lukea tilin dataa
write: julkaista puolestasi

View File

@ -54,7 +54,7 @@ fr:
title: Une erreur est survenue
new:
able_to: Cette application pourra
prompt: Autorisez %{client_name} à utiliser votre compte?
prompt: Autoriser %{client_name} à utiliser votre compte?
title: Autorisation requise
show:
title: Code d'autorisation
@ -66,7 +66,8 @@ fr:
index:
application: Application
created_at: Créé le
date_format: "%Y-%m-%d %H:%M:%S"
date_format: "%d-%m-%Y %H:%M:%S"
scopes: permissions
title: Vos applications autorisées
errors:
messages:
@ -80,7 +81,7 @@ fr:
invalid_scope: La portée demandée n'est pas valide, est inconnue, ou est mal formée.
invalid_token:
expired: Le jeton d'accès a expiré
revoked: Le jeton d'accès a été annulé
revoked: Le jeton d'accès a été révoqué
unknown: Le jeton d'accès n'est pas valide
resource_owner_authenticator_not_configured: La recherche du propriétaire de la ressource a échoué en raison de Doorkeeper.configure.resource_owner_authenticator n'est pas configuré.
server_error: Le serveur d'autorisation a rencontré une condition inattendue qui l'a empêché de remplir la demande.

View File

@ -0,0 +1,113 @@
---
'no':
activerecord:
attributes:
doorkeeper/application:
name: Navn
redirect_uri: Omdirigerings-URI
errors:
models:
doorkeeper/application:
attributes:
redirect_uri:
fragment_present: kan ikke inneholde ett fragment.
invalid_uri: må være en gyldig URI.
relative_uri: må være en absolutt URI.
secured_uri: må være en HTTPS/SSL URI.
doorkeeper:
applications:
buttons:
authorize: Autoriser
cancel: Avbryt
destroy: Ødelegg
edit: Endre
submit: Send inn
confirmations:
destroy: Er du sikker?
edit:
title: Endre applikasjon
form:
error: Whoops! Sjekk skjemaet ditt for mulige feil
help:
native_redirect_uri: Bruk %{native_redirect_uri} for lokale tester
redirect_uri: Bruk en linje per URI
scopes: Adskill omfang med mellomrom. La det være blankt for å bruke standard omfang.
index:
callback_url: Callback URL
name: Navn
new: Ny Applikasjon
title: Dine applikasjoner
new:
title: Ny Applikasjoner
show:
actions: Operasjoner
application_id: Applikasjon Id
callback_urls: Callback urls
scopes: Omfang
secret: Hemmelighet
title: 'Applikasjon: %{name}'
authorizations:
buttons:
authorize: Autoriser
deny: Avvis
error:
title: En feil oppsto
new:
able_to: Den vil ha mulighet til
prompt: Applikasjon %{client_name} spør om tilgang til din konto
title: Autorisasjon påkrevd
show:
title: Autoriserings kode
authorized_applications:
buttons:
revoke: Opphev
confirmations:
revoke: Opphev?
index:
application: Applikasjon
created_at: Autorisert
date_format: "%Y-%m-%d %H:%M:%S"
scopes: Omfang
title: Dine autoriserte applikasjoner
errors:
messages:
access_denied: Ressurseieren eller autoriserings tjeneren avviste forespørslen.
credential_flow_not_configured: Ressurseiers passord flyt feilet på grunn av at Doorkeeper.configure.resource_owner_from_credentials ikke var konfigurert.
invalid_client: Klient autentisering feilet på grunn av ukjent klient, ingen autentisering inkludert eller autentiserings metode som ikke er støttet.
invalid_grant: Autoriseringen er ugyldig, utløpt, opphevet, stemmer ikke overens med omdirigerings-URIen eller var utstedt til en annen klient.
invalid_redirect_uri: redirect urien som var inkludert er ikke gyldig.
invalid_request: Forespørslen mangler ett eller flere parametere, inkluderte ett parameter som ikke støttes eller har feil struktur.
invalid_resource_owner: Ressurseierens detaljer er ikke gyldig, eller så kan ikke eieren finnes.
invalid_scope: Det etterspurte omfanget er ugyldig, ukjent eller har feil struktur.
invalid_token:
expired: Tilgangsbeviset har utløpt
revoked: Tilgangsbeviset har blitt opphevet
unknown: Tilgangsbeviset er ugyldig
resource_owner_authenticator_not_configured: Ressurseier kunne ikke finnes fordi Doorkeeper.configure.resource_owner_authenticator ikke er konfigurert.
server_error: Autoriserings tjeneren støtte på en uventet hendelse som hindret den i å svare på forespørslen.
temporarily_unavailable: Autoriserings tjeneren kan ikke håndtere forespørslen grunnet en midlertidig overbelastning eller tjenervedlikehold.
unauthorized_client: Klienten har ikke autorisasjon for å utføre denne forespørslen med denne metoden.
unsupported_grant_type: Autorisasjons tildelings typen er ikke støttet av denne autoriserings tjeneren.
unsupported_response_type: Autorisasjons serveren støtter ikke denne typen av forespørsler.
flash:
applications:
create:
notice: Applikasjon opprettet.
destroy:
notice: Applikasjon slettet.
update:
notice: Applikasjon oppdatert.
authorized_applications:
destroy:
notice: Applikasjon opphevet.
layouts:
admin:
nav:
applications: Applikasjoner
oauth2_provider: OAuth2 tilbyder
application:
title: OAuth autorisering påkrevet
scopes:
follow: følg, blokker, avblokker, avfølg kontoer
read: lese dine data
write: poste på dine vegne

View File

@ -85,6 +85,13 @@ en:
validation_errors:
one: Something isn't quite right yet! Please review the error below
other: Something isn't quite right yet! Please review %{count} errors below
imports:
preface: You can import certain data like all the people you are following or blocking into your account on this instance, from files created by an export on another instance.
success: Your data was successfully uploaded and will now be processed in due time
types:
blocking: Blocking list
following: Following list
upload: Upload
landing_strip_html: <strong>%{name}</strong> is a user on <strong>%{domain}</strong>. You can follow them or interact with them if you have an account anywhere in the fediverse. If you don't, you can <a href="%{sign_up_path}">sign up here</a>.
notification_mailer:
digest:
@ -124,12 +131,14 @@ en:
back: Back to Mastodon
edit_profile: Edit profile
export: Data export
import: Import
preferences: Preferences
settings: Settings
two_factor_auth: Two-factor Authentication
statuses:
open_in_web: Open in web
over_character_limit: character limit of %{max} exceeded
show_more: Show more
visibilities:
private: Only show to followers
public: Public

164
config/locales/fi.yml Normal file
View File

@ -0,0 +1,164 @@
---
fi:
about:
about_mastodon: Mastodon on <em>ilmainen, avoimeen lähdekoodiin perustuva</em> sosiaalinen verkosto. <em>Hajautettu</em> vaihtoehto kaupallisille alustoille, se välttää eiskit yhden yrityksen monopolisoinnin sinun viestinnässäsi. Valitse palvelin mihin luotat &mdash; minkä tahansa valitset, voit vuorovaikuttaa muiden kanssa. Kuka tahansa voi luoda Mastodon palvelimen ja ottaa osaa <em>sosiaaliseen verkkoon</em> saumattomasti.
about_this: Tietoja tästä palvelimesta
apps: Ohjelmat
business_email: 'Business e-mail:'
contact: Ota yhteyttä
description_headline: Mikä on %{domain}?
domain_count_after: muut palvelimet
domain_count_before: Yhdistyneenä
features:
api: Avoin API ohjelmille ja palveluille
blocks: Rikkaat esto ja hiljennys työkalut
characters: 500 kirjainta per viesti
chronology: Aikajana on kronologisessa järjestyksessä
ethics: 'Eettinen suunnittelu: ei mainoksia, no seurantaa'
gifv: GIFV settejä ja lyhyitä videoita
privacy: Julkaisu kohtainen yksityisyys aseuts
public: Julkiset aikajanat
features_headline: Mikä erottaa Mastodonin muista
get_started: Aloita käyttö
links: Linkit
other_instances: Muut palvelimet
source_code: Lähdekoodi
status_count_after: statukset
status_count_before: Kuka loi
terms: Ehdot
user_count_after: käyttäjät
user_count_before: Koti käyttäjälle
accounts:
follow: Seuraa
followers: Seuraajat
following: Seuratut
nothing_here: Täällä ei ole mitään!
people_followed_by: Henkilöitä joita %{name} seuraa
people_who_follow: Henkilöt jotka seuraa %{name}
posts: Postaukset
remote_follow: Etäseuranta
unfollow: Lopeta seuraaminen
application_mailer:
settings: 'Muokkaa sähköposti asetuksia: %{link}'
signature: Mastodon ilmoituksia palvelimelta %{instance}
view: 'Katso:'
applications:
invalid_url: Annettu URL on väärä
auth:
change_password: Tunnukset
didnt_get_confirmation: Etkö saanut varmennus ohjeita?
forgot_password: Unohditko salasanasi?
login: Kirjaudu sisään
logout: Kirjaudu ulos
register: Rekisteröidy
resend_confirmation: Lähetä varmennus ohjeet uudestaan
reset_password: Palauta Salasana
set_new_password: Aseta uusi salasana
authorize_follow:
error: Valitettavasti tapahtui virhe etätilin haussa
follow: Seuraa
prompt_html: 'Sinä (<strong>%{self}</strong>) olet pyytänyt lupaa seurata:'
title: Seuraa %{acct}
datetime:
distance_in_words:
about_x_hours: "%{count}t"
about_x_months: "%{count}kk"
about_x_years: "%{count}v"
almost_x_years: "%{count}v"
half_a_minute: Juuri nyt
less_than_x_minutes: "%{count}m"
less_than_x_seconds: Juuri nyt
over_x_years: "%{count}v"
x_days: "%{count}pv"
x_minutes: "%{count}m"
x_months: "%{count}kk"
x_seconds: "%{count}s"
exports:
blocks: Estosi
csv: CSV
follows: Seurattavat
storage: Mediasi
generic:
changes_saved_msg: Muutokset onnistuneesti tallenettu!
powered_by: powered by %{link}
save_changes: Tallenna muutokset
validation_errors:
one: Jokin ei ole viellä oikein! Katso virhe alapuolelta
other: Jokin ei ole viellä oikein! Katso %{count} virhettä alapuolelta
imports:
preface: Voit tuoda tiettyä dataa kaikista ihmisistä joita seuraat tai estät tilillesi tälle palvelimelle tiedostoista, jotka on luotu toisella palvelimella
success: Datasi on onnistuneesti ladattu ja käsitellään pian
types:
blocking: Esto lista
following: Seuratut lista
upload: Lähetä
landing_strip_html: <strong>%{name}</strong> on käyttäjä domainilla <strong>%{domain}</strong>. Voit seurata tai vuorovaikuttaa heidän kanssaan jos sinulla on tili yleisessä verkossa. Jos sinulla ei ole tiliä, voit <a href="%{sign_up_path}">rekisteröityä täällä</a>.
notification_mailer:
digest:
body: 'Tässä on pieni yhteenveto palvelimelta %{instance} viimeksi kun olit paikalla %{since}:'
mention: "%{name} mainitsi sinut:"
new_followers_summary:
one: Olet saanut yhden uuden seuraajan! Jee!
other: Olet saanut %{count} uutta seuraajaa! Loistavaa!
subject:
one: "1 uusi ilmoitus viimeisen käyntisi jälkeen \U0001F418"
other: "%{count} uutta ilmoitusta viimeisen käyntisi jälkeen \U0001F418"
favourite:
body: 'Statuksestasi tykkäsi %{name}:'
subject: "%{name} tykkäsi sinun statuksestasi"
follow:
body: "%{name} seuraa nyt sinua!"
subject: "%{name} seuraa nyt sinua"
follow_request:
body: "%{name} on pyytänyt seurata sinua"
subject: 'Odottava seuraus pyyntö: %{name}'
mention:
body: 'Sinut mainitsi %{name} postauksessa:'
subject: Sinut mainitsi %{name}
reblog:
body: 'Sinun statustasi boostasi %{name}:'
subject: "%{name} boostasi statustasi"
pagination:
next: Seuraava
prev: Edellinen
remote_follow:
acct: Syötä sinun käyttäjänimesi@domain jos haluat seurata palvelimelta
missing_resource: Ei löydetty tarvittavaa uudelleenohjaavaa URL-linkkiä tilillesi
proceed: Siirry seuraamiseen
prompt: 'Sinä aiot seurata:'
settings:
authorized_apps: Valtuutetut ohjelmat
back: Takaisin Mastodoniin
edit_profile: Muokkaa profiilia
export: Datan vienti
import: Datan tuonti
preferences: Mieltymykset
settings: Asetukset
two_factor_auth: Kaksivaiheinen tunnistus
statuses:
open_in_web: Avaa webissä
over_character_limit: sallittu kirjanmäärä %{max} ylitetty
show_more: Näytä lisää
visibilities:
private: Näytä vain seuraajille
public: Julkinen
unlisted: Julkinen, mutta älä näytä julkisella aikajanalla
stream_entries:
click_to_show: Klikkaa näyttääksesi
reblogged: boosted
sensitive_content: Herkkä materiaali
time:
formats:
default: "%b %d, %Y, %H:%M"
two_factor_auth:
description_html: Jos otat käyttöön <strong>kaksivaiheisen tunnistuksen</stron>, kirjautumiseen vaaditaan puhelin, joka voi generoida tokeneita kirjautumista varten.
disable: Poista käytöstä
enable: Ota käyttöön
instructions_html: "<strong>Skannaa tämä QR-koodi Google Authenticator tai samanlaiseen sovellukseen puhelimellasi</strong>. Tästä hetkestä lähtien, ohjelma generoi tokenit mikä sinun tarvitsee syöttää sisäänkirjautuessa."
plaintext_secret_html: 'Plain-text secret: <samp>%{secret}</samp>'
warning: Jos et juuri nyt voi konfiguroida authenticator-applikaatiota juuri nyt, sinun pitäisi klikata "Poista käytöstä" tai et voi kirjautua sisään.
users:
invalid_email: Virheellinen sähköposti
invalid_otp_token: Virheellinen kaksivaihe tunnistus koodi
will_paginate:
page_gap: "&hellip;"

View File

@ -2,29 +2,65 @@
fr:
about:
about_mastodon: Mastodon est un serveur <em>libre</em> de réseautage social. Alternative <em>décentralisée</em> aux plateformes commerciales, la monopolisation de vos communications par une entreprise unique est évitée. Tout un chacun peut faire tourner Mastodon et participer au <em>réseau social</em> de manière transparente.
about_this: À propos de cette instance
apps: Applications
business_email: E-mail professionnel
description_headline: Qu'est-ce que %{domain} ?
domain_count_after: autres instances
domain_count_before: Connectés à
features:
api: API ouverte aux apps et services
blocking: Outils complets de bloquage et masquage
characters: 500 caractères par post
chronology: Fil chronologique
ethics: 'Pas de pubs, pas de pistage'
gifv: Partage de vidéos et de GIFs
privacy: Réglages de confidentialité au niveau des posts
public: Fils publics
features_headline: Ce qui rend Mastodon différent
get_started: Rejoindre le réseau
links: Liens
source_code: Code source
status_count_after: posts
status_count_before: Ayant publié
terms: Conditions dutilisation
user_count_after: utilisateurs⋅trices
user_count_before: Abrite
accounts:
follow: Suivre
followers: Abonnés
followers: Abonné⋅es
following: Abonnements
nothing_here: Rien à voir ici !
people_followed_by: Personnes suivies par %{name}
people_who_follow: Personnes qui suivent %{name}
posts: Statuts
remote_follow: Suivre à distance
unfollow: Ne plus suivre
application_mailer:
settings: 'Changer les préférences e-mail: ${link}'
signature: Notifications de Mastodon depuis %{instance}
view: 'Voir:'
applications:
invalid_url: L'URL fournie est invalide
auth:
change_password: Changer de mot de passe
didnt_get_confirmation: Vous navez pas reçu les consignes de confirmation ?
forgot_password: Mode passe oublié ?
forgot_password: Mot de passe oublié ?
login: Se connecter
logout: Se déconnecter
register: Sinscrire
resend_confirmation: Envoyer à nouveau les consignes de confirmation
reset_password: Réinitialiser le mot de passe
set_new_password: Établir le nouveau mot de passe
set_new_password: Définir le nouveau mot de passe
authorize_follow:
follow: Suivre
prompt_html: 'Vous (<strong>%{self}</strong>) avez demandé à suivre:'
title: Suivre %{acct}
exports:
blocks: Vous bloquez
csv: CSV
follows: Vous suivez
storage: Médias stockés
generic:
changes_saved_msg: Les modifications ont été enregistrées avec succès !
powered_by: propulsé par %{link}
@ -32,10 +68,27 @@ fr:
validation_errors:
one: Quelque chose ne va pas ! Vérifiez lerreur ci-dessous.
other: Quelques choses ne vont pas ! Vérifiez les erreurs ci-dessous.
imports:
preface: Vous pouvez importer certaines données comme les personnes que vous suivez ou bloquez sur votre compte sur cette instance à partir de fichiers crées sur une autre instance.
success: Vos données ont été importées avec succès et seront traités en temps et en heure
types:
blocking: Liste d'utilisateurs⋅trices bloqué⋅es
following: Liste d'utilisateurs⋅trices suivi⋅es
upload: Importer
landing_strip_html: <strong>%{name}</strong> utilise <strong>%{domain}</strong>. Vous pouvez le/la suivre et interagir si vous possédez un compte quelque part dans le "fediverse". Si ce n'est pas le cas, vous pouvez <a href="%{sign_up_path}">en créer un ici</a>.
notification_mailer:
digest:
body: 'Voici ce que vous avez raté sur ${instance} depuis votre dernière visite (%{}):'
mention: '%{name} vous a mentionné⋅e'
new_followers_summary:
one: Vous avez un⋅e nouvel⋅le abonné⋅e ! Youpi !
other: Vous avez %{count} nouveaux abonné⋅es ! Incroyable !
subject:
one: "Une nouvelle notification depuis votre dernière visite \U0001F418"
other: "%{count} nouvelles notifications depuis votre dernière visite \U0001F418"
favourite:
body: "%{name} a ajouté votre statut à ses favoris :"
subject: "%{name} a ajouté votre statut à ses favoris"
body: "%{name} a ajouté votre post à ses favoris :"
subject: "%{name} a ajouté votre post à ses favoris"
follow:
body: "%{name} vous suit !"
subject: "%{name} vous suit"
@ -48,8 +101,44 @@ fr:
pagination:
next: Suivant
prev: Précédent
remote_follow:
acct: Entrez votre pseudo@instance depuis lequel vous voulez suivre ce⋅tte utilisateur⋅trice
missing_resource: L'URL de redirection n'a pas pu être trouvée
proceed: Continuez pour suivre
prompt: 'Vous allez suivre :'
settings:
authorized_apps: Applications autorisées
back: Retour vers Mastodon
edit_profile: Modifier le profil
export: Export de données
import: Import de données
preferences: Préférences
settings: Réglages
two_factor_auth: Identification à deux facteurs (Two-factor auth)
statuses:
open_in_web: Ouvrir sur le web
over_character_limit: limite de caractères dépassée de %{max} caractères
show_more: Montrer plus
visibilities:
private: Abonné⋅es uniquement
public: Public
unlisted: Public sans être affiché sur le fil public
stream_entries:
click_to_show: Clic pour afficher
reblogged: partagé
sensitive_content: Contenu sensible
time:
formats:
default: '%d %b %Y, %H:%M'
two_factor_auth:
description_html: Si vous activez <strong>l'identification à deux facteurs</strong> vous devrez être en posession de votre téléphone afin de générer un code de connexion.
disable: Désactiver
enable: Activer
instructions_html: "<strong>Scannez ce QR code grâce à Google Authenticator, Authy ou une application similaire sur votre téléphone</strong>. Désormais, cette application générera des jetons que vous devrez saisir à chaque connexion."
plaintext_secret_html: 'Code secret en clair: <samp>%{secret}</samp>'
warning: Si vous ne pouvez pas configurer une application d'authentification maintenant, vous devriez cliquer sur "Désactiver" pour ne pas bloquer l'accès à votre compte.
users:
invalid_email: L'adresse e-mail est invalide
invalid_otp_token: Le code d'authentification à deux facteurs est invalide
will_paginate:
page_gap: "&hellip;"

164
config/locales/no.yml Normal file
View File

@ -0,0 +1,164 @@
---
'no':
about:
about_mastodon: Mastodon er et <em>gratis, åpen kildekode</em> sosialt nettverk. Et <em>desentralisert</em> alternativ til kommersielle plattformer. Slik kan det unngå risikoene ved å ha et enkelt selskap med monopol på din kommunikasjon. Velg en tjener du stoler på &mdash; uansett hvilken du velger så kan du interagere med alle andre. Alle kan kjøre sin egen Mastodon og delta sømløst i det sosiale nettverket.
about_this: Om denne instansen
apps: Applikasjoner
business_email: 'Bedriftsepost:'
contact: Kontakt
description_headline: Hva er %{domain}?
domain_count_after: andre instanser
domain_count_before: Koblet til
features:
api: Åpent api for applikasjoner og tjenester
blocks: Rikholdige blokkerings verktøy
characters: 500 tegn per post
chronology: Tidslinjer er kronologiske
ethics: 'Etisk design: Ingen reklame, ingen sporing'
gifv: GIFV sett og korte videoer
privacy: Finmaskete personvernsinnstillinger
public: Offentlige tidslinjer
features_headline: Hva skiller Mastodon fra andre sosiale nettverk
get_started: Kom i gang
links: Lenker
other_instances: Andre instanser
source_code: Kildekode
status_count_after: statuser
status_count_before: Hvem skrev
terms: Betingelser
user_count_after: brukere
user_count_before: Hjem til
accounts:
follow: Følg
followers: Følgere
following: Følger
nothing_here: Det er ingenting her!
people_followed_by: Folk som %{name} følger
people_who_follow: Folk som følger %{name}
posts: Poster
remote_follow: Følg fra andre instanser
unfollow: Avfølg
application_mailer:
settings: 'Endre foretrukne epost innstillinger: %{link}'
signature: Mastodon notiser fra %{instance}
view: 'Se:'
applications:
invalid_url: Den oppgitte URLen er ugyldig
auth:
change_password: Brukerdetaljer
didnt_get_confirmation: Fikk du ikke bekreftelsesmailen din?
forgot_password: Har du glemt passordet ditt?
login: Innlogging
logout: Logg ut
register: Bli med
resend_confirmation: Send bekreftelsesinstruksjoner på nytt
reset_password: Nullstill passord
set_new_password: Sett nytt passord
authorize_follow:
error: Uheldigvis så skjedde det en feil når vi prøvde å få tak i en konto fra en annen instans.
follow: Følg
prompt_html: 'Du (<strong>%{self}</strong>) har spurt om å følge:'
title: Følg %{acct}
datetime:
distance_in_words:
about_x_hours: "%{count}t"
about_x_months: "%{count}m"
about_x_years: "%{count}å"
almost_x_years: "%{count}å"
half_a_minute: Nylig
less_than_x_minutes: "%{count}min"
less_than_x_seconds: Nylig
over_x_years: "%{count}å"
x_days: "%{count}d"
x_minutes: "%{count}min"
x_months: "%{count}mo"
x_seconds: "%{count}s"
exports:
blocks: Du blokkerer
csv: CSV
follows: Du følger
storage: Media lagring
generic:
changes_saved_msg: Vellykket lagring av endringer!
powered_by: drevet av %{link}
save_changes: Lagre endringer
validation_errors:
one: Noe er ikke helt riktig ennå. Vær snill å se etter en gang til
other: Noe er ikke helt riktig ennå. Det er ennå %{count} feil å rette på
imports:
preface: Du kan importere data om mennesker du følger eller blokkerer inn til kontoen din på denne instansen, fra filer opprettet av eksporter fra andre instanser.
success: Din data ble mottatt og vil bli prosessert så fort som mulig.
types:
blocking: Blokkeringsliste
following: Følgeliste
upload: Opplastning
landing_strip_html: <strong>%{name}</strong> er en bruker på <strong>%{domain}</strong>. Du kan følge dem eller interagere med dem hvis du har en konto hvor som helst i fediverset. Hvis du ikke har en konto så kan du <a href="%{sign_up_path}">registrere deg her</a>.
notification_mailer:
digest:
body: 'Her er en kort oppsummering av hva du har gått glipp av på %{instance} siden du logget deg inn sist den %{since}:'
mention: "%{name} nevnte deg i:"
new_followers_summary:
one: Du har fått en ny følger. Jippi!
other: Du har fått %{count} nye følgere! Imponerende!
subject:
one: "1 ny hendelse siden ditt siste besøk \U0001F418"
other: "%{count} nye hendelser siden ditt siste besøk \U0001F418"
favourite:
body: 'Din status ble satt som favoritt av %{name}'
subject: "%{name} satte din status som favoritt."
follow:
body: "%{name} følger deg!"
subject: "%{name} følger deg"
follow_request:
body: "%{name} har spurt om å få lov til å følge deg"
subject: 'Ventende følger: %{name}'
mention:
body: 'Du ble nevnt av %{name} i:'
subject: Du ble nevnt av %{name}
reblog:
body: 'Din status fikk en boost av %{name}:'
subject: "%{name} ga din status en boost"
pagination:
next: Neste
prev: Forrige
remote_follow:
acct: Tast inn brukernavn@domene som du vil følge fra
missing_resource: Kunne ikke finne URLen for din konto
proceed: Fortsett med følging
prompt: 'Du kommer til å følge:'
settings:
authorized_apps: Autoriserte applikasjoner
back: Tilbake til Mastodon
edit_profile: Endre profil
export: Data eksport
import: Importer
preferences: Foretrukne valg
settings: Innstillinger
two_factor_auth: To-faktor autentisering
statuses:
open_in_web: Åpne i nettleser
over_character_limit: tegngrense på %{max} overskredet
show_more: Vis mer
visibilities:
private: Vis kun til følgere
public: Offentlig
unlisted: Offentlig, men vis ikke på offentlig tidslinje
stream_entries:
click_to_show: Klikk for å vise
reblogged: boostet
sensitive_content: Sensitivt innhold
time:
formats:
default: "%d, %b %Y, %H:%M"
two_factor_auth:
description_html: Hvis du skru på <strong>tofaktor autentisering</strong> vil innlogging kreve at du har telefonen din, som vil generere koder som du må taste inn.
disable: Skru av
enable: Skru på
instructions_html: "<strong>Scan denne QR-koden i Google Authenticator eller en lignende app på telefonen din</strong>. Fra nå av så vil denne applikasjonen generere koder for deg som skal brukes under innlogging"
plaintext_secret_html: 'Plain-text secret: <samp>%{secret}</samp>'
warning: Hvis du ikke kan konfigurere en autentikatorapp nå, så bør du trykke "Skru av"; ellers vil du ikke kunne logge inn.
users:
invalid_email: E-post addressen er ugyldig
invalid_otp_token: Ugyldig two-faktor kode
will_paginate:
page_gap: "&hellip;"

View File

@ -8,12 +8,15 @@ en:
header: PNG, GIF or JPG. At most 2MB. Will be downscaled to 700x335px
locked: Requires you to manually approve followers and defaults post privacy to followers-only
note: At most 160 characters
imports:
data: CSV file exported from another Mastodon instance
labels:
defaults:
avatar: Avatar
confirm_new_password: Confirm new password
confirm_password: Confirm password
current_password: Current password
data: Data
display_name: Display name
email: E-mail address
header: Header
@ -24,6 +27,7 @@ en:
otp_attempt: Two-factor code
password: Password
setting_default_privacy: Post privacy
type: Import type
username: Username
interactions:
must_be_follower: Block notifications from non-followers

View File

@ -0,0 +1,46 @@
---
fi:
simple_form:
hints:
defaults:
avatar: PNG, GIF tai JPG. Korkeintaan 2MB. Skaalataan kokoon 120x120px
display_name: Korkeintaan 30 merkkiä
header: PNG, GIF tai JPG. Korkeintaan 2MB. Skaalataan kokoon 700x335px
locked: Vaatii sinun manuaalisesti hyväksymään seuraajat ja asettaa julkaisun yksityisyyden vain seuraajille
note: Korkeintaan 160 merkkiä
imports:
data: CSV tiedosto tuotu toiselta Mastodon palvelimelta
labels:
defaults:
avatar: Avatar
confirm_new_password: Varmista uusi salasana
confirm_password: Varmista salasana
current_password: Nykyinen salasana
data: Data
display_name: Näyttö nimi
email: Sähköpostiosoite
header: Header
locale: Kieli
locked: Tee tilistä yksityinen
new_password: Uusi salasana
note: Bio
otp_attempt: Kaksivaiheinen koodi
password: Salasana
setting_default_privacy: Julkaisun yksityisyys
type: Tuonti tyyppi
username: Käyttäjänimi
interactions:
must_be_follower: Estä ilmoitukset käyttäjiltä jotka eivät seuraa sinua
must_be_following: Estä ilmoitukset käyttäjiltä joita et seuraa
notification_emails:
digest: Send digest e-mails
favourite: Lähetä s-posti kun joku tykkää statuksestasi
follow: Lähetä s-posti kun joku seuraa sinua
follow_request: Lähetä s-posti kun joku pyytää seurata sinua
mention: Lähetä s-posti kun joku mainitsee sinut
reblog: Lähetä s-posti kun joku uudestaanblogaa julkaisusi
'no': 'Ei'
required:
mark: "*"
text: vaaditaan
'yes': 'Kyllä'

View File

@ -1,26 +1,42 @@
---
fr:
simple_form:
hints:
defaults:
avatar: Au format PNG, GIF ou JPG. 2Mo maximum. Sera réduit à 120x120px
display_name: 30 caractères maximum
header: Au format PNG, GIF ou JPG. 2Mo maximum. Sera réduit à 700x335px
locked: Vous devrez approuver chaque abonné⋅e et vos statuts ne s'afficheront qu'à vos abonné⋅es
note: 160 caractères maximum
imports:
data: Un fichier CSV généré par une autre instance de Mastodon
labels:
defaults:
avatar: Image de profil
confirm_new_password: Confirmation du nouveau mot de passe
confirm_password: Confirmation du mot de passe
current_password: Mot de passe actuel
data: Données
display_name: Nom public
email: Adresse courriel
header: Image den-tête
locale: Langue
locked: Rendre le compte privé
new_password: Nouveau mot de passe
note: Présentation
otp_attempt: Code d'identification à deux facteurs
password: Mot de passe
setting_default_privacy: Confidentialité des statuts
type: Type d'import
username: Identifiant
interactions:
must_be_follower: Masquer les notifications des personnes qui ne vous suivent pas
must_be_following: Masquer les notifications des personnes que vous ne suivez pas
notification_emails:
favourite: Envoyer un courriel lorsque quelquun ajoute mes statut à ses favoris
digest: Envoyer des emails récapitulatifs
favourite: Envoyer un courriel lorsque quelquun ajoute mes statuts à ses favoris
follow: Envoyer un courriel lorsque quelquun me suit
follow_request: Envoyer un courriel lorsque quelqu'un demande à me suivre
mention: Envoyer un courriel lorsque quelquun me mentionne
reblog: Envoyer un courriel lorsque quelquun partage mes statuts
'no': Non

View File

@ -0,0 +1,46 @@
---
'no':
simple_form:
hints:
defaults:
avatar: PNG, GIF eller JPG. Maksimalt 2MB. Vil bli nedskalert til 120x120px
display_name: Maksimalt 30 tegn
header: PNG, GIF eller JPG. Maksimalt 2MB. Vil bli nedskalert til 700x335px
locked: Krever at du manuelt godkjenner følgere og setter standard beskyttelse av poster til kun-følgere
note: Maksimalt 160 tegn
imports:
data: CSV fil eksportert fra en annen Mastodon instans
labels:
defaults:
avatar: Avatar
confirm_new_password: Bekreft nytt passord
confirm_password: Bekreft passord
current_password: Nåværende passord
data: Data
display_name: Visningsnavn
email: E-post adresse
header: Header
locale: Språk
locked: Endre konto til privat
new_password: Nytt passord
note: Biografi
otp_attempt: To-faktor kode
password: Passord
setting_default_privacy: Leserettigheter for poster
type: Importeringstype
username: Brukernavn
interactions:
must_be_follower: Blokker meldinger fra ikke-følgere
must_be_following: Blokker meldinger fra folk du ikke følger
notification_emails:
digest: Send oppsummerings eposter
favourite: Send e-post når noen setter din status som favoritt
follow: Send e-post når noen følger deg
follow_request: Send e-post når noen spør om å få følge deg
mention: Send e-post når noen nevner deg
reblog: Send e-post når noen reblogger din status
'no': 'Nei'
required:
mark: "*"
text: påkrevd
'yes': 'Ja'

View File

@ -9,15 +9,16 @@ SimpleNavigation::Configuration.run do |navigation|
settings.item :preferences, safe_join([fa_icon('sliders fw'), t('settings.preferences')]), settings_preferences_url
settings.item :password, safe_join([fa_icon('cog fw'), t('auth.change_password')]), edit_user_registration_url
settings.item :two_factor_auth, safe_join([fa_icon('mobile fw'), t('settings.two_factor_auth')]), settings_two_factor_auth_url
settings.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_url
settings.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_url
settings.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url
end
primary.item :admin, safe_join([fa_icon('cogs fw'), 'Administration']), admin_accounts_url, if: proc { current_user.admin? } do |admin|
primary.item :admin, safe_join([fa_icon('cogs fw'), 'Administration']), admin_reports_url, if: proc { current_user.admin? } do |admin|
admin.item :reports, safe_join([fa_icon('flag fw'), 'Reports']), admin_reports_url, highlights_on: %r{/admin/reports}
admin.item :accounts, safe_join([fa_icon('users fw'), 'Accounts']), admin_accounts_url, highlights_on: %r{/admin/accounts}
admin.item :pubsubhubbubs, safe_join([fa_icon('paper-plane-o fw'), 'PubSubHubbub']), admin_pubsubhubbub_index_url
admin.item :domain_blocks, safe_join([fa_icon('lock fw'), 'Domain Blocks']), admin_domain_blocks_url
admin.item :domain_blocks, safe_join([fa_icon('lock fw'), 'Domain Blocks']), admin_domain_blocks_url, highlights_on: %r{/admin/domain_blocks}
admin.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url
admin.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_url
admin.item :settings, safe_join([fa_icon('cogs fw'), 'Site Settings']), admin_settings_url

View File

@ -51,6 +51,7 @@ Rails.application.routes.draw do
namespace :settings do
resource :profile, only: [:show, :update]
resource :preferences, only: [:show, :update]
resource :import, only: [:show, :create]
resource :export, only: [:show] do
collection do
@ -76,7 +77,7 @@ Rails.application.routes.draw do
namespace :admin do
resources :pubsubhubbub, only: [:index]
resources :domain_blocks, only: [:index, :create]
resources :domain_blocks, only: [:index, :new, :create]
resources :settings, only: [:index, :update]
resources :reports, only: [:index, :show] do

View File

@ -1,14 +1,13 @@
class AddCounterCaches < ActiveRecord::Migration[5.0]
def change
add_column :statuses, :favourites_count, :integer
add_column :statuses, :reblogs_count, :integer
execute('update statuses set favourites_count = (select count(*) from favourites where favourites.status_id = statuses.id), reblogs_count = (select count(*) from statuses as reblogs where reblogs.reblog_of_id = statuses.id)')
add_column :accounts, :statuses_count, :integer
add_column :accounts, :followers_count, :integer
add_column :accounts, :following_count, :integer
execute('update accounts set statuses_count = (select count(*) from statuses where account_id = accounts.id), followers_count = (select count(*) from follows where target_account_id = accounts.id), following_count = (select count(*) from follows where account_id = accounts.id)')
add_column :statuses, :favourites_count, :integer, null: false, default: 0
add_column :statuses, :reblogs_count, :integer, null: false, default: 0
add_column :accounts, :statuses_count, :integer, null: false, default: 0
add_column :accounts, :followers_count, :integer, null: false, default: 0
add_column :accounts, :following_count, :integer, null: false, default: 0
end
end
# To make the new fields contain correct data:
# update statuses set favourites_count = (select count(*) from favourites where favourites.status_id = statuses.id), reblogs_count = (select count(*) from statuses as reblogs where reblogs.reblog_of_id = statuses.id);
# update accounts set statuses_count = (select count(*) from statuses where account_id = accounts.id), followers_count = (select count(*) from follows where target_account_id = accounts.id), following_count = (select count(*) from follows where account_id = accounts.id);

View File

@ -0,0 +1,11 @@
class CreateImports < ActiveRecord::Migration[5.0]
def change
create_table :imports do |t|
t.integer :account_id, null: false
t.integer :type, null: false
t.boolean :approved
t.timestamps
end
end
end

View File

@ -0,0 +1,11 @@
class AddAttachmentDataToImports < ActiveRecord::Migration
def self.up
change_table :imports do |t|
t.attachment :data
end
end
def self.down
remove_attachment :imports, :data
end
end

View File

@ -0,0 +1,5 @@
class AddActionTakenByAccountIdToReports < ActiveRecord::Migration[5.0]
def change
add_column :reports, :action_taken_by_account_id, :integer
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170330021336) do
ActiveRecord::Schema.define(version: 20170403172249) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -44,9 +44,9 @@ ActiveRecord::Schema.define(version: 20170330021336) do
t.boolean "suspended", default: false, null: false
t.boolean "locked", default: false, null: false
t.string "header_remote_url", default: "", null: false
t.integer "statuses_count"
t.integer "followers_count"
t.integer "following_count"
t.integer "statuses_count", default: 0, null: false
t.integer "followers_count", default: 0, null: false
t.integer "following_count", default: 0, null: false
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
t.index "lower((username)::text), lower((domain)::text)", name: "index_accounts_on_username_and_domain_lower", using: :btree
t.index ["username", "domain"], name: "index_accounts_on_username_and_domain", unique: true, using: :btree
@ -93,6 +93,18 @@ ActiveRecord::Schema.define(version: 20170330021336) do
t.index ["account_id", "target_account_id"], name: "index_follows_on_account_id_and_target_account_id", unique: true, using: :btree
end
create_table "imports", force: :cascade do |t|
t.integer "account_id", null: false
t.integer "type", null: false
t.boolean "approved"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "data_file_name"
t.string "data_content_type"
t.integer "data_file_size"
t.datetime "data_updated_at"
end
create_table "media_attachments", force: :cascade do |t|
t.bigint "status_id"
t.string "file_file_name"
@ -189,13 +201,14 @@ ActiveRecord::Schema.define(version: 20170330021336) do
end
create_table "reports", force: :cascade do |t|
t.integer "account_id", null: false
t.integer "target_account_id", null: false
t.bigint "status_ids", default: [], null: false, array: true
t.text "comment", default: "", null: false
t.boolean "action_taken", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "account_id", null: false
t.integer "target_account_id", null: false
t.bigint "status_ids", default: [], null: false, array: true
t.text "comment", default: "", null: false
t.boolean "action_taken", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "action_taken_by_account_id"
end
create_table "settings", force: :cascade do |t|
@ -223,8 +236,8 @@ ActiveRecord::Schema.define(version: 20170330021336) do
t.integer "application_id"
t.text "spoiler_text", default: "", null: false
t.boolean "reply", default: false
t.integer "favourites_count"
t.integer "reblogs_count"
t.integer "favourites_count", default: 0, null: false
t.integer "reblogs_count", default: 0, null: false
t.index ["account_id"], name: "index_statuses_on_account_id", using: :btree
t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id", using: :btree
t.index ["reblog_of_id"], name: "index_statuses_on_reblog_of_id", using: :btree

View File

@ -2,10 +2,10 @@ version: '2'
services:
db:
restart: always
image: postgres
image: postgres:alpine
redis:
restart: always
image: redis
image: redis:alpine
web:
restart: always
build: .
@ -33,7 +33,7 @@ services:
restart: always
build: .
env_file: .env.production
command: bundle exec sidekiq -q default -q mailers -q push
command: bundle exec sidekiq -q default -q mailers -q pull -q push
depends_on:
- db
- redis

View File

@ -7,6 +7,7 @@ Index
- [Frequently Asked Questions](Using-Mastodon/FAQ.md)
- [List of Mastodon instances](Using-Mastodon/List-of-Mastodon-instances.md)
- [Apps](Using-Mastodon/Apps.md)
- [User Guide](Using-Mastodon/User-guide.md)
### Using the API
- [API documentation](Using-the-API/API.md)

View File

@ -95,7 +95,6 @@ Setup a user and database for Mastodon:
In the prompt:
CREATE USER mastodon CREATEDB;
CREATE DATABASE mastodon_production OWNER mastodon;
\q
## Rbenv
@ -181,7 +180,7 @@ User=mastodon
WorkingDirectory=/home/mastodon/live
Environment="RAILS_ENV=production"
Environment="DB_POOL=5"
ExecStart=/home/mastodon/.rbenv/shims/bundle exec sidekiq -c 5 -q default -q mailers -q push
ExecStart=/home/mastodon/.rbenv/shims/bundle exec sidekiq -c 5 -q default -q mailers -q pull -q push
TimeoutSec=15
Restart=always
@ -210,7 +209,7 @@ Restart=always
WantedBy=multi-user.target
```
This allows you to `sudo systemctl enable mastodon-*.service` and `sudo systemctl start mastodon-*.service` to get things going.
This allows you to `sudo systemctl enable /etc/systemd/system/mastodon-*.service` and `sudo systemctl start mastodon-web.service mastodon-sidekiq.service mastodon-streaming.service` to get things going.
## Cronjobs

View File

@ -1,19 +1,26 @@
List of Known Mastodon instances
==========================
| Name | Theme/Notes, if applicable | Open Registrations |
| -------------|-------------|---|
| [mastodon.social](https://mastodon.social) |Flagship, quick updates|Yes|
| [awoo.space](https://awoo.space) |Intentionally moderated, only federates with mastodon.social|Yes|
| [social.tchncs.de](https://social.tchncs.de)|N/A|Yes|
| [animalliberation.social](https://animalliberation.social) |Animal Rights|Yes|
| [socially.constructed.space](https://socially.constructed.space) |Single user|No|
| [epiktistes.com](https://epiktistes.com) |N/A|Yes|
| [on.vu](https://on.vu) | Appears defunct|No|
| [gay.crime.team](https://gay.crime.team) |N/A|Yes(?)|
| [icosahedron.website](https://icosahedron.website/) |Icosahedron-themed (well, visually), open registration.|Yes|
| [memetastic.space](https://memetastic.space) |Memes|Yes|
| [social.diskseven.com](https://social.diskseven.com) |Single user|No|
| [social.gestaltzerfall.net](https://social.gestaltzerfall.net) |Single user|No|
There is also a list at [instances.mastodon.xyz](https://instances.mastodon.xyz) showing realtime information about instances.
| Name | Theme/Notes, if applicable | Open Registrations | IPv6 |
| -------------|-------------|---|---|
| [mastodon.social](https://mastodon.social) |Flagship, quick updates|Yes|No|
| [awoo.space](https://awoo.space) |Intentionally moderated, only federates with mastodon.social|Yes|No|
| [social.tchncs.de](https://social.tchncs.de)|N/A|Yes|No|
| [animalliberation.social](https://animalliberation.social) |Animal Rights|Yes|No|
| [socially.constructed.space](https://socially.constructed.space) |Single user|No|No|
| [epiktistes.com](https://epiktistes.com) |N/A|Yes|No|
| [gay.crime.team](https://gay.crime.team) |the place for doin' gay crime online (please don't actually do crime here)|Yes|No|
| [icosahedron.website](https://icosahedron.website/) |Icosahedron-themed (well, visually), open registration.|Yes|No|
| [memetastic.space](https://memetastic.space) |Memes|Yes|No|
| [social.diskseven.com](https://social.diskseven.com) |Single user|No|No (DNS entry but no response)|
| [social.gestaltzerfall.net](https://social.gestaltzerfall.net) |Single user|No|No|
| [mastodon.xyz](https://mastodon.xyz) |N/A|Yes|Yes|
| [social.targaryen.house](https://social.targaryen.house) |N/A|Yes|No|
| [social.mashek.net](https://social.mashek.net) |Themed and customised for Mashekstein Labs community. Selectively federates.|Yes|No|
| [masto.themimitoof.fr](https://masto.themimitoof.fr) |N/A|Yes|Yes|
| [social.imirhil.fr](https://social.imirhil.fr) |N/A|No|Yes|
| [social.wxcafe.net](https://social.wxcafe.net) |Open registrations, federates everywhere, no moderation yet|Yes|Yes|
Let me know if you start running one so I can add it to the list! (Alternatively, add it yourself as a pull request).

View File

@ -0,0 +1,196 @@
Mastodon User's Guide
=====================
* [Intro](User-guide.md#intro)
* [Decentralization and Federation](User-guide.md#decentralization-and-federation)
* [Getting Started](User-guide.md#getting-started)
* [Setting Up Your Profile](User-guide.md#setting-up-your-profile)
* [E-Mail Notifications](User-guide.md#e-mail-notifications)
* [Text Posts](User-guide.md#text-posts)
* [Content Warnings](User-guide.md#content-warnings)
* [Hashtags](User-guide.md#hashtags)
* [Boosts and Favourites](User-guide.md#boosts-and-favourites)
* [Posting Images](User-guide.md#posting-images)
* [Following Other Users](User-guide.md#following-other-users)
* [Notifications](User-guide.md#notifications)
* [Mobile Apps](User-guide.md#mobile-apps)
* [The Federated Timeline](User-guide.md#the-federated-timeline)
* [The Local Timeline](User-guide.md#the-local-timeline)
* [Searching](User-guide.md#searching)
* [Privacy, Safety and Security](User-guide.md#privacy-safety-and-security)
* [Two-Factor Authentication](User-guide.md#two-factor-authentication)
* [Account Privacy](User-guide.md#account-privacy)
* [Toot Privacy](User-guide.md#toot-privacy)
* [Blocking](User-guide.md#blocking)
* [Reporting Toots or Users](User-guide.md#reporting-toots-or-users)
## Intro
Mastodon is a social network application based on the GNU Social protocol. It behaves a lot like other social networks, especially Twitter, with one key difference - it is open-source and anyone can start their own server (also called an "instance"), and users of any instance can interact freely with those of other instances (called "federation"). Thus, it is possible for small communities to set up their own servers to use amongst themselves while also allowing interaction with other communities.
#### Decentralization and Federation
Mastodon is a system decentralized through a concept called "federation" - rather than depending on a single person or organization to run its infrastructure, anyone can download and run the software and run their own server. Federation means different Mastodon servers can interact with each other seamlessly, similar to e.g. e-mail.
As such, anyone can download Mastodon and e.g. run it for a small community of people, but any user registered on that instance can follow and send and read posts from other Mastodon instances (as well as servers running other GNU Social-compatible services). This means that not only is users' data not inherently owned by a company with an interest in selling it to advertisers, but also that if any given server shuts down its users can set up a new one or migrate to another instance, rather than the entire service being lost.
Within each Mastodon instance, usernames just appear as `@username`, similar to other services such as Twitter. Users from other instances appear, and can be searched for and followed, as `@user@servername.ext` - so e.g. `@gargron` on the `mastodon.social` instance can be followed from other instances as `@gargron@mastodon.social`).
Posts from users on external instances are "federated" into the local one, i.e. if `user1@mastodon1` follows `user2@gnusocial2`, any posts `user2@gnusocial2` makes appear in both `user1@mastodon`'s Home feed and the public timeline on the `mastodon1` server. Mastodon server administrators have some control over this and can exclude users' posts from appearing on the public timeline; post privacy settings from users on Mastodon instances also affect this, see below in the [Toot Privacy](User-guide.md#toot-privacy) section.
## Getting Started
#### Setting Up Your Profile
You can customise your Mastodon profile in a number of ways - you can set a custom "display" name, a profile "avatar" picture, a background image for your profile page header, and a short "bio" that summarises you or your account.
![Preferences icon](screenshots/preferences.png) To edit your profile, click the Preferences icon in the Compose column and select "Edit Profile" on the left-hand menu on the Preferences page. Your display name is limited to 30 characters, your bio to 160. Avatars and header pictures can be uploaded as png, gif or jpg images and cannot be larger than 2MB. They will be resized to standard sizes - 120x120 pixels for avatars, 700x335 pixels for header pictures.
#### E-Mail Notifications
![Preferences icon](screenshots/preferences.png) Mastodon can notify you of activity via e-mail if you so choose. To adjust your settings for receiving e-mail notifications, click the Preferences icon in the Compose column and select the "Preferences" page from the left-hand menu. Here you will find a number of checkboxes to enable or disable e-mail notifications for various types of activity.
#### Text Posts
The most basic way to interact with Mastodon is to make a text post, also called a *Toot*. In order to toot, simply enter the message you want to post into the "What is on your mind?" text box in the Compose column and click "TOOT". There is a limit of up to 500 characters per toot; if you really do need more than this you can reply to your own toots so they will appear like a conversation.
If you want to reply to another user's toot, you can click the "Reply" icon on it. This will add their username to your input box along with a preview of the message you're replying to, and the user will receive a notification of your response.
Similarly, in order to start a conversation with another user, just mention their user name in your toot. When you type the @ symbol followed directly (without a space) by any character in a message, Mastodon will automatically start suggesting users that match the username you're typing. Like with replies, mentioning a user like this will send them a notification.
##### Content Warnings
When you want to post something that you don't want to be immediately visible - for example, spoilers for that film that's just out, or some personal thoughts that contain [triggers](http://www.bbc.co.uk/news/blogs-ouch-26295437), you can "hide" it behind a Content Warning.
To do this, click the ![CW icon](screenshots/compose-cw.png) "CW" switch under the Compose box. This will add another text box labeled "Content warning"; you should enter a short summary of what the "body" of your post contains here while your actual post goes into the "What is on your mind?" box as normal.
![animation showing how to enable content warnings](screenshots/content-warning.gif)
This will cause the body of your post to be hidden behind a "Show More" button in the timeline, with only the content warning visible by default:
![animation showing content warnings in the timeline](screenshots/cw-toot.gif)
**NOTE** that this will not hide images included in your post - images can be marked as "sensitive" separately to hide them from view until clicked on. To find out how to do this, see the [Posting Images](User-guide.md#posting-images) section of this user guide.
##### Hashtags
If you're making a post belonging to a wider subject, it might be worth adding a "hashtag" to it. This can be done simply by adding any alphanumeric term with a # sign in front of it to the toot, e.g. #introductions (which is popular on mastodon.social for new users to introduce themselves to the community), or #politics for political discussions, etc. Clicking on a hashtag in a toot will show a timeline consisting only of toots that include this hashtag (i.e. it's a shortcut to searching for it). This allows users to group messages of similar subjects together, forming a separate "timeline" for people interested in that subject.
##### Boosts and Favourites
You can *favourite* another user's toot by clicking the star icon underneath. This will send the user a notification that you have marked their post as a favourite; the meaning of this varies widely by context from a general "I'm listening" to signalling agreement or offering support for the ideas expressed.
Additionally you can *boost* toots by clicking the "circular arrows" icon. Boosting a toot will show it on your profile timeline and make it appear to all your followers, even if they aren't following the user who made the original post. This is helpful if someone posts a message you think others should see, as it increases the message's reach while keeping the author information intact.
#### Posting Images
![Image icon](screenshots/compose-media.png) In order to post an image, simply click or tap the "image" icon in your Compose column and select a file to upload.
If the image is "not safe for work" or has otherwise sensitive content, you can select the ![NSFW toggle](screenshots/compose-nsfw.png) "NSFW" button which appears once you have added an image. This will hide the image in your post by default, making it clickable to show the preview. This is the "visual" version of [content warnings](User-guide.md#content-warnings) and could be combined with them if there is text to accompany the image - otherwise it's fine to just mark the image as sensitive and make the body of your post the content warning.
You can also attach video files or GIF animations to Toots. However, there is a 4MB file size limit for these files and videos must be in .webm or .mp4 format.
#### Following Other Users
Following another user will make all of their toots as well as other users' toots which they [boost](User-guide.md#boosts-and-favourites) in your Home column. This gives you a separate timeline from the [federated timeline](User-guide.md#the-federated-timeline) in which you can read what particular people are up to without the noise of general conversation.
![Follow icon](screenshots/follow.png) In order to follow a user, click their name or avatar to open their profile, then click the Follow icon in the top left of their profile view.
If their account is locked (which is shown with a padlock icon ![Padlock icon](screenshots/locked-icon.png) next to their user name), they will receive a notification of your request to follow them and need to approve this before you are added to their follower list (and thus see their toots). To show you that you're waiting for someone to approve your follow request, the Follow icon ![Follow icon](screenshots/follow-icon.png) on their profile will be replaced with an hourglass icon ![Pending icon](screenshots/pending-icon.png).
Once you follow a user, the Follow icon will be highlighted in blue on their profile ![Following icon](screenshots/following-icon.png); you can unfollow them again by clicking this.
If you know someone's user name you can also open their profile for following by entering it in the [Search box](User-guide.md#searching) in the Compose column. This also works for remote users, though depending on whether they are known to your home instance you might have to enter their full name including the domain (e.g. `gargron@mastodon.social`) into the search box before their profile will appear in the suggestions.
Alternately, if you already have a user's profile open in a separate browser tab, most GNU Social-related networks should have a "Follow" or "Subscribe" button on their profile page. This will ask you to enter the full user name to follow **from** (ie. if your account is on mastodon.social you would want to enter this as `myaccount@mastodon.social`)
#### Notifications
When someone follows your account or requests to follow you, mentions your user name (either as an initial message or in response to one of your toots) or boosts or favourites one of your toots, you will receive a notification for this. These will appear as desktop notifications on your computer (if your web browser supports this and you've enabled them) as well as in your "Notifications" column.
![Notification Settings icon](screenshots/notifications-settings.png) You can filter what kind of notifications you see in the Notifications column by clicking the Notification Settings icon at the top of the column and ticking or un-ticking what you do or don't want to see notifications for.
![Clear icon](screenshots/notifications-clear.png) If your notifications become cluttered, you can clear the column by clicking the Clear icon at the top of the column; this will wipe its contents.
![Preferences icon](screenshots/preferences.png) You can also disable notifications from people you don't follow or who don't follow you entirely - to do this, click the Preferences icon in the Compose column, select "Preferences" on the left-hand menu and check either of the respective "Block notifications" options.
#### Mobile Apps
There are no official mobile Mastodon apps for iOS or Android at this point. However, there are several third-party apps in development; you can find a list of these [here](Apps.md).
#### The Federated Timeline
Mastodon has a "Federated" timeline, which is a collection of all public toots made by all local users as well as posts from remote users that are federated (because someone on your instance follows the remote user making the post). This is a good way to meet new people to follow or interact with, but can be overwhelming especially if there's a lot of activity.
![Federated Timeline icon](screenshots/federated-timeline.png) To view the federated timeline, click the "Federated Timeline" icon in your Compose column or the respective button on the Getting Started panel. To hide the federated timeline again, simply click the "Back" link at the top of the column while you're viewing it.
#### The Local Timeline
In addition to the Federated Timeline, there's also a "Local" timeline, which only shows public toots made by users on your home instance. This is quieter than the Federated timeline, and useful if you want to stick close to your instance's community without having too much noise from outside. To view the Local Timeline, click the ![Menu icon](screenshots/compose-menu.png) Menu icon on the Compose pane and then select "Local Timeline" on the rightmost column.
#### Searching
Mastodon has a search function - however, this is limited to users and [hashtags](User-guide.md#hashtags) only and cannot be used to search through the full text of toots. In order to start a search, just type into the search box in the Compose column; Mastodon will automatically start showing suggestions of both user names and hashtags in a pop-up after a moment. Selecting any of these will open the user's profile or a view of all toots on the hashtag.
## Privacy, Safety and Security
Mastodon has a number of advanced security, privacy and safety features over more public networks such as Twitter. Particularly the privacy controls are fairly granular; this section will explain how these features work.
#### Two-Factor Authentication
Two-Factor Authentication (2FA) is a mechanism that improves the security of your Mastodon account by requiring a numeric code from another device (most commonly mobile phones) linked to your Mastodon account when you log in - this means that even if someone gets hold of both your e-mail address and your password, they cannot take over your Mastodon account as they would need a physical device you own to log in.
Mastodon's 2FA uses Google Authenticator (or compatible apps). You can install this for free to your [Android](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2) or [iOS](https://itunes.apple.com/gb/app/google-authenticator/id388497605) device; [this Wikipedia page](https://en.wikipedia.org/wiki/Google_Authenticator#Implementations) lists further versions of the app for other systems.
![Preferences icon](screenshots/preferences.png) In order to enable 2FA for your Mastodon account, click the Preferences icon in the Compose column, click "Two-factor Authentication" in the left menu on the settings page and follow the instructions. Once activated, every time you log in you will need a one-time code generated by the Authenticator app on the device you've linked to your account.
#### Account Privacy
To allow you more control over who can see your toots, Mastodon supports "private" or "locked" accounts. If your account is set to private, you will be notified every time someone tries to follow you, and you will be able to allow or deny the follow request. Additionally, if your account is private, any new toots you compose will default to being private (see the [Toot Privacy](User-guide.md#toot-privacy) section below).
![Preferences icon](screenshots/preferences.png) To make your account private, click the Preferences icon in the Compose pane, select "Edit Profile" and tick the "Make account private" checkbox, then click "Save Changes".
![Screenshot of the "Private Account" setting](screenshots/private.png)
#### Toot Privacy
Toot privacy is handled independently of account privacy, and individually for each toot. The three tiers of visibility for toots are Public (default), Unlisted or Private. In order to select your privacy level, click the ![Globe icon](screenshots/compose-privacy.png) globe icon. Changes to this setting are remembered between posts, i.e. if you make one private toot you will need to disable the switch again to make public toots.
**Public** is the default status of toots on accounts not set to private; a toot is public if neither of the two flags are set. Public toots are visible to any other user on the public timeline, federate to other GNU Social instances without restriction and appear on your user profile page to anyone including search engine bots and visitors who aren't logged into a Mastodon account.
**Unlisted** toots are toggled with the "Do not display in public timeline" option in the Compose pane. They are visible to anyone following you and appear on your profile page to the public even without a Mastodon login, but do *not* appear to anyone viewing the Public Timeline while logged into Mastodon.
**Private** toots, finally, are toggled with the "Mark as private" switch. Private toots do not appear in the public timeline nor on your profile page to anyone viewing it unless they are on your Followers list. This means the option is of very limited use if your account is not also set to be private (as anyone can follow you without confirmation and thus see your private toots). However the separation of this means that if you *do* set your entire account to private, you can switch this option off on a toot to make unlisted or even public toots from your otherwise private account.
Private toots do not federate to other instances, unless you @mention a remote user. In this case, they will federate to their instance *and may appear there PUBLICLY*. A warning will be displayed if you're composing a private toot that will federate to another instance.
Private toots cannot be boosted. If someone you follow makes a private toot, it will appear in your timeline with a padlock icon in place of the Boost icon. **NOTE** that remote instances may not respect this.
**Direct** messages are only visible to users you have @mentioned in them. This does *not* federate to protect your privacy (as other instances may ignore the "Direct" status and display the messages as public if they were to receive them), even if you have @mentioned a remote user.
To summarise:
Toot Privacy | Visible on Profile | Visible on Public Timeline | Federates to other instances
------------ | ------------------ | -------------------------- | ---------------------------
Public | Anyone incl. anonymous viewers | Yes | Yes
Unlisted | Anyone incl. anonymous viewers | No | Yes
Private | Followers only | No | Only remote @mentions
Direct | No | No | No
#### Blocking
You can block a user to stop them contacting you. To do this, you can click or tap the Menu icon on either a toot of theirs or their profile view and select "Block".
**NOTE** that this will stop them from seeing your public toots while they are logged in, but they *will* be able to see your public toots by simply opening your profile in another browser that isn't logged into Mastodon (or logged into a different account that you have not blocked).
Mentions, favourites, boosts or any other interaction with you from a blocked user will be hidden from your view. You will not see replies to a blocked person, even if the reply mentions you, nor will you see their toots if someone boosts them. You will not see toots mentioning a blocked person except in the public timeline.
The blocked user will not be notified of your blocking them. They will be removed from your followers, *but* will still be able to see any public toots you make. Blocks do not federate across instances.
#### Reporting Toots or Users
If you encounter a toot or a user that is breaking the rules of your instance or that you otherwise want to draw the instance administrators' attention to (e.g. if someone is harassing another user, spamming pornography or posting illegal content), you can click the "..." menu button on the toot or the "hamburger" menu on the profile and select to report this. The rightmost column will then switch over to the following form:
![Report form](screenshots/report.png)
In this form, you can select any toots you would like to report to the instance administrators and fill in any comment that might be helpful in identifying or handling the issue (from "is a spammer" to "this post contains untagged pornography"). The report will be visible to server administrators once it is sent so they can take appropriate action, for example hiding the user's posts from the public timeline or banning their account.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -6,25 +6,36 @@ API overview
- [Available libraries](#available-libraries)
- [Notes](#notes)
- [Methods](#methods)
- Posting a status
- Uploading media
- Retrieving a timeline
- Retrieving notifications
- Following a remote user
- Fetching data
- Deleting a status
- Reblogging a status
- Favouriting a status
- Threads (status context)
- Who reblogged/favourited a status
- Following/unfollowing accounts
- Blocking/unblocking accounts
- Getting instance information
- Creating OAuth apps
- [Accounts](#accounts)
- [Apps](#apps)
- [Blocks](#blocks)
- [Favourites](#favourites)
- [Follow Requests](#follow-requests)
- [Follows](#follows)
- [Instances](#instances)
- [Media](#media)
- [Mutes](#mutes)
- [Notifications](#notifications)
- [Reports](#reports)
- [Search](#search)
- [Statuses](#statuses)
- [Timelines](#timelines)
- [Entities](#entities)
- Status
- Account
- [Pagination](#pagination)
- [Account](#account)
- [Application](#application)
- [Attachment](#attachment)
- [Card](#card)
- [Context](#context)
- [Error](#error)
- [Instance](#instance)
- [Mention](#mention)
- [Notification](#notification)
- [Relationships](#relationships)
- [Results](#results)
- [Status](#status)
- [Tag](#tag)
___
## Available libraries
@ -33,216 +44,117 @@ API overview
- [For JavaScript](https://github.com/Zatnosk/libodonjs)
- [For JavaScript (Node.js)](https://github.com/jessicahayley/node-mastodon)
___
## Notes
When an array parameter is mentioned, the Rails convention of specifying array parameters in query strings is meant. For example, a ruby array like `foo = [1, 2, 3]` can be encoded in the params as `foo[]=1&foo[]=2&foo[]=3`. Square brackets can be indexed but can also be empty.
### Parameter types
When an array parameter is mentioned, the Rails convention of specifying array parameters in query strings is meant.
For example, a ruby array like `foo = [1, 2, 3]` can be encoded in the params as `foo[]=1&foo[]=2&foo[]=3`.
Square brackets can be indexed but can also be empty.
When a file parameter is mentioned, a form-encoded upload is expected.
### Selecting ranges
For most `GET` operations that return arrays, the query parameters `max_id` and `since_id` can be used to specify the range of IDs to return.
API methods that return collections of items can return a `Link` header containing URLs for the `next` and `prev` pages.
See the [Link header RFC](https://tools.ietf.org/html/rfc5988) for more information.
### Errors
If the request you make doesn't go through, Mastodon will usually respond with an [Error](#error).
___
## Methods
### Posting a new status
**POST /api/v1/statuses**
### Accounts
Form data:
#### Fetching an account:
- `status`: The text of the status
- `in_reply_to_id` (optional): local ID of the status you want to reply to
- `media_ids` (optional): array of media IDs to attach to the status (maximum 4)
- `sensitive` (optional): set this to mark the media of the status as NSFW
- `visibility` (optional): either `private`, `unlisted` or `public`
- `spoiler_text` (optional): text to be shown as a warning before the actual content
GET /api/v1/accounts/:id
Returns the new status.
Returns an [Account](#account).
**POST /api/v1/media**
#### Getting the current user:
Form data:
GET /api/v1/accounts/verify_credentials
- `file`: Image to be uploaded
Returns the authenticated user's [Account](#account).
Returns a media object with an ID that can be attached when creating a status (see above).
#### Getting an account's followers:
### Retrieving a timeline
GET /api/v1/accounts/:id/followers
**GET /api/v1/timelines/home**
**GET /api/v1/timelines/public**
**GET /api/v1/timelines/tag/:hashtag**
Returns an array of [Accounts](#account).
Returns statuses, most recent ones first. Home timeline is statuses from people you follow, mentions timeline is all statuses that mention you. Public timeline is "whole known network", and the last is the hashtag timeline.
#### Getting who account is following:
GET /api/v1/accounts/:id/following
Returns an array of [Accounts](#account).
#### Getting an account's statuses:
GET /api/v1/accounts/:id/statuses
Query parameters:
- `max_id` (optional): Skip statuses younger than ID (e.g. navigate backwards in time)
- `since_id` (optional): Skip statuses older than ID (e.g. check for updates)
Query parameters for public and tag timelines only:
- `local` (optional): Only return statuses originating from this instance
### Notifications
**GET /api/v1/notifications**
Returns notifications for the authenticated user. Each notification has an `id`, a `type` (mention, reblog, favourite, follow), an `account` which it came *from*, and in case of mention, reblog and favourite also a `status`.
**GET /api/v1/notifications/:id**
Returns single notification.
**POST /api/v1/notifications/clear**
Clears all of user's notifications.
### Following a remote user
**POST /api/v1/follows**
Form data:
- uri: username@domain of the person you want to follow
Returns the local representation of the followed account.
### Fetching data
**GET /api/v1/statuses/:id**
Returns status.
**GET /api/v1/accounts/:id**
Returns account.
**GET /api/v1/accounts/verify_credentials**
Returns authenticated user's account.
**GET /api/v1/accounts/:id/statuses**
Returns statuses by user.
Query parameters:
- `max_id` (optional): Skip statuses younger than ID (e.g. navigate backwards in time)
- `since_id` (optional): Skip statuses older than ID (e.g. check for updates)
- `only_media` (optional): Only return statuses that have media attachments
- `exclude_replies` (optional): Skip statuses that reply to other statuses
**GET /api/v1/accounts/:id/following**
Returns an array of [Statuses](#status).
Returns users the given user is following.
#### Following/unfollowing an account:
**GET /api/v1/accounts/:id/followers**
GET /api/v1/accounts/:id/follow
GET /api/v1/accounts/:id/unfollow
Returns users the given user is followed by.
Returns the target [Account](#account).
**GET /api/v1/accounts/relationships**
#### Blocking/unblocking an account:
Returns relationships (`following`, `followed_by`, `blocking`, `muting`, `requested`) of the current user to a list of given accounts.
GET /api/v1/accounts/:id/block
GET /api/v1/accounts/:id/unblock
Returns the target [Account](#account).
#### Muting/unmuting an account:
GET /api/v1/accounts/:id/mute
GET /api/v1/accounts/:id/unmute
Returns the target [Account](#account).
#### Getting an account's relationships:
GET /api/v1/accounts/relationships
Query parameters:
- `id` (can be array): Account IDs
**GET /api/v1/accounts/search**
Returns an array of [Relationships](#relationships) of the current user to a list of given accounts.
Returns matching accounts. Will lookup an account remotely if the search term is in the username@domain format and not yet in the database.
#### Searching for accounts:
GET /api/v1/accounts/search
Query parameters:
- `q`: what to search for
- `limit`: maximum number of matching accounts to return
- `q`: What to search for
- `limit`: Maximum number of matching accounts to return (default: `40`)
**GET /api/v1/blocks**
Returns an array of matching [Accounts](#accounts).
Will lookup an account remotely if the search term is in the `username@domain` format and not yet in the database.
Returns accounts blocked by authenticated user.
### Apps
**GET /api/v1/mutes**
#### Registering an application:
Returns accounts muted by authenticated user.
**GET /api/v1/follow_requests**
Returns accounts that want to follow the authenticated user but are waiting for approval.
**GET /api/v1/favourites**
Returns statuses favourited by authenticated user.
### Deleting a status
**DELETE /api/v1/statuses/:id**
Returns an empty object.
### Reblogging a status
**POST /api/v1/statuses/:id/reblog**
Returns a new status that wraps around the reblogged one.
### Unreblogging a status
**POST /api/v1/statuses/:id/unreblog**
Returns the status that used to be reblogged.
### Favouriting a status
**POST /api/v1/statuses/:id/favourite**
Returns the target status.
### Unfavouriting a status
**POST /api/v1/statuses/:id/unfavourite**
Returns the target status.
### Threads
**GET /api/v1/statuses/:id/context**
Returns `ancestors` and `descendants` of the status.
### Who reblogged/favourited a status
**GET /api/v1/statuses/:id/reblogged_by**
**GET /api/v1/statuses/:id/favourited_by**
Returns list of accounts.
### Following and unfollowing users
**POST /api/v1/accounts/:id/follow**
**POST /api/v1/accounts/:id/unfollow**
Returns the updated relationship to the user.
### Blocking and unblocking users
**POST /api/v1/accounts/:id/block**
**POST /api/v1/accounts/:id/unblock**
Returns the updated relationship to the user.
### Getting instance information
**GET /api/v1/instance**
Returns an object containing the `title`, `description`, `email` and `uri` of the instance. Does not require authentication.
# Muting and unmuting users
**POST /api/v1/accounts/:id/mute**
**POST /api/v1/accounts/:id/unmute**
Returns the updated relationship to the user.
### OAuth apps
**POST /api/v1/apps**
POST /api/v1/apps
Form data:
@ -251,77 +163,361 @@ Form data:
- `scopes`: This can be a space-separated list of the following items: "read", "write" and "follow" (see [this page](OAuth-details.md) for details on what the scopes do)
- `website`: (optional) URL to the homepage of your app
Creates a new OAuth app. Returns `id`, `client_id` and `client_secret` which can be used with [OAuth authentication in your 3rd party app](Testing-with-cURL.md).
Creates a new OAuth app.
Returns `id`, `client_id` and `client_secret` which can be used with [OAuth authentication in your 3rd party app](Testing-with-cURL.md).
These values should be requested in the app itself from the API for each new app install + mastodon domain combo, and stored in the app for future requests.
### Blocks
#### Fetching a user's blocks:
GET /api/v1/blocks
Returns an array of [Accounts](#account) blocked by the authenticated user.
### Favourites
#### Fetching a user's favourites:
GET /api/v1/favourites
Returns an array of [Statuses](#status) favourited by the authenticated user.
### Follow Requests
#### Fetching a list of follow requests:
GET /api/v1/follow_requests
Returns an array of [Accounts](#account) which have requested to follow the authenticated user.
#### Authorizing or rejecting follow requests:
POST /api/v1/follow_requests/authorize
POST /api/v1/follow_requests/reject
Form data:
- `id`: The id of the account to authorize or reject
Returns an empty object.
### Follows
#### Following a remote user:
POST /api/v1/follows
Form data:
- `uri`: `username@domain` of the person you want to follow
Returns the local representation of the followed account, as an [Account](#account).
### Instances
#### Getting instance information:
GET /api/v1/instance
Returns the current [Instance](#instance).
Does not require authentication.
### Media
#### Uploading a media attachment:
POST /api/v1/media
Form data:
- `file`: Media to be uploaded
Returns an [Attachment](#attachment) that can be used when creating a status.
### Mutes
#### Fetching a user's mutes:
GET /api/v1/mutes
Returns an array of [Accounts](#account) muted by the authenticated user.
### Notifications
#### Fetching a user's notifications:
GET /api/v1/notifications
Returns a list of [Notifications](#notification) for the authenticated user.
#### Getting a single notification:
GET /api/v1/notifications/:id
Returns the [Notification](#notification).
#### Clearing notifications:
POST /api/v1/notifications/clear
Deletes all notifications from the Mastodon server for the authenticated user.
Returns an empty object.
### Reports
#### Fetching a user's reports:
GET /api/v1/reports
Returns a list of [Reports](#report) made by the authenticated user.
#### Reporting a user:
POST /api/v1/reports
Form data:
- `account_id`: The ID of the account to report
- `status_ids`: The IDs of statuses to report (can be an array)
- `comment`: A comment to associate with the report.
Returns the finished [Report](#report).
### Search
#### Searching for content:
GET /api/v1/search
Form data:
- `q`: The search query
- `resolve`: Whether to resolve non-local accounts
Returns [Results](#results).
If `q` is a URL, Mastodon will attempt to fetch the provided account or status.
Otherwise, it will do a local account and hashtag search.
### Statuses
#### Fetching a status:
GET /api/v1/statuses/:id
Returns a [Status](#status).
#### Getting status context:
GET /api/v1/statuses/:id/contexts
Returns a [Context](#context).
#### Getting a card associated with a status:
GET /api/v1/statuses/:id/card
Returns a [Card](#card).
#### Getting who reblogged/favourited a status:
GET /api/v1/statuses/:id/reblogged_by
GET /api/v1/statuses/:id/favourited_by
Returns an array of [Accounts](#account).
#### Posting a new status:
POST /api/v1/statuses
Form data:
- `status`: The text of the status
- `in_reply_to_id` (optional): local ID of the status you want to reply to
- `media_ids` (optional): array of media IDs to attach to the status (maximum 4)
- `sensitive` (optional): set this to mark the media of the status as NSFW
- `spoiler_text` (optional): text to be shown as a warning before the actual content
- `visibility` (optional): either "direct", "private", "unlisted" or "public"
Returns the new [Status](#status).
#### Deleting a status:
DELETE /api/v1/statuses/:id
Returns an empty object.
#### Reblogging/unreblogging a status:
POST /api/vi/statuses/:id/reblog
POST /api/vi/statuses/:id/unreblog
Returns the target [Status](#status).
#### Favouriting/unfavouriting a status:
POST /api/vi/statuses/:id/favourite
POST /api/vi/statuses/:id/unfavourite
Returns the target [Status](#status).
### Timelines
#### Retrieving a timeline:
GET /api/v1/timelines/home
GET /api/v1/timelines/public
GET /api/v1/timelines/tag/:hashtag
Query parameters:
- `local` (optional; public and tag timelines only): Only return statuses originating from this instance
Returns an array of [Statuses](#status), most recent ones first.
___
## Entities
### Status
| Attribute | Description |
|---------------------|-------------|
| `id` ||
| `uri` | fediverse-unique resource ID |
| `url` | URL to the status page (can be remote) |
| `account` | Account |
| `in_reply_to_id` | null or ID of status it replies to |
| `reblog` | null or Status|
| `content` | Body of the status. This will contain HTML (remote HTML already sanitized) |
| `created_at` ||
| `reblogs_count` ||
| `favourites_count` ||
| `reblogged` | Boolean for authenticated user |
| `favourited` | Boolean for authenticated user |
| `sensitive` | Boolean, true if media attachments should be hidden by default |
| `spoiler_text` | If not empty, warning text that should be displayed before the actual content |
| `visibility` | Either `public`, `unlisted` or `private` |
| `media_attachments` | array of MediaAttachments |
| `mentions` | array of Mentions |
| `application` | Application from which the status was posted |
Media Attachment:
| Attribute | Description |
|---------------------|-------------|
| `url` | URL of the original image (can be remote) |
| `preview_url` | URL of the preview image |
| `type` | Image or video |
Mention:
| Attribute | Description |
|---------------------|-------------|
| `url` | URL of user's profile (can be remote) |
| `acct` | Username for local or username@domain for remote users |
| `id` | Account ID |
Application:
| Attribute | Description |
|---------------------|-------------|
| `name` | Name of the app |
| `website` | Homepage URL of the app |
### Account
| Attribute | Description |
|-------------------|-------------|
| `id` ||
| `username` ||
| `acct` | Equals username for local users, includes @domain for remote ones |
| `display_name` ||
| `note` | Biography of user |
| `url` | URL of the user's profile page (can be remote) |
| `avatar` | URL to the avatar image |
| `header` | URL to the header image |
| `locked` | Boolean for when the account cannot be followed without waiting for approval first |
| `followers_count` ||
| `following_count` ||
| `statuses_count` ||
| Attribute | Description |
| ------------------------ | ----------- |
| `id` | The ID of the account |
| `username` | The username of the account |
| `acct` | Equals `username` for local users, includes `@domain` for remote ones |
| `display_name` | The account's display name |
| `note` | Biography of user |
| `url` | URL of the user's profile page (can be remote) |
| `avatar` | URL to the avatar image |
| `header` | URL to the header image |
| `locked` | Boolean for when the account cannot be followed without waiting for approval first |
| `created_at` | The time the account was created |
| `followers_count` | The number of followers for the account |
| `following_count` | The number of accounts the given account is following |
| `statuses_count` | The number of statuses the account has made |
## Pagination
### Application
API methods that return collections of items can return a `Link` header containing URLs for the `next` and `prev` pages. [Link header RFC](https://tools.ietf.org/html/rfc5988)
| Attribute | Description |
| ------------------------ | ----------- |
| `name` | Name of the app |
| `website` | Homepage URL of the app |
### Attachment
| Attribute | Description |
| ------------------------ | ----------- |
| `id` | ID of the attachment |
| `type` | One of: "image", "video", "gifv" |
| `url` | URL of the locally hosted version of the image |
| `remote_url` | For remote images, the remote URL of the original image |
| `preview_url` | URL of the preview image |
| `text_url` | Shorter URL for the image, for insertion into text (only present on local images) |
### Card
| Attribute | Description |
| ------------------------ | ----------- |
| `url` | The url associated with the card |
| `title` | The title of the card |
| `description` | The card description |
| `image` | The image associated with the card, if any |
### Context
| Attribute | Description |
| ------------------------ | ----------- |
| `ancestors` | The ancestors of the status in the conversation, as a list of [Statuses](#status) |
| `descendants` | The descendants of the status in the conversation, as a list of [Statuses](#status) |
### Error
| Attribute | Description |
| ------------------------ | ----------- |
| `error` | A textual description of the error |
### Instance
| Attribute | Description |
| ------------------------ | ----------- |
| `uri` | URI of the current instance |
| `title` | The instance's title |
| `description` | A description for the instance |
| `email` | An email address which can be used to contact the instance administrator |
### Mention
| Attribute | Description |
| ------------------------ | ----------- |
| `url` | URL of user's profile (can be remote) |
| `username` | The username of the account |
| `acct` | Equals `username` for local users, includes `@domain` for remote ones |
| `id` | Account ID |
### Notifications
| Attribute | Description |
| ------------------------ | ----------- |
| `id` | The notification ID |
| `type` | One of: "mention", "reblog", "favourite", "follow" |
| `created_at` | The time the notification was created |
| `account` | The [Account](#account) sending the notification to the user |
| `status` | The [Status](#status) associated with the notification, if applicible |
### Relationships
| Attribute | Description |
| ------------------------ | ----------- |
| `following` | Whether the user is currently following the account |
| `followed_by` | Whether the user is currently being followed by the account |
| `blocking` | Whether the user is currently blocking the account |
| `muting` | Whether the user is currently muting the account |
| `requested` | Whether the user has requested to follow the account |
### Report
| Attribute | Description |
| ------------------------ | ----------- |
| `id` | The ID of the report |
| `action_taken` | The action taken in response to the report |
### Results
| Attribute | Description |
| ------------------------ | ----------- |
| `accounts` | An array of matched [Accounts](#account) |
| `statuses` | An array of matchhed [Statuses](#status) |
| `hashtags` | An array of matched hashtags, as strings |
### Status
| Attribute | Description |
| ------------------------ | ----------- |
| `id` | The ID of the status |
| `uri` | A Fediverse-unique resource ID |
| `url` | URL to the status page (can be remote) |
| `account` | The [Account](#account) which posted the status |
| `in_reply_to_id` | `null` or the ID of the status it replies to |
| `in_reply_to_account_id` | `null` or the ID of the account it replies to |
| `reblog` | `null` or the reblogged [Status](#status) |
| `content` | Body of the status; this will contain HTML (remote HTML already sanitized) |
| `created_at` | The time the status was created |
| `reblogs_count` | The number of reblogs for the status |
| `favourites_count` | The number of favourites for the status |
| `reblogged` | Whether the authenticated user has reblogged the status |
| `favourited` | Whether the authenticated user has favourited the status |
| `sensitive` | Whether media attachments should be hidden by default |
| `spoiler_text` | If not empty, warning text that should be displayed before the actual content |
| `visibility` | One of: `public`, `unlisted`, `private`, `direct` |
| `media_attachments` | An array of [Attachments](#attachment) |
| `mentions` | An array of [Mentions](#mention) |
| `tags` | An array of [Tags](#tag) |
| `application` | [Application](#application) from which the status was posted |
### Tags
| Attribute | Description |
| ------------------------ | ----------- |
| `name` | The hashtag, not including the preceding `#` |
| `url` | The URL of the hashtag |

View File

@ -62,4 +62,23 @@ namespace :mastodon do
end
end
end
namespace :maintenance do
desc 'Update counter caches'
task update_counter_caches: :environment do
Rails.logger.debug 'Updating counter caches for accounts...'
Account.unscoped.select('id').find_in_batches do |batch|
Account.where(id: batch.map(&:id)).update_all('statuses_count = (select count(*) from statuses where account_id = accounts.id), followers_count = (select count(*) from follows where target_account_id = accounts.id), following_count = (select count(*) from follows where account_id = accounts.id)')
end
Rails.logger.debug 'Updating counter caches for statuses...'
Status.unscoped.select('id').find_in_batches do |batch|
Status.where(id: batch.map(&:id)).update_all('favourites_count = (select count(*) from favourites where favourites.status_id = statuses.id), reblogs_count = (select count(*) from statuses as reblogs where reblogs.reblog_of_id = statuses.id)')
end
Rails.logger.debug 'Done!'
end
end
end

View File

@ -0,0 +1,2 @@
Fabricator(:import) do
end

View File

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe Import, type: :model do
end

View File

@ -14,7 +14,7 @@ RSpec.describe BlockDomainService do
bad_status2
bad_attachment
subject.call('evil.org', :suspend)
subject.call(DomainBlock.create!(domain: 'evil.org', severity: :suspend))
end
it 'creates a domain block' do

View File

@ -215,8 +215,11 @@ const streamHttpEnd = req => (id, listener) => {
// Setup stream output to WebSockets
const streamToWs = (req, ws) => {
const heartbeat = setInterval(() => ws.ping(), 15000)
ws.on('close', () => {
log.verbose(req.requestId, `Ending stream for ${req.accountId}`)
clearInterval(heartbeat)
})
return (event, payload) => {
@ -234,6 +237,10 @@ const streamWsEnd = ws => (id, listener) => {
ws.on('close', () => {
unsubscribe(id, listener)
})
ws.on('error', e => {
unsubscribe(id, listener)
})
}
app.use(setRequestId)