diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4210d18671..bfc771ab98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ Below are the guidelines for working on pull requests: ## General -- 2 spaces indendation +- 2 spaces indentation ## Documentation diff --git a/Dockerfile b/Dockerfile index 1f95f4f497..bcc911343c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,31 @@ -FROM ruby:2.3.1 +FROM ruby:2.3.1-alpine -ENV RAILS_ENV=production -ENV NODE_ENV=production - -RUN echo 'deb http://httpredir.debian.org/debian jessie-backports main contrib non-free' >> /etc/apt/sources.list -RUN curl -sL https://deb.nodesource.com/setup_4.x | bash - -RUN apt-get update -qq && apt-get install -y build-essential libpq-dev libxml2-dev libxslt1-dev nodejs ffmpeg && rm -rf /var/lib/apt/lists/* -RUN npm install -g npm@3 && npm install -g yarn -RUN mkdir /mastodon +ENV RAILS_ENV=production \ + NODE_ENV=production WORKDIR /mastodon -ADD Gemfile /mastodon/Gemfile -ADD Gemfile.lock /mastodon/Gemfile.lock -RUN bundle install --deployment --without test development +COPY . /mastodon -ADD package.json /mastodon/package.json -ADD yarn.lock /mastodon/yarn.lock -RUN yarn +RUN BUILD_DEPS=" \ + postgresql-dev \ + libxml2-dev \ + libxslt-dev \ + build-base" \ + && apk -U upgrade && apk add \ + $BUILD_DEPS \ + nodejs \ + libpq \ + libxml2 \ + libxslt \ + ffmpeg \ + file \ + imagemagick \ + && npm install -g npm@3 && npm install -g yarn \ + && bundle install --deployment --without test development \ + && yarn \ + && npm cache clean \ + && apk del $BUILD_DEPS \ + && rm -rf /tmp/* /var/cache/apk/* -ADD . /mastodon - -VOLUME ["/mastodon/public/system", "/mastodon/public/assets"] +VOLUME /mastodon/public/system /mastodon/public/assets diff --git a/Gemfile b/Gemfile index 440f2e87b7..46baed3079 100644 --- a/Gemfile +++ b/Gemfile @@ -50,6 +50,8 @@ gem 'rails-settings-cached' gem 'simple-navigation' gem 'statsd-instrument' gem 'ruby-oembed', require: 'oembed' +gem 'rack-timeout' +gem 'tzinfo-data' gem 'react-rails' gem 'browserify-rails' @@ -89,5 +91,4 @@ group :production do gem 'rails_12factor' gem 'redis-rails' gem 'lograge' - gem 'rack-timeout' end diff --git a/Gemfile.lock b/Gemfile.lock index 3ad5353793..6e3115249d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/README.md b/README.md index 592a4ed73c..20499e6e3b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/assets/images/fluffy-elephant-friend.png b/app/assets/images/fluffy-elephant-friend.png index 11787e9360..f0df299278 100644 Binary files a/app/assets/images/fluffy-elephant-friend.png and b/app/assets/images/fluffy-elephant-friend.png differ diff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx index 05fa8e68d0..37ebb99690 100644 --- a/app/assets/javascripts/components/actions/accounts.jsx +++ b/app/assets/javascripts/components/actions/accounts.jsx @@ -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)); diff --git a/app/assets/javascripts/components/actions/modal.jsx b/app/assets/javascripts/components/actions/modal.jsx index d19218c485..615cd6bfe8 100644 --- a/app/assets/javascripts/components/actions/modal.jsx +++ b/app/assets/javascripts/components/actions/modal.jsx @@ -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 - }; -}; diff --git a/app/assets/javascripts/components/actions/search.jsx b/app/assets/javascripts/components/actions/search.jsx index e4af716eef..df3ae0db1a 100644 --- a/app/assets/javascripts/components/actions/search.jsx +++ b/app/assets/javascripts/components/actions/search.jsx @@ -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 }; }; diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx index 3e2d4ff431..6cd1f04b31 100644 --- a/app/assets/javascripts/components/actions/timelines.jsx +++ b/app/assets/javascripts/components/actions/timelines.jsx @@ -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 + }; +}; diff --git a/app/assets/javascripts/components/components/lightbox.jsx b/app/assets/javascripts/components/components/lightbox.jsx deleted file mode 100644 index f04ca47bae..0000000000 --- a/app/assets/javascripts/components/components/lightbox.jsx +++ /dev/null @@ -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 ( - - {({ backgroundOpacity, opacity, y }) => -
-
- - {children} -
-
- } -
- ); - } - -}); - -export default injectIntl(Lightbox); diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx index 234cd396af..4ebb76ea75 100644 --- a/app/assets/javascripts/components/components/status_action_bar.jsx +++ b/app/assets/javascripts/components/components/status_action_bar.jsx @@ -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 }); } diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx index 92597a2eca..ab21ca9cd7 100644 --- a/app/assets/javascripts/components/components/video_player.jsx +++ b/app/assets/javascripts/components/components/video_player.jsx @@ -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' }; diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index 40fbac5255..cbb7b85bcb 100644 --- a/app/assets/javascripts/components/containers/mastodon.jsx +++ b/app/assets/javascripts/components/containers/mastodon.jsx @@ -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()); } diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx index e7543bc397..fd3fbe4c32 100644 --- a/app/assets/javascripts/components/containers/status_container.jsx +++ b/app/assets/javascripts/components/containers/status_container.jsx @@ -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) { diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx index e1aae3c770..a359963c42 100644 --- a/app/assets/javascripts/components/features/account/components/header.jsx +++ b/app/assets/javascripts/components/features/account/components/header.jsx @@ -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 ( + + {({ radius }) => + + {account.get('acct')} + + } + + ); + } + +}); + const Header = React.createClass({ propTypes: { @@ -68,14 +110,9 @@ const Header = React.createClass({ return (
- -
- -
- - -
+ + @{account.get('acct')} {lockedIcon}
diff --git a/app/assets/javascripts/components/features/community_timeline/index.jsx b/app/assets/javascripts/components/features/community_timeline/index.jsx index 2cfd7b2fe0..0957338cfa 100644 --- a/app/assets/javascripts/components/features/community_timeline/index.jsx +++ b/app/assets/javascripts/components/features/community_timeline/index.jsx @@ -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': diff --git a/app/assets/javascripts/components/features/compose/components/drawer.jsx b/app/assets/javascripts/components/features/compose/components/drawer.jsx deleted file mode 100644 index ab67c86ea5..0000000000 --- a/app/assets/javascripts/components/features/compose/components/drawer.jsx +++ /dev/null @@ -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 = ( -
- - - - - -
- ); - } - - return ( -
- {header} - -
- {children} -
-
- ); -}; - -Drawer.propTypes = { - withHeader: React.PropTypes.bool, - children: React.PropTypes.node, - intl: React.PropTypes.object -}; - -export default injectIntl(Drawer); diff --git a/app/assets/javascripts/components/features/compose/components/search.jsx b/app/assets/javascripts/components/features/compose/components/search.jsx index a0e8f82fbc..936e003f22 100644 --- a/app/assets/javascripts/components/features/compose/components/search.jsx +++ b/app/assets/javascripts/components/features/compose/components/search.jsx @@ -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 ; - } else if (suggestion.type === 'hashtag') { - return #{suggestion.id}; - } else { - return ; - } -}; - -const renderSectionTitle = section => ( - -); - -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 ( -
- + -
+
+ + +
); - }, + } }); diff --git a/app/assets/javascripts/components/features/compose/components/search_results.jsx b/app/assets/javascripts/components/features/compose/components/search_results.jsx new file mode 100644 index 0000000000..fd05e7f7e4 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/search_results.jsx @@ -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 = ( +
+ {results.get('accounts').map(accountId => )} +
+ ); + } + + if (results.get('statuses') && results.get('statuses').size > 0) { + count += results.get('statuses').size; + statuses = ( +
+ {results.get('statuses').map(statusId => )} +
+ ); + } + + if (results.get('hashtags') && results.get('hashtags').size > 0) { + count += results.get('hashtags').size; + hashtags = ( +
+ {results.get('hashtags').map(hashtag => + + #{hashtag} + + )} +
+ ); + } + + return ( +
+
+ +
+ + {accounts} + {statuses} + {hashtags} +
+ ); + } + +}); + +export default SearchResults; diff --git a/app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx b/app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx deleted file mode 100644 index 97cc9487e7..0000000000 --- a/app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx +++ /dev/null @@ -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 ( - - - - ); - } - -}); - -export default SensitiveToggle; diff --git a/app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx b/app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx deleted file mode 100644 index 1c59e43935..0000000000 --- a/app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx +++ /dev/null @@ -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 ( - - ); - } - -}); - -export default SpoilerToggle; diff --git a/app/assets/javascripts/components/features/compose/containers/search_container.jsx b/app/assets/javascripts/components/features/compose/containers/search_container.jsx index 17a68f2fc8..906c0c28c4 100644 --- a/app/assets/javascripts/components/features/compose/containers/search_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/search_container.jsx @@ -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()); } }); diff --git a/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx b/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx new file mode 100644 index 0000000000..e5911fd388 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx @@ -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); diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx index 15e2c58094..9421de3ff5 100644 --- a/app/assets/javascripts/components/features/compose/index.jsx +++ b/app/assets/javascripts/components/features/compose/index.jsx @@ -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 = ( +
+ + + + + +
+ ); + } + return ( - +
+ {header} + - - - + +
+
+ + +
+ + + {({ x }) => +
+ +
+ } +
+
+
); } }); -export default connect()(Compose); +export default connect(mapStateToProps)(injectIntl(Compose)); diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx index 6f9e988ba1..d7a78d9cc2 100644 --- a/app/assets/javascripts/components/features/getting_started/index.jsx +++ b/app/assets/javascripts/components/features/getting_started/index.jsx @@ -43,9 +43,7 @@ const GettingStarted = ({ intl, me }) => {
-

-

-

tootsuite/mastodon, apps: }} />

+

tootsuite/mastodon, apps: }} />

diff --git a/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx index 3317210bf0..92e700874b 100644 --- a/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx +++ b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx @@ -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({
- } /> + } />
diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx index b2342abbdd..6d766a83b2 100644 --- a/app/assets/javascripts/components/features/public_timeline/index.jsx +++ b/app/assets/javascripts/components/features/public_timeline/index.jsx @@ -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': diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx index 6a7635cc66..f98fe1b013 100644 --- a/app/assets/javascripts/components/features/status/index.jsx +++ b/app/assets/javascripts/components/features/status/index.jsx @@ -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) { diff --git a/app/assets/javascripts/components/features/ui/components/media_modal.jsx b/app/assets/javascripts/components/features/ui/components/media_modal.jsx new file mode 100644 index 0000000000..35eb2cb0cd --- /dev/null +++ b/app/assets/javascripts/components/features/ui/components/media_modal.jsx @@ -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 =
; + rightNav =
; + } + + if (attachment.get('type') === 'image') { + content = ; + } else if (attachment.get('type') === 'gifv') { + content = ; + } + + return ( +
+ {leftNav} + +
+ + {content} +
+ + {rightNav} +
+ ); + } + +}); + +export default injectIntl(MediaModal); diff --git a/app/assets/javascripts/components/features/ui/components/modal_root.jsx b/app/assets/javascripts/components/features/ui/components/modal_root.jsx new file mode 100644 index 0000000000..d2ae5e145e --- /dev/null +++ b/app/assets/javascripts/components/features/ui/components/modal_root.jsx @@ -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 ( + + {interpolatedStyles => +
+ {interpolatedStyles.map(({ key, data: { type, props }, style }) => { + const SpecificComponent = MODAL_COMPONENTS[type]; + + return ( +
+
+
+ +
+
+ ); + })} +
+ } + + ); + } + +}); + +export default ModalRoot; diff --git a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx index 225a6a5fc8..6cdb29dbff 100644 --- a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx +++ b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx @@ -1,15 +1,23 @@ import { Link } from 'react-router'; import { FormattedMessage } from 'react-intl'; -const TabsBar = () => { - return ( -
- - - - -
- ); -}; +const TabsBar = React.createClass({ + + render () { + return ( +
+ + + + + + + + +
+ ); + } + +}); export default TabsBar; diff --git a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx index e3c4281b93..26d77818c2 100644 --- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx @@ -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 = () => ( -
- -
-); - -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 =
; - rightNav =
; - } - - if (attachment.get('type') === 'image') { - content = ( - - ); - } else if (attachment.get('type') === 'gifv') { - content = ; - } - - return ( - - {leftNav} - {content} - {rightNav} - - ); - } - -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Modal); +export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot); diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx index b7e8f32a44..89fb82568f 100644 --- a/app/assets/javascripts/components/features/ui/index.jsx +++ b/app/assets/javascripts/components/features/ui/index.jsx @@ -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 ( -
+
{mountedColumns} diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx index 5af44ea4bf..53e2898eb6 100644 --- a/app/assets/javascripts/components/locales/en.jsx +++ b/app/assets/javascripts/components/locales/en.jsx @@ -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", diff --git a/app/assets/javascripts/components/locales/fi.jsx b/app/assets/javascripts/components/locales/fi.jsx new file mode 100644 index 0000000000..5bef999231 --- /dev/null +++ b/app/assets/javascripts/components/locales/fi.jsx @@ -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; diff --git a/app/assets/javascripts/components/locales/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx index 2f5dd182f9..23fa9349c5 100644 --- a/app/assets/javascripts/components/locales/fr.jsx +++ b/app/assets/javascripts/components/locales/fr.jsx @@ -1,68 +1,91 @@ const fr = { - "account.block": "Bloquer", - "account.edit_profile": "Modifier le profil", - "account.followers": "Abonnés", - "account.follows": "Abonnements", - "account.follow": "Suivre", - "account.follows_you": "Vous suit", - "account.mention": "Mentionner", - "account.posts": "Statuts", - "account.requested": "Invitation envoyée", - "account.unblock": "Débloquer", - "account.unfollow": "Ne plus suivre", "column_back_button.label": "Retour", - "column.home": "Accueil", - "column.mentions": "Mentions", - "column.notifications": "Notifications", - "column.public": "Fil public", - "compose_form.placeholder": "Qu’avez-vous en tête ?", - "compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ?", - "compose_form.private": "Rendre privé", - "compose_form.publish": "Pouet ", - "compose_form.sensitive": "Marquer le média comme délicat", - "compose_form.spoiler": "Masque le texte par un avertissement", - "compose_form.unlisted": "Ne pas afficher dans le fil public", - "getting_started.about_addressing": "Vous pouvez vous suivre les statuts de quelqu’un en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.", - "getting_started.about_developer": "Pour suivre le développeur de ce projet, c’est Gargron@mastodon.social", - "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, l’identifiant suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.", - "getting_started.heading": "Pour commencer", - "getting_started.open_source_notice": "Mastodon est un logiciel libre. Vous pouvez contribuer et envoyer vos commentaires et rapports de bogues via {github} sur GitHub.", "lightbox.close": "Fermer", "loading_indicator.label": "Chargement…", - "navigation_bar.edit_profile": "Modifier le profil", - "navigation_bar.logout": "Déconnexion", - "navigation_bar.preferences": "Préférences", - "navigation_bar.public_timeline": "Fil public", - "notification.favourite": "{name} a ajouté à ses favoris :", - "notification.follow": "{name} vous suit.", - "notification.mention": "{name} vous a mentionné⋅e :", - "notification.reblog": "{name} a partagé votre statut :", - "notifications.column_settings.alert": "Notifications locales", - "notifications.column_settings.favourite": "Favoris :", - "notifications.column_settings.follow": "Nouveaux abonnés :", - "notifications.column_settings.mention": "Mentions :", - "notifications.column_settings.reblog": "Partages :", - "notifications.column_settings.show": "Afficher dans la colonne", - "reply_indicator.cancel": "Annuler", - "search.account": "Compte", - "search.hashtag": "Mot-clé", - "search.placeholder": "Chercher", - "status.delete": "Effacer", - "status.favourite": "Ajouter aux favoris", "status.mention": "Mentionner", - "status.reblogged_by": "{name} a partagé :", - "status.reblog": "Partager", + "status.delete": "Effacer", "status.reply": "Répondre", - "status.sensitive_toggle": "Cliquer pour dévoiler", + "status.reblog": "Partager", + "status.favourite": "Ajouter aux favoris", + "status.reblogged_by": "{name} a partagé :", "status.sensitive_warning": "Contenu délicat", + "status.sensitive_toggle": "Cliquer pour dévoiler", + "video_player.toggle_sound": "Mettre/Couper le son", + "account.mention": "Mentionner", + "account.edit_profile": "Modifier le profil", + "account.unblock": "Débloquer", + "account.unfollow": "Ne plus suivre", + "account.block": "Bloquer", + "account.mute": "Masquer", + "account.unmute": "Ne plus masquer", + "account.follow": "Suivre", + "account.posts": "Statuts", + "account.follows": "Abonnements", + "account.followers": "Abonnés", + "account.follows_you": "Vous suit", + "account.requested": "Invitation envoyée", + "account.report": "Signaler", + "account.disclaimer": "Ce compte est situé sur une autre instance. Les nombres peuvent être plus grands.", + "getting_started.heading": "Pour commencer", + "getting_started.about_addressing": "Vous pouvez suivre les statuts de quelqu’un en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.", + "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, l’identifiant suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.", + "getting_started.about_developer": "Pour suivre le développeur de ce projet, c’est Gargron@mastodon.social", + "getting_started.open_source_notice": "Mastodon est un logiciel libre. Vous pouvez contribuer et envoyer vos commentaires et rapports de bogues via {github} sur GitHub.", + "column.home": "Accueil", + "column.community": "Fil public local", + "column.public": "Fil public global", + "column.notifications": "Notifications", + "column.public": "Fil public", + "column.blocks": "Utilisateurs bloqués", + "column.favourites": "Favoris", "tabs_bar.compose": "Composer", "tabs_bar.home": "Accueil", "tabs_bar.mentions": "Mentions", + "tabs_bar.public": "Fil public global", "tabs_bar.notifications": "Notifications", - "tabs_bar.public": "Public", + "compose_form.placeholder": "Qu’avez-vous en tête ?", + "compose_form.publish": "Pouet ", + "compose_form.sensitive": "Marquer le média comme délicat", + "compose_form.spoiler": "Masquer le texte par un avertissement", + "compose_form.private": "Rendre privé", + "compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ? Les statuts privés ne fonctionnent que sur les instances de Mastodons. Si {domains} {domainsCount, plural, one {n'est pas une instance de Mastodon} other {ne sont pas des instances de Mastodon}}, il n'y aura aucune indication que votre statut est privé, et il pourrait être partagé ou rendu visible d'une autre manière à d'autres personnes imprévues", + "compose_form.unlisted": "Ne pas afficher dans les fils publics", + "emoji_button.label": "Insérer un emoji", + "navigation_bar.edit_profile": "Modifier le profil", + "navigation_bar.preferences": "Préférences", + "navigation_bar.community_timeline": "Fil public local", + "navigation_bar.public_timeline": "Fil public global", + "navigation_bar.blocks": "Utilisateurs bloqués", + "navigation_bar.favourites": "Favoris", + "navigation_bar.info": "Plus d'informations", + "notification.favourite": "{name} a ajouté à ses favoris :", + "navigation_bar.logout": "Déconnexion", + "reply_indicator.cancel": "Annuler", + "search.placeholder": "Chercher", + "search.account": "Compte", + "search.hashtag": "Mot-clé", + "search_results.total": "{count} {count, plural, one {résultat} other {résultats}}", "upload_button.label": "Joindre un média", "upload_form.undo": "Annuler", - "video_player.toggle_sound": "Mettre/Couper le son", + "notification.follow": "{name} vous suit.", + "notification.favourite": "{name} a ajouté à ses favoris :", + "notification.reblog": "{name} a partagé votre statut :", + "notification.mention": "{name} vous a mentionné⋅e :", + "notifications.column_settings.alert": "Notifications locales", + "notifications.column_settings.show": "Afficher dans la colonne", + "notifications.column_settings.follow": "Nouveaux abonnés :", + "notifications.column_settings.favourite": "Favoris :", + "notifications.column_settings.mention": "Mentions :", + "notifications.column_settings.reblog": "Partages :", + "privacy.public.short": "Public", + "privacy.public.long": "Afficher dans les fils publics", + "privacy.unlisted.short": "Non-listé", + "privacy.unlisted.long": "Ne pas afficher dans les fils publics", + "privacy.private.short": "Privé", + "privacy.private.long": "N’afficher que pour vos abonné⋅e⋅s", + "privacy.direct.short": "Direct", + "privacy.direct.long": "N’afficher que pour les personnes mentionné⋅e⋅s", + "privacy.change": "Ajuster la confidentialité du message", }; export default fr; diff --git a/app/assets/javascripts/components/locales/index.jsx b/app/assets/javascripts/components/locales/index.jsx index 203929d66b..72b8a5df5d 100644 --- a/app/assets/javascripts/components/locales/index.jsx +++ b/app/assets/javascripts/components/locales/index.jsx @@ -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) { diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx index 6ce41670d6..df94400939 100644 --- a/app/assets/javascripts/components/reducers/accounts.jsx +++ b/app/assets/javascripts/components/reducers/accounts.jsx @@ -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: diff --git a/app/assets/javascripts/components/reducers/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx index 37ffbc62b2..3566820ef1 100644 --- a/app/assets/javascripts/components/reducers/modal.jsx +++ b/app/assets/javascripts/components/reducers/modal.jsx @@ -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; } diff --git a/app/assets/javascripts/components/reducers/relationships.jsx b/app/assets/javascripts/components/reducers/relationships.jsx index 591f8034be..c65c48b431 100644 --- a/app/assets/javascripts/components/reducers/relationships.jsx +++ b/app/assets/javascripts/components/reducers/relationships.jsx @@ -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; } }; diff --git a/app/assets/javascripts/components/reducers/search.jsx b/app/assets/javascripts/components/reducers/search.jsx index e95f9ed79f..b3fe6c7bea 100644 --- a/app/assets/javascripts/components/reducers/search.jsx +++ b/app/assets/javascripts/components/reducers/search.jsx @@ -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; } diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx index 1669b8c65e..ca8fa7a015 100644 --- a/app/assets/javascripts/components/reducers/statuses.jsx +++ b/app/assets/javascripts/components/reducers/statuses.jsx @@ -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); diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx index c67d054235..675a527593 100644 --- a/app/assets/javascripts/components/reducers/timelines.jsx +++ b/app/assets/javascripts/components/reducers/timelines.jsx @@ -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; } diff --git a/app/assets/javascripts/components/selectors/index.jsx b/app/assets/javascripts/components/selectors/index.jsx index 0e88654a16..01a6cb264f 100644 --- a/app/assets/javascripts/components/selectors/index.jsx +++ b/app/assets/javascripts/components/selectors/index.jsx @@ -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) => { diff --git a/app/assets/javascripts/extras.jsx b/app/assets/javascripts/extras.jsx index 5738863ddd..c13feceffe 100644 --- a/app/assets/javascripts/extras.jsx +++ b/app/assets/javascripts/extras.jsx @@ -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); + } + }); }); diff --git a/app/assets/stylesheets/accounts.scss b/app/assets/stylesheets/accounts.scss index 7c48c91f39..25e24a95a6 100644 --- a/app/assets/stylesheets/accounts.scss +++ b/app/assets/stylesheets/accounts.scss @@ -311,6 +311,7 @@ padding: 10px; padding-top: 15px; color: $color3; + word-wrap: break-word; } } } diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index 04d37546c8..d233b3471a 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -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; + } +} diff --git a/app/assets/stylesheets/stream_entries.scss b/app/assets/stylesheets/stream_entries.scss index b9a9a1da3e..4a6dc6aa44 100644 --- a/app/assets/stylesheets/stream_entries.scss +++ b/app/assets/stylesheets/stream_entries.scss @@ -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 { diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 491036db28..abf4b7df4c 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -5,6 +5,9 @@ class AboutController < ApplicationController def index @description = Setting.site_description + + @user = User.new + @user.build_account end def more diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index e362957e73..1f44328478 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -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 diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index 67d57e4eb5..2b3b1809fd 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -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 diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb index ca9dd0b7ee..2ec7280af0 100644 --- a/app/controllers/api/v1/apps_controller.rb +++ b/app/controllers/api/v1/apps_controller.rb @@ -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 diff --git a/app/controllers/api/v1/follows_controller.rb b/app/controllers/api/v1/follows_controller.rb index c22dacbaab..7c0f44f038 100644 --- a/app/controllers/api/v1/follows_controller.rb +++ b/app/controllers/api/v1/follows_controller.rb @@ -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 diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index f8139ade77..aed3578d7e 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -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 diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 46bdddbc14..f83c573cb5 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -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 diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 024258c0e1..4ece7e7028 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -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 diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb index af6e5b7df2..0446b9e4dd 100644 --- a/app/controllers/api/v1/timelines_controller.rb +++ b/app/controllers/api/v1/timelines_controller.rb @@ -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? diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ef9364897c..c06142fd43 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index feaad04f6b..7c25266d81 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -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 diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb new file mode 100644 index 0000000000..cbb5e65da5 --- /dev/null +++ b/app/controllers/settings/imports_controller.rb @@ -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 diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 74215e8df4..e01f7d0cc4 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -10,6 +10,7 @@ module SettingsHelper hu: 'Magyar', uk: 'Українська', 'zh-CN': '简体中文', + fi: 'Suomi', }.freeze def human_locale(locale) diff --git a/app/lib/exceptions.rb b/app/lib/exceptions.rb index 200da9fe16..9bc802c120 100644 --- a/app/lib/exceptions.rb +++ b/app/lib/exceptions.rb @@ -4,4 +4,5 @@ module Mastodon class Error < StandardError; end class NotPermittedError < Error; end class ValidationError < Error; end + class RaceConditionError < Error; end end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index b0dda12564..cd6ca12918 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -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 diff --git a/app/models/feed.rb b/app/models/feed.rb index 5e1905e152..3cbc160a06 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -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 diff --git a/app/models/import.rb b/app/models/import.rb new file mode 100644 index 0000000000..5384986d81 --- /dev/null +++ b/app/models/import.rb @@ -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 diff --git a/app/models/report.rb b/app/models/report.rb index 05dc8cff19..fd8e46aacc 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -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) } diff --git a/app/models/status.rb b/app/models/status.rb index 81b26fd145..daf1285720 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -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 diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index 9518b1fcf2..6c131bd341 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -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 diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 0cacfd7cd3..df404cbef1 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -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? diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb index 54d11b631d..e1ec56e8d3 100644 --- a/app/services/precompute_feed_service.rb +++ b/app/services/precompute_feed_service.rb @@ -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 diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 159c037137..e9745010b0 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -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) diff --git a/app/views/about/index.html.haml b/app/views/about/index.html.haml index be5e406c57..fdfb2b916f 100644 --- a/app/views/about/index.html.haml +++ b/app/views/about/index.html.haml @@ -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') } diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml index e35b08317c..0d43fba304 100644 --- a/app/views/accounts/_header.html.haml +++ b/app/views/accounts/_header.html.haml @@ -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 diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index b528e161e4..ba1c3bae7b 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -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 diff --git a/app/views/admin/domain_blocks/index.html.haml b/app/views/admin/domain_blocks/index.html.haml index dbaeb47164..eb7894b86c 100644 --- a/app/views/admin/domain_blocks/index.html.haml +++ b/app/views/admin/domain_blocks/index.html.haml @@ -14,3 +14,4 @@ %td= block.severity = will_paginate @blocks, pagination_options += link_to 'Add new', new_admin_domain_block_path, class: 'button' diff --git a/app/views/admin/domain_blocks/new.html.haml b/app/views/admin/domain_blocks/new.html.haml new file mode 100644 index 0000000000..fbd39d6cfa --- /dev/null +++ b/app/views/admin/domain_blocks/new.html.haml @@ -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 diff --git a/app/views/admin/reports/index.html.haml b/app/views/admin/reports/index.html.haml index 8a5414cef3..839259dc27 100644 --- a/app/views/admin/reports/index.html.haml +++ b/app/views/admin/reports/index.html.haml @@ -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 diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml index 74cac016dd..caa8415dff 100644 --- a/app/views/admin/reports/show.html.haml +++ b/app/views/admin/reports/show.html.haml @@ -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 diff --git a/app/views/api/v1/accounts/show.rabl b/app/views/api/v1/accounts/show.rabl index e21fe79412..32df0457a7 100644 --- a/app/views/api/v1/accounts/show.rabl +++ b/app/views/api/v1/accounts/show.rabl @@ -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 } diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl index f384b6d140..54e8a86d81 100644 --- a/app/views/api/v1/statuses/_show.rabl +++ b/app/views/api/v1/statuses/_show.rabl @@ -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' diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml index 750d6036f3..59fe078dfc 100644 --- a/app/views/layouts/admin.html.haml +++ b/app/views/layouts/admin.html.haml @@ -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' } diff --git a/app/views/settings/imports/show.html.haml b/app/views/settings/imports/show.html.haml new file mode 100644 index 0000000000..8502913dcc --- /dev/null +++ b/app/views/settings/imports/show.html.haml @@ -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 diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index 8c0456b1fe..8495f28b9a 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -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? · diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml index cb2c976ce4..2eb9bf1667 100644 --- a/app/views/stream_entries/_simple_status.html.haml +++ b/app/views/stream_entries/_simple_status.html.haml @@ -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 diff --git a/app/workers/after_remote_follow_request_worker.rb b/app/workers/after_remote_follow_request_worker.rb index f1d6869cc7..1f2db30615 100644 --- a/app/workers/after_remote_follow_request_worker.rb +++ b/app/workers/after_remote_follow_request_worker.rb @@ -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) diff --git a/app/workers/after_remote_follow_worker.rb b/app/workers/after_remote_follow_worker.rb index 0d04456a94..bdd2c2a91d 100644 --- a/app/workers/after_remote_follow_worker.rb +++ b/app/workers/after_remote_follow_worker.rb @@ -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) diff --git a/app/workers/domain_block_worker.rb b/app/workers/domain_block_worker.rb new file mode 100644 index 0000000000..8844778291 --- /dev/null +++ b/app/workers/domain_block_worker.rb @@ -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 diff --git a/app/workers/import_worker.rb b/app/workers/import_worker.rb new file mode 100644 index 0000000000..7cf29fb53c --- /dev/null +++ b/app/workers/import_worker.rb @@ -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 diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb index af3394b8b3..834b0088bd 100644 --- a/app/workers/link_crawl_worker.rb +++ b/app/workers/link_crawl_worker.rb @@ -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)) diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb index 0f288f43fe..d745cb99c7 100644 --- a/app/workers/merge_worker.rb +++ b/app/workers/merge_worker.rb @@ -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 diff --git a/app/workers/notification_worker.rb b/app/workers/notification_worker.rb index 1a2faefd8e..da1d6ab455 100644 --- a/app/workers/notification_worker.rb +++ b/app/workers/notification_worker.rb @@ -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)) diff --git a/app/workers/processing_worker.rb b/app/workers/processing_worker.rb index 5df404bcc9..4a467d9244 100644 --- a/app/workers/processing_worker.rb +++ b/app/workers/processing_worker.rb @@ -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)) diff --git a/app/workers/regeneration_worker.rb b/app/workers/regeneration_worker.rb index 3aece0ba28..82665b581f 100644 --- a/app/workers/regeneration_worker.rb +++ b/app/workers/regeneration_worker.rb @@ -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 diff --git a/app/workers/salmon_worker.rb b/app/workers/salmon_worker.rb index fc95ce47ff..2888b574b6 100644 --- a/app/workers/salmon_worker.rb +++ b/app/workers/salmon_worker.rb @@ -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)) diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb index 593edd032f..38287e8e64 100644 --- a/app/workers/thread_resolve_worker.rb +++ b/app/workers/thread_resolve_worker.rb @@ -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) diff --git a/app/workers/unmerge_worker.rb b/app/workers/unmerge_worker.rb index dbf7243de7..ea6aacebf6 100644 --- a/app/workers/unmerge_worker.rb +++ b/app/workers/unmerge_worker.rb @@ -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 diff --git a/config/application.rb b/config/application.rb index 9d32f30cbb..17b7a19cc9 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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') diff --git a/config/initializers/timeout.rb b/config/initializers/timeout.rb index 06a29492e6..de87fd906c 100644 --- a/config/initializers/timeout.rb +++ b/config/initializers/timeout.rb @@ -1,4 +1,6 @@ +Rack::Timeout::Logger.disable +Rack::Timeout.service_timeout = false + if Rails.env.production? Rack::Timeout.service_timeout = 90 - Rack::Timeout::Logger.disable end diff --git a/config/locales/devise.fi.yml b/config/locales/devise.fi.yml new file mode 100644 index 0000000000..79fe81230c --- /dev/null +++ b/config/locales/devise.fi.yml @@ -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:" diff --git a/config/locales/devise.fr.yml b/config/locales/devise.fr.yml index b64601e7b5..ce44d041aa 100644 --- a/config/locales/devise.fr.yml +++ b/config/locales/devise.fr.yml @@ -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): ' diff --git a/config/locales/devise.no.yml b/config/locales/devise.no.yml new file mode 100644 index 0000000000..8b650e548f --- /dev/null +++ b/config/locales/devise.no.yml @@ -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:" diff --git a/config/locales/doorkeeper.fi.yml b/config/locales/doorkeeper.fi.yml new file mode 100644 index 0000000000..cd1a9d0584 --- /dev/null +++ b/config/locales/doorkeeper.fi.yml @@ -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 diff --git a/config/locales/doorkeeper.fr.yml b/config/locales/doorkeeper.fr.yml index 6f3c0864ad..c94e5c095b 100644 --- a/config/locales/doorkeeper.fr.yml +++ b/config/locales/doorkeeper.fr.yml @@ -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. diff --git a/config/locales/doorkeeper.no.yml b/config/locales/doorkeeper.no.yml new file mode 100644 index 0000000000..f149f53e01 --- /dev/null +++ b/config/locales/doorkeeper.no.yml @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index 3e130aaf84..157f107a59 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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: %{name} is a user on %{domain}. You can follow them or interact with them if you have an account anywhere in the fediverse. If you don't, you can sign up here. 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 diff --git a/config/locales/fi.yml b/config/locales/fi.yml new file mode 100644 index 0000000000..3bcfe5c205 --- /dev/null +++ b/config/locales/fi.yml @@ -0,0 +1,164 @@ +--- +fi: + about: + about_mastodon: Mastodon on ilmainen, avoimeen lähdekoodiin perustuva sosiaalinen verkosto. Hajautettu vaihtoehto kaupallisille alustoille, se välttää eiskit yhden yrityksen monopolisoinnin sinun viestinnässäsi. Valitse palvelin mihin luotat — minkä tahansa valitset, voit vuorovaikuttaa muiden kanssa. Kuka tahansa voi luoda Mastodon palvelimen ja ottaa osaa sosiaaliseen verkkoon 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ä (%{self}) 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: %{name} on käyttäjä domainilla %{domain}. Voit seurata tai vuorovaikuttaa heidän kanssaan jos sinulla on tili yleisessä verkossa. Jos sinulla ei ole tiliä, voit rekisteröityä täällä. + 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 kaksivaiheisen tunnistuksen, kirjautumiseen vaaditaan puhelin, joka voi generoida tokeneita kirjautumista varten. + disable: Poista käytöstä + enable: Ota käyttöön + instructions_html: "Skannaa tämä QR-koodi Google Authenticator tai samanlaiseen sovellukseen puhelimellasi. Tästä hetkestä lähtien, ohjelma generoi tokenit mikä sinun tarvitsee syöttää sisäänkirjautuessa." + plaintext_secret_html: 'Plain-text secret: %{secret}' + 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: "…" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 173e8d16cf..7585014034 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -2,29 +2,65 @@ fr: about: about_mastodon: Mastodon est un serveur libre de réseautage social. Alternative décentralisée 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 réseau social 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 d’utilisation + 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 n’avez 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: S’inscrire 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 (%{self}) 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 l’erreur 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: %{name} utilise %{domain}. 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 en créer un ici. 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 l'identification à deux facteurs 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: "Scannez ce QR code grâce à Google Authenticator, Authy ou une application similaire sur votre téléphone. Désormais, cette application générera des jetons que vous devrez saisir à chaque connexion." + plaintext_secret_html: 'Code secret en clair: %{secret}' + 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: "…" diff --git a/config/locales/no.yml b/config/locales/no.yml new file mode 100644 index 0000000000..b9a752d5af --- /dev/null +++ b/config/locales/no.yml @@ -0,0 +1,164 @@ +--- +'no': + about: + about_mastodon: Mastodon er et gratis, åpen kildekode sosialt nettverk. Et desentralisert 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å — 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 (%{self}) 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: %{name} er en bruker på %{domain}. 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 registrere deg her. + 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å tofaktor autentisering 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: "Scan denne QR-koden i Google Authenticator eller en lignende app på telefonen din. Fra nå av så vil denne applikasjonen generere koder for deg som skal brukes under innlogging" + plaintext_secret_html: 'Plain-text secret: %{secret}' + 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: "…" diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index c4bd0ad96e..df4f6ca007 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -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 diff --git a/config/locales/simple_form.fi.yml b/config/locales/simple_form.fi.yml new file mode 100644 index 0000000000..02c11752f9 --- /dev/null +++ b/config/locales/simple_form.fi.yml @@ -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ä' diff --git a/config/locales/simple_form.fr.yml b/config/locales/simple_form.fr.yml index 0fcf89140d..fd03734368 100644 --- a/config/locales/simple_form.fr.yml +++ b/config/locales/simple_form.fr.yml @@ -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 d’en-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 quelqu’un ajoute mes statut à ses favoris + digest: Envoyer des emails récapitulatifs + favourite: Envoyer un courriel lorsque quelqu’un ajoute mes statuts à ses favoris follow: Envoyer un courriel lorsque quelqu’un me suit + follow_request: Envoyer un courriel lorsque quelqu'un demande à me suivre mention: Envoyer un courriel lorsque quelqu’un me mentionne reblog: Envoyer un courriel lorsque quelqu’un partage mes statuts 'no': Non diff --git a/config/locales/simple_form.no.yml b/config/locales/simple_form.no.yml new file mode 100644 index 0000000000..7e705b19b1 --- /dev/null +++ b/config/locales/simple_form.no.yml @@ -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' diff --git a/config/navigation.rb b/config/navigation.rb index 607a0ff102..c6b7b97678 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index cf83649681..ca77191f7c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20170330021336_add_counter_caches.rb b/db/migrate/20170330021336_add_counter_caches.rb index eb4e54d0af..cf064b9e1c 100644 --- a/db/migrate/20170330021336_add_counter_caches.rb +++ b/db/migrate/20170330021336_add_counter_caches.rb @@ -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); diff --git a/db/migrate/20170330163835_create_imports.rb b/db/migrate/20170330163835_create_imports.rb new file mode 100644 index 0000000000..d6f74823d7 --- /dev/null +++ b/db/migrate/20170330163835_create_imports.rb @@ -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 diff --git a/db/migrate/20170330164118_add_attachment_data_to_imports.rb b/db/migrate/20170330164118_add_attachment_data_to_imports.rb new file mode 100644 index 0000000000..4850b0663d --- /dev/null +++ b/db/migrate/20170330164118_add_attachment_data_to_imports.rb @@ -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 diff --git a/db/migrate/20170403172249_add_action_taken_by_account_id_to_reports.rb b/db/migrate/20170403172249_add_action_taken_by_account_id_to_reports.rb new file mode 100644 index 0000000000..2d4e121980 --- /dev/null +++ b/db/migrate/20170403172249_add_action_taken_by_account_id_to_reports.rb @@ -0,0 +1,5 @@ +class AddActionTakenByAccountIdToReports < ActiveRecord::Migration[5.0] + def change + add_column :reports, :action_taken_by_account_id, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 52437ca57c..3aaa3e3ad7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index e6002eaa5e..d6ba66ddeb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/README.md b/docs/README.md index d35dece148..abf6fcc4ba 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/Running-Mastodon/Production-guide.md b/docs/Running-Mastodon/Production-guide.md index a6c776f092..469fefa943 100644 --- a/docs/Running-Mastodon/Production-guide.md +++ b/docs/Running-Mastodon/Production-guide.md @@ -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 diff --git a/docs/Using-Mastodon/List-of-Mastodon-instances.md b/docs/Using-Mastodon/List-of-Mastodon-instances.md index ef3c835de4..780977bd47 100644 --- a/docs/Using-Mastodon/List-of-Mastodon-instances.md +++ b/docs/Using-Mastodon/List-of-Mastodon-instances.md @@ -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). diff --git a/docs/Using-Mastodon/User-guide.md b/docs/Using-Mastodon/User-guide.md new file mode 100644 index 0000000000..f78921c6f5 --- /dev/null +++ b/docs/Using-Mastodon/User-guide.md @@ -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. diff --git a/docs/Using-Mastodon/screenshots/compose-cw.png b/docs/Using-Mastodon/screenshots/compose-cw.png new file mode 100644 index 0000000000..584080a532 Binary files /dev/null and b/docs/Using-Mastodon/screenshots/compose-cw.png differ diff --git a/docs/Using-Mastodon/screenshots/compose-media.png b/docs/Using-Mastodon/screenshots/compose-media.png new file mode 100644 index 0000000000..7a63c196c9 Binary files /dev/null and b/docs/Using-Mastodon/screenshots/compose-media.png differ diff --git a/docs/Using-Mastodon/screenshots/compose-nsfw.png b/docs/Using-Mastodon/screenshots/compose-nsfw.png new file mode 100644 index 0000000000..a4ff5ed7b8 Binary files /dev/null and b/docs/Using-Mastodon/screenshots/compose-nsfw.png differ diff --git a/docs/Using-Mastodon/screenshots/compose-privacy.png b/docs/Using-Mastodon/screenshots/compose-privacy.png new file mode 100644 index 0000000000..b18ed2043c Binary files /dev/null and b/docs/Using-Mastodon/screenshots/compose-privacy.png differ diff --git a/docs/Using-Mastodon/screenshots/content-warning.gif b/docs/Using-Mastodon/screenshots/content-warning.gif new file mode 100644 index 0000000000..2e47206181 Binary files /dev/null and b/docs/Using-Mastodon/screenshots/content-warning.gif differ diff --git a/docs/Using-Mastodon/screenshots/cw-toot.gif b/docs/Using-Mastodon/screenshots/cw-toot.gif new file mode 100644 index 0000000000..5329933a64 Binary files /dev/null and b/docs/Using-Mastodon/screenshots/cw-toot.gif differ diff --git a/docs/Using-Mastodon/screenshots/federated-timeline.png b/docs/Using-Mastodon/screenshots/federated-timeline.png new file mode 100644 index 0000000000..d74b089fdd Binary files /dev/null and b/docs/Using-Mastodon/screenshots/federated-timeline.png differ diff --git a/docs/Using-Mastodon/screenshots/follow-icon.png b/docs/Using-Mastodon/screenshots/follow-icon.png new file mode 100644 index 0000000000..ee516c2f5a Binary files /dev/null and b/docs/Using-Mastodon/screenshots/follow-icon.png differ diff --git a/docs/Using-Mastodon/screenshots/following-icon.png b/docs/Using-Mastodon/screenshots/following-icon.png new file mode 100644 index 0000000000..bccdc110e1 Binary files /dev/null and b/docs/Using-Mastodon/screenshots/following-icon.png differ diff --git a/docs/Using-Mastodon/screenshots/locked-icon.png b/docs/Using-Mastodon/screenshots/locked-icon.png new file mode 100644 index 0000000000..d199f1f12d Binary files /dev/null and b/docs/Using-Mastodon/screenshots/locked-icon.png differ diff --git a/docs/Using-Mastodon/screenshots/notifications-clear.png b/docs/Using-Mastodon/screenshots/notifications-clear.png new file mode 100644 index 0000000000..7d0922ccb0 Binary files /dev/null and b/docs/Using-Mastodon/screenshots/notifications-clear.png differ diff --git a/docs/Using-Mastodon/screenshots/notifications-settings.png b/docs/Using-Mastodon/screenshots/notifications-settings.png new file mode 100644 index 0000000000..3a3417e7d8 Binary files /dev/null and b/docs/Using-Mastodon/screenshots/notifications-settings.png differ diff --git a/docs/Using-Mastodon/screenshots/pending-icon.png b/docs/Using-Mastodon/screenshots/pending-icon.png new file mode 100644 index 0000000000..777b3c391e Binary files /dev/null and b/docs/Using-Mastodon/screenshots/pending-icon.png differ diff --git a/docs/Using-Mastodon/screenshots/preferences.png b/docs/Using-Mastodon/screenshots/preferences.png new file mode 100644 index 0000000000..943413feb0 Binary files /dev/null and b/docs/Using-Mastodon/screenshots/preferences.png differ diff --git a/docs/Using-Mastodon/screenshots/private.png b/docs/Using-Mastodon/screenshots/private.png new file mode 100644 index 0000000000..cf338aad0c Binary files /dev/null and b/docs/Using-Mastodon/screenshots/private.png differ diff --git a/docs/Using-Mastodon/screenshots/report.png b/docs/Using-Mastodon/screenshots/report.png new file mode 100644 index 0000000000..5ce401ee7c Binary files /dev/null and b/docs/Using-Mastodon/screenshots/report.png differ diff --git a/docs/Using-the-API/API.md b/docs/Using-the-API/API.md index 4db634a603..bc5ca3de4c 100644 --- a/docs/Using-the-API/API.md +++ b/docs/Using-the-API/API.md @@ -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 | diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake index bb10410b50..79dcb722ae 100644 --- a/lib/tasks/mastodon.rake +++ b/lib/tasks/mastodon.rake @@ -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 diff --git a/spec/fabricators/import_fabricator.rb b/spec/fabricators/import_fabricator.rb new file mode 100644 index 0000000000..e2eb1e0dfb --- /dev/null +++ b/spec/fabricators/import_fabricator.rb @@ -0,0 +1,2 @@ +Fabricator(:import) do +end diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb new file mode 100644 index 0000000000..fa52077cd1 --- /dev/null +++ b/spec/models/import_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Import, type: :model do + +end diff --git a/spec/services/block_domain_service_spec.rb b/spec/services/block_domain_service_spec.rb index d88b3b55cf..8e71d45429 100644 --- a/spec/services/block_domain_service_spec.rb +++ b/spec/services/block_domain_service_spec.rb @@ -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 diff --git a/streaming/index.js b/streaming/index.js index 0f838e411a..7edf6203fb 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -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)