forked from treehouse/mastodon
Merge branch 'master' into development
commit
3abb0f7bc7
|
@ -13,7 +13,7 @@ Below are the guidelines for working on pull requests:
|
||||||
|
|
||||||
## General
|
## General
|
||||||
|
|
||||||
- 2 spaces indendation
|
- 2 spaces indentation
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|
43
Dockerfile
43
Dockerfile
|
@ -1,24 +1,31 @@
|
||||||
FROM ruby:2.3.1
|
FROM ruby:2.3.1-alpine
|
||||||
|
|
||||||
ENV RAILS_ENV=production
|
ENV RAILS_ENV=production \
|
||||||
ENV NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
RUN echo 'deb http://httpredir.debian.org/debian jessie-backports main contrib non-free' >> /etc/apt/sources.list
|
|
||||||
RUN curl -sL https://deb.nodesource.com/setup_4.x | bash -
|
|
||||||
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev libxml2-dev libxslt1-dev nodejs ffmpeg && rm -rf /var/lib/apt/lists/*
|
|
||||||
RUN npm install -g npm@3 && npm install -g yarn
|
|
||||||
RUN mkdir /mastodon
|
|
||||||
|
|
||||||
WORKDIR /mastodon
|
WORKDIR /mastodon
|
||||||
|
|
||||||
ADD Gemfile /mastodon/Gemfile
|
COPY . /mastodon
|
||||||
ADD Gemfile.lock /mastodon/Gemfile.lock
|
|
||||||
RUN bundle install --deployment --without test development
|
|
||||||
|
|
||||||
ADD package.json /mastodon/package.json
|
RUN BUILD_DEPS=" \
|
||||||
ADD yarn.lock /mastodon/yarn.lock
|
postgresql-dev \
|
||||||
RUN yarn
|
libxml2-dev \
|
||||||
|
libxslt-dev \
|
||||||
|
build-base" \
|
||||||
|
&& apk -U upgrade && apk add \
|
||||||
|
$BUILD_DEPS \
|
||||||
|
nodejs \
|
||||||
|
libpq \
|
||||||
|
libxml2 \
|
||||||
|
libxslt \
|
||||||
|
ffmpeg \
|
||||||
|
file \
|
||||||
|
imagemagick \
|
||||||
|
&& npm install -g npm@3 && npm install -g yarn \
|
||||||
|
&& bundle install --deployment --without test development \
|
||||||
|
&& yarn \
|
||||||
|
&& npm cache clean \
|
||||||
|
&& apk del $BUILD_DEPS \
|
||||||
|
&& rm -rf /tmp/* /var/cache/apk/*
|
||||||
|
|
||||||
ADD . /mastodon
|
VOLUME /mastodon/public/system /mastodon/public/assets
|
||||||
|
|
||||||
VOLUME ["/mastodon/public/system", "/mastodon/public/assets"]
|
|
||||||
|
|
3
Gemfile
3
Gemfile
|
@ -50,6 +50,8 @@ gem 'rails-settings-cached'
|
||||||
gem 'simple-navigation'
|
gem 'simple-navigation'
|
||||||
gem 'statsd-instrument'
|
gem 'statsd-instrument'
|
||||||
gem 'ruby-oembed', require: 'oembed'
|
gem 'ruby-oembed', require: 'oembed'
|
||||||
|
gem 'rack-timeout'
|
||||||
|
gem 'tzinfo-data'
|
||||||
|
|
||||||
gem 'react-rails'
|
gem 'react-rails'
|
||||||
gem 'browserify-rails'
|
gem 'browserify-rails'
|
||||||
|
@ -89,5 +91,4 @@ group :production do
|
||||||
gem 'rails_12factor'
|
gem 'rails_12factor'
|
||||||
gem 'redis-rails'
|
gem 'redis-rails'
|
||||||
gem 'lograge'
|
gem 'lograge'
|
||||||
gem 'rack-timeout'
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -423,6 +423,8 @@ GEM
|
||||||
unf (~> 0.1.0)
|
unf (~> 0.1.0)
|
||||||
tzinfo (1.2.2)
|
tzinfo (1.2.2)
|
||||||
thread_safe (~> 0.1)
|
thread_safe (~> 0.1)
|
||||||
|
tzinfo-data (1.2017.2)
|
||||||
|
tzinfo (>= 1.0.0)
|
||||||
uglifier (3.0.1)
|
uglifier (3.0.1)
|
||||||
execjs (>= 0.3.0, < 3)
|
execjs (>= 0.3.0, < 3)
|
||||||
unf (0.1.4)
|
unf (0.1.4)
|
||||||
|
@ -513,6 +515,7 @@ DEPENDENCIES
|
||||||
simplecov
|
simplecov
|
||||||
statsd-instrument
|
statsd-instrument
|
||||||
twitter-text
|
twitter-text
|
||||||
|
tzinfo-data
|
||||||
uglifier (>= 1.3.0)
|
uglifier (>= 1.3.0)
|
||||||
webmock
|
webmock
|
||||||
will_paginate
|
will_paginate
|
||||||
|
|
|
@ -7,7 +7,7 @@ Mastodon
|
||||||
[travis]: https://travis-ci.org/tootsuite/mastodon
|
[travis]: https://travis-ci.org/tootsuite/mastodon
|
||||||
[code_climate]: https://codeclimate.com/github/tootsuite/mastodon
|
[code_climate]: https://codeclimate.com/github/tootsuite/mastodon
|
||||||
|
|
||||||
Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly.
|
Mastodon is a free, open-source social network server. A decentralized solution to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly.
|
||||||
|
|
||||||
An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon.
|
An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon.
|
||||||
|
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 59 KiB |
|
@ -579,15 +579,18 @@ export function expandFollowingFail(id, error) {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchRelationships(account_ids) {
|
export function fetchRelationships(accountIds) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
if (account_ids.length === 0) {
|
const loadedRelationships = getState().get('relationships');
|
||||||
|
const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null);
|
||||||
|
|
||||||
|
if (newAccountIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(fetchRelationshipsRequest(account_ids));
|
dispatch(fetchRelationshipsRequest(newAccountIds));
|
||||||
|
|
||||||
api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
|
api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
|
||||||
dispatch(fetchRelationshipsSuccess(response.data));
|
dispatch(fetchRelationshipsSuccess(response.data));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(fetchRelationshipsFail(error));
|
dispatch(fetchRelationshipsFail(error));
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
export const MEDIA_OPEN = 'MEDIA_OPEN';
|
export const MODAL_OPEN = 'MODAL_OPEN';
|
||||||
export const MODAL_CLOSE = 'MODAL_CLOSE';
|
export const MODAL_CLOSE = 'MODAL_CLOSE';
|
||||||
|
|
||||||
export const MODAL_INDEX_DECREASE = 'MODAL_INDEX_DECREASE';
|
export function openModal(type, props) {
|
||||||
export const MODAL_INDEX_INCREASE = 'MODAL_INDEX_INCREASE';
|
|
||||||
|
|
||||||
export function openMedia(media, index) {
|
|
||||||
return {
|
return {
|
||||||
type: MEDIA_OPEN,
|
type: MODAL_OPEN,
|
||||||
media,
|
modalType: type,
|
||||||
index
|
modalProps: props
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -17,15 +14,3 @@ export function closeModal() {
|
||||||
type: MODAL_CLOSE
|
type: MODAL_CLOSE
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function decreaseIndexInModal() {
|
|
||||||
return {
|
|
||||||
type: MODAL_INDEX_DECREASE
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function increaseIndexInModal() {
|
|
||||||
return {
|
|
||||||
type: MODAL_INDEX_INCREASE
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
|
||||||
export const SEARCH_CHANGE = 'SEARCH_CHANGE';
|
export const SEARCH_CHANGE = 'SEARCH_CHANGE';
|
||||||
export const SEARCH_SUGGESTIONS_CLEAR = 'SEARCH_SUGGESTIONS_CLEAR';
|
export const SEARCH_CLEAR = 'SEARCH_CLEAR';
|
||||||
export const SEARCH_SUGGESTIONS_READY = 'SEARCH_SUGGESTIONS_READY';
|
export const SEARCH_SHOW = 'SEARCH_SHOW';
|
||||||
export const SEARCH_RESET = 'SEARCH_RESET';
|
|
||||||
|
export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
|
||||||
|
export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
|
||||||
|
export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
|
||||||
|
|
||||||
export function changeSearch(value) {
|
export function changeSearch(value) {
|
||||||
return {
|
return {
|
||||||
|
@ -12,42 +15,59 @@ export function changeSearch(value) {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function clearSearchSuggestions() {
|
export function clearSearch() {
|
||||||
return {
|
return {
|
||||||
type: SEARCH_SUGGESTIONS_CLEAR
|
type: SEARCH_CLEAR
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function readySearchSuggestions(value, { accounts, hashtags, statuses }) {
|
export function submitSearch() {
|
||||||
return {
|
|
||||||
type: SEARCH_SUGGESTIONS_READY,
|
|
||||||
value,
|
|
||||||
accounts,
|
|
||||||
hashtags,
|
|
||||||
statuses
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function fetchSearchSuggestions(value) {
|
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
if (getState().getIn(['search', 'loaded_value']) === value) {
|
const value = getState().getIn(['search', 'value']);
|
||||||
|
|
||||||
|
if (value.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispatch(fetchSearchRequest());
|
||||||
|
|
||||||
api(getState).get('/api/v1/search', {
|
api(getState).get('/api/v1/search', {
|
||||||
params: {
|
params: {
|
||||||
q: value,
|
q: value,
|
||||||
resolve: true,
|
resolve: true
|
||||||
limit: 4
|
|
||||||
}
|
}
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
dispatch(readySearchSuggestions(value, response.data));
|
dispatch(fetchSearchSuccess(response.data));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(fetchSearchFail(error));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resetSearch() {
|
export function fetchSearchRequest() {
|
||||||
return {
|
return {
|
||||||
type: SEARCH_RESET
|
type: SEARCH_FETCH_REQUEST
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchSearchSuccess(results) {
|
||||||
|
return {
|
||||||
|
type: SEARCH_FETCH_SUCCESS,
|
||||||
|
results,
|
||||||
|
accounts: results.accounts,
|
||||||
|
statuses: results.statuses
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchSearchFail(error) {
|
||||||
|
return {
|
||||||
|
type: SEARCH_FETCH_FAIL,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function showSearch() {
|
||||||
|
return {
|
||||||
|
type: SEARCH_SHOW
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -14,6 +14,9 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
|
||||||
|
|
||||||
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
|
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
|
||||||
|
|
||||||
|
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
|
||||||
|
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
|
||||||
|
|
||||||
export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
|
export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
|
||||||
return {
|
return {
|
||||||
type: TIMELINE_REFRESH_SUCCESS,
|
type: TIMELINE_REFRESH_SUCCESS,
|
||||||
|
@ -76,6 +79,11 @@ export function refreshTimeline(timeline, id = null) {
|
||||||
let skipLoading = false;
|
let skipLoading = false;
|
||||||
|
|
||||||
if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded']) && (id === null || getState().getIn(['timelines', timeline, 'id']) === id)) {
|
if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded']) && (id === null || getState().getIn(['timelines', timeline, 'id']) === id)) {
|
||||||
|
if (id === null && getState().getIn(['timelines', timeline, 'online'])) {
|
||||||
|
// Skip refreshing when timeline is live anyway
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
params = { ...params, since_id: newestId };
|
params = { ...params, since_id: newestId };
|
||||||
skipLoading = true;
|
skipLoading = true;
|
||||||
}
|
}
|
||||||
|
@ -162,3 +170,17 @@ export function scrollTopTimeline(timeline, top) {
|
||||||
top
|
top
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function connectTimeline(timeline) {
|
||||||
|
return {
|
||||||
|
type: TIMELINE_CONNECT,
|
||||||
|
timeline
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function disconnectTimeline(timeline) {
|
||||||
|
return {
|
||||||
|
type: TIMELINE_DISCONNECT,
|
||||||
|
timeline
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -1,82 +0,0 @@
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
|
||||||
import IconButton from './icon_button';
|
|
||||||
import { Motion, spring } from 'react-motion';
|
|
||||||
import { injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
const overlayStyle = {
|
|
||||||
position: 'fixed',
|
|
||||||
top: '0',
|
|
||||||
left: '0',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
background: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignContent: 'center',
|
|
||||||
flexDirection: 'row',
|
|
||||||
zIndex: '9999'
|
|
||||||
};
|
|
||||||
|
|
||||||
const dialogStyle = {
|
|
||||||
color: '#282c37',
|
|
||||||
boxShadow: '0 0 30px rgba(0, 0, 0, 0.8)',
|
|
||||||
margin: 'auto',
|
|
||||||
position: 'relative'
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeStyle = {
|
|
||||||
position: 'absolute',
|
|
||||||
top: '4px',
|
|
||||||
right: '4px'
|
|
||||||
};
|
|
||||||
|
|
||||||
const Lightbox = React.createClass({
|
|
||||||
|
|
||||||
propTypes: {
|
|
||||||
isVisible: React.PropTypes.bool,
|
|
||||||
onOverlayClicked: React.PropTypes.func,
|
|
||||||
onCloseClicked: React.PropTypes.func,
|
|
||||||
intl: React.PropTypes.object.isRequired,
|
|
||||||
children: React.PropTypes.node
|
|
||||||
},
|
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
this._listener = e => {
|
|
||||||
if (this.props.isVisible && e.key === 'Escape') {
|
|
||||||
this.props.onCloseClicked();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keyup', this._listener);
|
|
||||||
},
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
|
||||||
window.removeEventListener('keyup', this._listener);
|
|
||||||
},
|
|
||||||
|
|
||||||
stopPropagation (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
},
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Motion defaultStyle={{ backgroundOpacity: 0, opacity: 0, y: -400 }} style={{ backgroundOpacity: spring(isVisible ? 50 : 0), opacity: isVisible ? spring(200) : 0, y: spring(isVisible ? 0 : -400, { stiffness: 150, damping: 12 }) }}>
|
|
||||||
{({ backgroundOpacity, opacity, y }) =>
|
|
||||||
<div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex', pointerEvents: !isVisible ? 'none' : 'auto'}} onClick={onOverlayClicked}>
|
|
||||||
<div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }} onClick={this.stopPropagation}>
|
|
||||||
<IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</Motion>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default injectIntl(Lightbox);
|
|
|
@ -7,6 +7,7 @@ import { defineMessages, injectIntl } from 'react-intl';
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||||
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
|
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
|
||||||
|
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||||
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
||||||
reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
|
reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
|
||||||
|
@ -28,6 +29,7 @@ const StatusActionBar = React.createClass({
|
||||||
onReblog: React.PropTypes.func,
|
onReblog: React.PropTypes.func,
|
||||||
onDelete: React.PropTypes.func,
|
onDelete: React.PropTypes.func,
|
||||||
onMention: React.PropTypes.func,
|
onMention: React.PropTypes.func,
|
||||||
|
onMute: React.PropTypes.func,
|
||||||
onBlock: React.PropTypes.func,
|
onBlock: React.PropTypes.func,
|
||||||
onReport: React.PropTypes.func,
|
onReport: React.PropTypes.func,
|
||||||
me: React.PropTypes.number.isRequired,
|
me: React.PropTypes.number.isRequired,
|
||||||
|
@ -56,6 +58,10 @@ const StatusActionBar = React.createClass({
|
||||||
this.props.onMention(this.props.status.get('account'), this.context.router);
|
this.props.onMention(this.props.status.get('account'), this.context.router);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleMuteClick () {
|
||||||
|
this.props.onMute(this.props.status.get('account'));
|
||||||
|
},
|
||||||
|
|
||||||
handleBlockClick () {
|
handleBlockClick () {
|
||||||
this.props.onBlock(this.props.status.get('account'));
|
this.props.onBlock(this.props.status.get('account'));
|
||||||
},
|
},
|
||||||
|
@ -81,6 +87,7 @@ const StatusActionBar = React.createClass({
|
||||||
} else {
|
} else {
|
||||||
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
|
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
|
||||||
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
|
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
|
||||||
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
|
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,8 @@ const muteStyle = {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '10px',
|
top: '10px',
|
||||||
right: '10px',
|
right: '10px',
|
||||||
|
color: 'white',
|
||||||
|
textShadow: "0px 1px 1px black, 1px 0px 1px black",
|
||||||
opacity: '0.8',
|
opacity: '0.8',
|
||||||
zIndex: '5'
|
zIndex: '5'
|
||||||
};
|
};
|
||||||
|
@ -54,6 +56,8 @@ const spoilerButtonStyle = {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '6px',
|
top: '6px',
|
||||||
left: '8px',
|
left: '8px',
|
||||||
|
color: 'white',
|
||||||
|
textShadow: "0px 1px 1px black, 1px 0px 1px black",
|
||||||
zIndex: '100'
|
zIndex: '100'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,9 @@ import {
|
||||||
refreshTimelineSuccess,
|
refreshTimelineSuccess,
|
||||||
updateTimeline,
|
updateTimeline,
|
||||||
deleteFromTimelines,
|
deleteFromTimelines,
|
||||||
refreshTimeline
|
refreshTimeline,
|
||||||
|
connectTimeline,
|
||||||
|
disconnectTimeline
|
||||||
} from '../actions/timelines';
|
} from '../actions/timelines';
|
||||||
import { updateNotifications, refreshNotifications } from '../actions/notifications';
|
import { updateNotifications, refreshNotifications } from '../actions/notifications';
|
||||||
import createBrowserHistory from 'history/lib/createBrowserHistory';
|
import createBrowserHistory from 'history/lib/createBrowserHistory';
|
||||||
|
@ -44,6 +46,7 @@ import fr from 'react-intl/locale-data/fr';
|
||||||
import pt from 'react-intl/locale-data/pt';
|
import pt from 'react-intl/locale-data/pt';
|
||||||
import hu from 'react-intl/locale-data/hu';
|
import hu from 'react-intl/locale-data/hu';
|
||||||
import uk from 'react-intl/locale-data/uk';
|
import uk from 'react-intl/locale-data/uk';
|
||||||
|
import fi from 'react-intl/locale-data/fi';
|
||||||
import getMessagesForLocale from '../locales';
|
import getMessagesForLocale from '../locales';
|
||||||
import { hydrateStore } from '../actions/store';
|
import { hydrateStore } from '../actions/store';
|
||||||
import createStream from '../stream';
|
import createStream from '../stream';
|
||||||
|
@ -56,7 +59,7 @@ const browserHistory = useRouterHistory(createBrowserHistory)({
|
||||||
basename: '/web'
|
basename: '/web'
|
||||||
});
|
});
|
||||||
|
|
||||||
addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk]);
|
addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk, ...fi]);
|
||||||
|
|
||||||
const Mastodon = React.createClass({
|
const Mastodon = React.createClass({
|
||||||
|
|
||||||
|
@ -70,6 +73,14 @@ const Mastodon = React.createClass({
|
||||||
|
|
||||||
this.subscription = createStream(accessToken, 'user', {
|
this.subscription = createStream(accessToken, 'user', {
|
||||||
|
|
||||||
|
connected () {
|
||||||
|
store.dispatch(connectTimeline('home'));
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnected () {
|
||||||
|
store.dispatch(disconnectTimeline('home'));
|
||||||
|
},
|
||||||
|
|
||||||
received (data) {
|
received (data) {
|
||||||
switch(data.event) {
|
switch(data.event) {
|
||||||
case 'update':
|
case 'update':
|
||||||
|
@ -85,6 +96,7 @@ const Mastodon = React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
reconnected () {
|
reconnected () {
|
||||||
|
store.dispatch(connectTimeline('home'));
|
||||||
store.dispatch(refreshTimeline('home'));
|
store.dispatch(refreshTimeline('home'));
|
||||||
store.dispatch(refreshNotifications());
|
store.dispatch(refreshNotifications());
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ import {
|
||||||
} from '../actions/accounts';
|
} from '../actions/accounts';
|
||||||
import { deleteStatus } from '../actions/statuses';
|
import { deleteStatus } from '../actions/statuses';
|
||||||
import { initReport } from '../actions/reports';
|
import { initReport } from '../actions/reports';
|
||||||
import { openMedia } from '../actions/modal';
|
import { openModal } from '../actions/modal';
|
||||||
import { createSelector } from 'reselect'
|
import { createSelector } from 'reselect'
|
||||||
import { isMobile } from '../is_mobile'
|
import { isMobile } from '../is_mobile'
|
||||||
|
|
||||||
|
@ -63,7 +63,7 @@ const mapDispatchToProps = (dispatch) => ({
|
||||||
},
|
},
|
||||||
|
|
||||||
onOpenMedia (media, index) {
|
onOpenMedia (media, index) {
|
||||||
dispatch(openMedia(media, index));
|
dispatch(openModal('MEDIA', { media, index }));
|
||||||
},
|
},
|
||||||
|
|
||||||
onBlock (account) {
|
onBlock (account) {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import emojify from '../../../emoji';
|
||||||
import escapeTextContentForBrowser from 'escape-html';
|
import escapeTextContentForBrowser from 'escape-html';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import IconButton from '../../../components/icon_button';
|
import IconButton from '../../../components/icon_button';
|
||||||
|
import { Motion, spring } from 'react-motion';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
|
@ -11,6 +12,47 @@ const messages = defineMessages({
|
||||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
|
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const Avatar = React.createClass({
|
||||||
|
|
||||||
|
propTypes: {
|
||||||
|
account: ImmutablePropTypes.map.isRequired
|
||||||
|
},
|
||||||
|
|
||||||
|
getInitialState () {
|
||||||
|
return {
|
||||||
|
isHovered: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
|
handleMouseOver () {
|
||||||
|
if (this.state.isHovered) return;
|
||||||
|
this.setState({ isHovered: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
handleMouseOut () {
|
||||||
|
if (!this.state.isHovered) return;
|
||||||
|
this.setState({ isHovered: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { account } = this.props;
|
||||||
|
const { isHovered } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
|
||||||
|
{({ radius }) =>
|
||||||
|
<a href={account.get('url')} className='account__header__avatar' target='_blank' rel='noopener' style={{ display: 'block', width: '90px', height: '90px', margin: '0 auto', marginBottom: '10px', borderRadius: `${radius}px`, overflow: 'hidden' }} onMouseOver={this.handleMouseOver} onMouseOut={this.handleMouseOut}>
|
||||||
|
<img src={account.get('avatar')} alt={account.get('acct')} style={{ display: 'block', width: '90px', height: '90px' }} />
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</Motion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
const Header = React.createClass({
|
const Header = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
|
@ -68,14 +110,9 @@ const Header = React.createClass({
|
||||||
return (
|
return (
|
||||||
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
|
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
|
||||||
<div style={{ padding: '20px 10px' }}>
|
<div style={{ padding: '20px 10px' }}>
|
||||||
<a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
|
<Avatar account={account} />
|
||||||
<div className='account__header__avatar' style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}>
|
|
||||||
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
|
<span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
|
||||||
<span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
|
<span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
|
||||||
<div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
|
<div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,9 @@ import Column from '../ui/components/column';
|
||||||
import {
|
import {
|
||||||
refreshTimeline,
|
refreshTimeline,
|
||||||
updateTimeline,
|
updateTimeline,
|
||||||
deleteFromTimelines
|
deleteFromTimelines,
|
||||||
|
connectTimeline,
|
||||||
|
disconnectTimeline
|
||||||
} from '../../actions/timelines';
|
} from '../../actions/timelines';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
||||||
|
@ -44,6 +46,18 @@ const CommunityTimeline = React.createClass({
|
||||||
|
|
||||||
subscription = createStream(accessToken, 'public:local', {
|
subscription = createStream(accessToken, 'public:local', {
|
||||||
|
|
||||||
|
connected () {
|
||||||
|
dispatch(connectTimeline('community'));
|
||||||
|
},
|
||||||
|
|
||||||
|
reconnected () {
|
||||||
|
dispatch(connectTimeline('community'));
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnected () {
|
||||||
|
dispatch(disconnectTimeline('community'));
|
||||||
|
},
|
||||||
|
|
||||||
received (data) {
|
received (data) {
|
||||||
switch(data.event) {
|
switch(data.event) {
|
||||||
case 'update':
|
case 'update':
|
||||||
|
|
|
@ -1,44 +0,0 @@
|
||||||
import { Link } from 'react-router';
|
|
||||||
import { injectIntl, defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
|
||||||
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' },
|
|
||||||
community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
|
|
||||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
|
||||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
|
|
||||||
});
|
|
||||||
|
|
||||||
const Drawer = ({ children, withHeader, intl }) => {
|
|
||||||
let header = '';
|
|
||||||
|
|
||||||
if (withHeader) {
|
|
||||||
header = (
|
|
||||||
<div className='drawer__header'>
|
|
||||||
<Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
|
|
||||||
<Link title={intl.formatMessage(messages.community)} className='drawer__tab' to='/timelines/public/local'><i className='fa fa-fw fa-users' /></Link>
|
|
||||||
<Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
|
|
||||||
<a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
|
|
||||||
<a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='drawer'>
|
|
||||||
{header}
|
|
||||||
|
|
||||||
<div className='drawer__inner'>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Drawer.propTypes = {
|
|
||||||
withHeader: React.PropTypes.bool,
|
|
||||||
children: React.PropTypes.node,
|
|
||||||
intl: React.PropTypes.object
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(Drawer);
|
|
|
@ -1,123 +1,68 @@
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Autosuggest from 'react-autosuggest';
|
|
||||||
import AutosuggestAccountContainer from '../containers/autosuggest_account_container';
|
|
||||||
import AutosuggestStatusContainer from '../containers/autosuggest_status_container';
|
|
||||||
import { debounce } from 'react-decoration';
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }
|
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }
|
||||||
});
|
});
|
||||||
|
|
||||||
const getSuggestionValue = suggestion => suggestion.value;
|
|
||||||
|
|
||||||
const renderSuggestion = suggestion => {
|
|
||||||
if (suggestion.type === 'account') {
|
|
||||||
return <AutosuggestAccountContainer id={suggestion.id} />;
|
|
||||||
} else if (suggestion.type === 'hashtag') {
|
|
||||||
return <span>#{suggestion.id}</span>;
|
|
||||||
} else {
|
|
||||||
return <AutosuggestStatusContainer id={suggestion.id} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSectionTitle = section => (
|
|
||||||
<strong><FormattedMessage id={`search.${section.title}`} defaultMessage={section.title} /></strong>
|
|
||||||
);
|
|
||||||
|
|
||||||
const getSectionSuggestions = section => section.items;
|
|
||||||
|
|
||||||
const outerStyle = {
|
|
||||||
padding: '10px',
|
|
||||||
lineHeight: '20px',
|
|
||||||
position: 'relative'
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconStyle = {
|
|
||||||
position: 'absolute',
|
|
||||||
top: '18px',
|
|
||||||
right: '20px',
|
|
||||||
fontSize: '18px',
|
|
||||||
pointerEvents: 'none'
|
|
||||||
};
|
|
||||||
|
|
||||||
const Search = React.createClass({
|
const Search = React.createClass({
|
||||||
|
|
||||||
contextTypes: {
|
|
||||||
router: React.PropTypes.object
|
|
||||||
},
|
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
suggestions: React.PropTypes.array.isRequired,
|
|
||||||
value: React.PropTypes.string.isRequired,
|
value: React.PropTypes.string.isRequired,
|
||||||
|
submitted: React.PropTypes.bool,
|
||||||
onChange: React.PropTypes.func.isRequired,
|
onChange: React.PropTypes.func.isRequired,
|
||||||
|
onSubmit: React.PropTypes.func.isRequired,
|
||||||
onClear: React.PropTypes.func.isRequired,
|
onClear: React.PropTypes.func.isRequired,
|
||||||
onFetch: React.PropTypes.func.isRequired,
|
onShow: React.PropTypes.func.isRequired,
|
||||||
onReset: React.PropTypes.func.isRequired,
|
|
||||||
intl: React.PropTypes.object.isRequired
|
intl: React.PropTypes.object.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
onChange (_, { newValue }) {
|
handleChange (e) {
|
||||||
if (typeof newValue !== 'string') {
|
this.props.onChange(e.target.value);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.onChange(newValue);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onSuggestionsClearRequested () {
|
handleClear (e) {
|
||||||
|
e.preventDefault();
|
||||||
this.props.onClear();
|
this.props.onClear();
|
||||||
},
|
},
|
||||||
|
|
||||||
@debounce(500)
|
handleKeyDown (e) {
|
||||||
onSuggestionsFetchRequested ({ value }) {
|
if (e.key === 'Enter') {
|
||||||
value = value.replace('#', '');
|
e.preventDefault();
|
||||||
this.props.onFetch(value.trim());
|
this.props.onSubmit();
|
||||||
},
|
|
||||||
|
|
||||||
onSuggestionSelected (_, { suggestion }) {
|
|
||||||
if (suggestion.type === 'account') {
|
|
||||||
this.context.router.push(`/accounts/${suggestion.id}`);
|
|
||||||
} else if(suggestion.type === 'hashtag') {
|
|
||||||
this.context.router.push(`/timelines/tag/${suggestion.id}`);
|
|
||||||
} else {
|
|
||||||
this.context.router.push(`/statuses/${suggestion.id}`);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleFocus () {
|
||||||
|
this.props.onShow();
|
||||||
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const inputProps = {
|
const { intl, value, submitted } = this.props;
|
||||||
placeholder: this.props.intl.formatMessage(messages.placeholder),
|
const hasValue = value.length > 0 || submitted;
|
||||||
value: this.props.value,
|
|
||||||
onChange: this.onChange,
|
|
||||||
className: 'search__input'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='search' style={outerStyle}>
|
<div className='search'>
|
||||||
<Autosuggest
|
<input
|
||||||
multiSection={true}
|
className='search__input'
|
||||||
suggestions={this.props.suggestions}
|
type='text'
|
||||||
focusFirstSuggestion={true}
|
placeholder={intl.formatMessage(messages.placeholder)}
|
||||||
focusInputOnSuggestionClick={false}
|
value={value}
|
||||||
alwaysRenderSuggestions={false}
|
onChange={this.handleChange}
|
||||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
onKeyUp={this.handleKeyDown}
|
||||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
onFocus={this.handleFocus}
|
||||||
onSuggestionSelected={this.onSuggestionSelected}
|
|
||||||
getSuggestionValue={getSuggestionValue}
|
|
||||||
renderSuggestion={renderSuggestion}
|
|
||||||
renderSectionTitle={renderSectionTitle}
|
|
||||||
getSectionSuggestions={getSectionSuggestions}
|
|
||||||
inputProps={inputProps}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={iconStyle}><i className='fa fa-search' /></div>
|
<div className='search__icon'>
|
||||||
|
<i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
|
||||||
|
<i className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} onClick={this.handleClear} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
import AccountContainer from '../../../containers/account_container';
|
||||||
|
import StatusContainer from '../../../containers/status_container';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
const SearchResults = React.createClass({
|
||||||
|
|
||||||
|
propTypes: {
|
||||||
|
results: ImmutablePropTypes.map.isRequired
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { results } = this.props;
|
||||||
|
|
||||||
|
let accounts, statuses, hashtags;
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
if (results.get('accounts') && results.get('accounts').size > 0) {
|
||||||
|
count += results.get('accounts').size;
|
||||||
|
accounts = (
|
||||||
|
<div className='search-results__section'>
|
||||||
|
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.get('statuses') && results.get('statuses').size > 0) {
|
||||||
|
count += results.get('statuses').size;
|
||||||
|
statuses = (
|
||||||
|
<div className='search-results__section'>
|
||||||
|
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.get('hashtags') && results.get('hashtags').size > 0) {
|
||||||
|
count += results.get('hashtags').size;
|
||||||
|
hashtags = (
|
||||||
|
<div className='search-results__section'>
|
||||||
|
{results.get('hashtags').map(hashtag =>
|
||||||
|
<Link className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
|
||||||
|
#{hashtag}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='search-results'>
|
||||||
|
<div className='search-results__header'>
|
||||||
|
<FormattedMessage id='search_results.total' defaultMessage='{count} {count, plural, one {result} other {results}}' values={{ count }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accounts}
|
||||||
|
{statuses}
|
||||||
|
{hashtags}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SearchResults;
|
|
@ -1,31 +0,0 @@
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
import Toggle from 'react-toggle';
|
|
||||||
import Collapsable from '../../../components/collapsable';
|
|
||||||
|
|
||||||
const SensitiveToggle = React.createClass({
|
|
||||||
|
|
||||||
propTypes: {
|
|
||||||
hasMedia: React.PropTypes.bool,
|
|
||||||
isSensitive: React.PropTypes.bool,
|
|
||||||
onChange: React.PropTypes.func.isRequired
|
|
||||||
},
|
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { hasMedia, isSensitive, onChange } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Collapsable isVisible={hasMedia} fullHeight={39.5}>
|
|
||||||
<label className='compose-form__label'>
|
|
||||||
<Toggle checked={isSensitive} onChange={onChange} />
|
|
||||||
<span className='compose-form__label__text'><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark media as sensitive' /></span>
|
|
||||||
</label>
|
|
||||||
</Collapsable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default SensitiveToggle;
|
|
|
@ -1,27 +0,0 @@
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
import Toggle from 'react-toggle';
|
|
||||||
|
|
||||||
const SpoilerToggle = React.createClass({
|
|
||||||
|
|
||||||
propTypes: {
|
|
||||||
isSpoiler: React.PropTypes.bool,
|
|
||||||
onChange: React.PropTypes.func.isRequired
|
|
||||||
},
|
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { isSpoiler, onChange } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className='compose-form__label with-border' style={{ marginTop: '10px' }}>
|
|
||||||
<Toggle checked={isSpoiler} onChange={onChange} />
|
|
||||||
<span className='compose-form__label__text'><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default SpoilerToggle;
|
|
|
@ -1,15 +1,15 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
changeSearch,
|
changeSearch,
|
||||||
clearSearchSuggestions,
|
clearSearch,
|
||||||
fetchSearchSuggestions,
|
submitSearch,
|
||||||
resetSearch
|
showSearch
|
||||||
} from '../../../actions/search';
|
} from '../../../actions/search';
|
||||||
import Search from '../components/search';
|
import Search from '../components/search';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
suggestions: state.getIn(['search', 'suggestions']),
|
value: state.getIn(['search', 'value']),
|
||||||
value: state.getIn(['search', 'value'])
|
submitted: state.getIn(['search', 'submitted'])
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
@ -19,15 +19,15 @@ const mapDispatchToProps = dispatch => ({
|
||||||
},
|
},
|
||||||
|
|
||||||
onClear () {
|
onClear () {
|
||||||
dispatch(clearSearchSuggestions());
|
dispatch(clearSearch());
|
||||||
},
|
},
|
||||||
|
|
||||||
onFetch (value) {
|
onSubmit () {
|
||||||
dispatch(fetchSearchSuggestions(value));
|
dispatch(submitSearch());
|
||||||
},
|
},
|
||||||
|
|
||||||
onReset () {
|
onShow () {
|
||||||
dispatch(resetSearch());
|
dispatch(showSearch());
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import SearchResults from '../components/search_results';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
results: state.getIn(['search', 'results'])
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(SearchResults);
|
|
@ -1,17 +1,34 @@
|
||||||
import Drawer from './components/drawer';
|
|
||||||
import ComposeFormContainer from './containers/compose_form_container';
|
import ComposeFormContainer from './containers/compose_form_container';
|
||||||
import UploadFormContainer from './containers/upload_form_container';
|
import UploadFormContainer from './containers/upload_form_container';
|
||||||
import NavigationContainer from './containers/navigation_container';
|
import NavigationContainer from './containers/navigation_container';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import SearchContainer from './containers/search_container';
|
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { mountCompose, unmountCompose } from '../../actions/compose';
|
import { mountCompose, unmountCompose } from '../../actions/compose';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { injectIntl, defineMessages } from 'react-intl';
|
||||||
|
import SearchContainer from './containers/search_container';
|
||||||
|
import { Motion, spring } from 'react-motion';
|
||||||
|
import SearchResultsContainer from './containers/search_results_container';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||||
|
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' },
|
||||||
|
community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
|
||||||
|
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||||
|
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden'])
|
||||||
|
});
|
||||||
|
|
||||||
const Compose = React.createClass({
|
const Compose = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
dispatch: React.PropTypes.func.isRequired,
|
dispatch: React.PropTypes.func.isRequired,
|
||||||
withHeader: React.PropTypes.bool
|
withHeader: React.PropTypes.bool,
|
||||||
|
showSearch: React.PropTypes.bool,
|
||||||
|
intl: React.PropTypes.object.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
@ -25,15 +42,46 @@ const Compose = React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
const { withHeader, showSearch, intl } = this.props;
|
||||||
|
|
||||||
|
let header = '';
|
||||||
|
|
||||||
|
if (withHeader) {
|
||||||
|
header = (
|
||||||
|
<div className='drawer__header'>
|
||||||
|
<Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
|
||||||
|
<Link title={intl.formatMessage(messages.community)} className='drawer__tab' to='/timelines/public/local'><i className='fa fa-fw fa-users' /></Link>
|
||||||
|
<Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
|
||||||
|
<a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
|
||||||
|
<a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer withHeader={this.props.withHeader}>
|
<div className='drawer'>
|
||||||
|
{header}
|
||||||
|
|
||||||
<SearchContainer />
|
<SearchContainer />
|
||||||
<NavigationContainer />
|
|
||||||
<ComposeFormContainer />
|
<div className='drawer__pager'>
|
||||||
</Drawer>
|
<div className='drawer__inner'>
|
||||||
|
<NavigationContainer />
|
||||||
|
<ComposeFormContainer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
|
||||||
|
{({ x }) =>
|
||||||
|
<div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
|
||||||
|
<SearchResultsContainer />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</Motion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect()(Compose);
|
export default connect(mapStateToProps)(injectIntl(Compose));
|
||||||
|
|
|
@ -43,9 +43,7 @@ const GettingStarted = ({ intl, me }) => {
|
||||||
|
|
||||||
<div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}>
|
<div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<div className='static-content getting-started'>
|
<div className='static-content getting-started'>
|
||||||
<p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
|
<p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p>
|
||||||
<p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
|
|
||||||
<p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
@ -6,7 +6,7 @@ import SettingToggle from '../../notifications/components/setting_toggle';
|
||||||
import SettingText from './setting_text';
|
import SettingText from './setting_text';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter by regular expressions' }
|
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }
|
||||||
});
|
});
|
||||||
|
|
||||||
const outerStyle = {
|
const outerStyle = {
|
||||||
|
@ -44,7 +44,7 @@ const ColumnSettings = React.createClass({
|
||||||
<span className='column-settings--section' style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
|
<span className='column-settings--section' style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
|
||||||
|
|
||||||
<div style={rowStyle}>
|
<div style={rowStyle}>
|
||||||
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reblogs' />} />
|
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={rowStyle}>
|
<div style={rowStyle}>
|
||||||
|
|
|
@ -5,7 +5,9 @@ import Column from '../ui/components/column';
|
||||||
import {
|
import {
|
||||||
refreshTimeline,
|
refreshTimeline,
|
||||||
updateTimeline,
|
updateTimeline,
|
||||||
deleteFromTimelines
|
deleteFromTimelines,
|
||||||
|
connectTimeline,
|
||||||
|
disconnectTimeline
|
||||||
} from '../../actions/timelines';
|
} from '../../actions/timelines';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
||||||
|
@ -44,6 +46,18 @@ const PublicTimeline = React.createClass({
|
||||||
|
|
||||||
subscription = createStream(accessToken, 'public', {
|
subscription = createStream(accessToken, 'public', {
|
||||||
|
|
||||||
|
connected () {
|
||||||
|
dispatch(connectTimeline('public'));
|
||||||
|
},
|
||||||
|
|
||||||
|
reconnected () {
|
||||||
|
dispatch(connectTimeline('public'));
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnected () {
|
||||||
|
dispatch(disconnectTimeline('public'));
|
||||||
|
},
|
||||||
|
|
||||||
received (data) {
|
received (data) {
|
||||||
switch(data.event) {
|
switch(data.event) {
|
||||||
case 'update':
|
case 'update':
|
||||||
|
|
|
@ -28,7 +28,7 @@ import {
|
||||||
import { ScrollContainer } from 'react-router-scroll';
|
import { ScrollContainer } from 'react-router-scroll';
|
||||||
import ColumnBackButton from '../../components/column_back_button';
|
import ColumnBackButton from '../../components/column_back_button';
|
||||||
import StatusContainer from '../../containers/status_container';
|
import StatusContainer from '../../containers/status_container';
|
||||||
import { openMedia } from '../../actions/modal';
|
import { openModal } from '../../actions/modal';
|
||||||
import { isMobile } from '../../is_mobile'
|
import { isMobile } from '../../is_mobile'
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
|
@ -99,7 +99,7 @@ const Status = React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
handleOpenMedia (media, index) {
|
handleOpenMedia (media, index) {
|
||||||
this.props.dispatch(openMedia(media, index));
|
this.props.dispatch(openModal('MEDIA', { media, index }));
|
||||||
},
|
},
|
||||||
|
|
||||||
handleReport (status) {
|
handleReport (status) {
|
||||||
|
|
|
@ -0,0 +1,133 @@
|
||||||
|
import LoadingIndicator from '../../../components/loading_indicator';
|
||||||
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import ExtendedVideoPlayer from '../../../components/extended_video_player';
|
||||||
|
import ImageLoader from 'react-imageloader';
|
||||||
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
import IconButton from '../../../components/icon_button';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
close: { id: 'lightbox.close', defaultMessage: 'Close' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const leftNavStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
padding: '30px 15px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '24px',
|
||||||
|
top: '0',
|
||||||
|
left: '-61px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
|
};
|
||||||
|
|
||||||
|
const rightNavStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
padding: '30px 15px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '24px',
|
||||||
|
top: '0',
|
||||||
|
right: '-61px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '4px',
|
||||||
|
right: '4px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const MediaModal = React.createClass({
|
||||||
|
|
||||||
|
propTypes: {
|
||||||
|
media: ImmutablePropTypes.list.isRequired,
|
||||||
|
index: React.PropTypes.number.isRequired,
|
||||||
|
onClose: React.PropTypes.func.isRequired,
|
||||||
|
intl: React.PropTypes.object.isRequired
|
||||||
|
},
|
||||||
|
|
||||||
|
getInitialState () {
|
||||||
|
return {
|
||||||
|
index: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
|
handleNextClick () {
|
||||||
|
this.setState({ index: (this.getIndex() + 1) % this.props.media.size});
|
||||||
|
},
|
||||||
|
|
||||||
|
handlePrevClick () {
|
||||||
|
this.setState({ index: (this.getIndex() - 1) % this.props.media.size});
|
||||||
|
},
|
||||||
|
|
||||||
|
handleKeyUp (e) {
|
||||||
|
switch(e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
this.handlePrevClick();
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
this.handleNextClick();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
window.addEventListener('keyup', this.handleKeyUp, false);
|
||||||
|
},
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
window.removeEventListener('keyup', this.handleKeyUp);
|
||||||
|
},
|
||||||
|
|
||||||
|
getIndex () {
|
||||||
|
return this.state.index !== null ? this.state.index : this.props.index;
|
||||||
|
},
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { media, intl, onClose } = this.props;
|
||||||
|
|
||||||
|
const index = this.getIndex();
|
||||||
|
const attachment = media.get(index);
|
||||||
|
const url = attachment.get('url');
|
||||||
|
|
||||||
|
let leftNav, rightNav, content;
|
||||||
|
|
||||||
|
leftNav = rightNav = content = '';
|
||||||
|
|
||||||
|
if (media.size > 1) {
|
||||||
|
leftNav = <div style={leftNavStyle} className='modal-container__nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
|
||||||
|
rightNav = <div style={rightNavStyle} className='modal-container__nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment.get('type') === 'image') {
|
||||||
|
content = <ImageLoader src={url} imgProps={{ style: { display: 'block' } }} />;
|
||||||
|
} else if (attachment.get('type') === 'gifv') {
|
||||||
|
content = <ExtendedVideoPlayer src={url} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='modal-root__modal media-modal'>
|
||||||
|
{leftNav}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<IconButton title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} style={closeStyle} />
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rightNav}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default injectIntl(MediaModal);
|
|
@ -0,0 +1,80 @@
|
||||||
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
|
import MediaModal from './media_modal';
|
||||||
|
import { TransitionMotion, spring } from 'react-motion';
|
||||||
|
|
||||||
|
const MODAL_COMPONENTS = {
|
||||||
|
'MEDIA': MediaModal
|
||||||
|
};
|
||||||
|
|
||||||
|
const ModalRoot = React.createClass({
|
||||||
|
|
||||||
|
propTypes: {
|
||||||
|
type: React.PropTypes.string,
|
||||||
|
props: React.PropTypes.object,
|
||||||
|
onClose: React.PropTypes.func.isRequired
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
|
handleKeyUp (e) {
|
||||||
|
if (e.key === 'Escape' && !!this.props.type) {
|
||||||
|
this.props.onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
window.addEventListener('keyup', this.handleKeyUp, false);
|
||||||
|
},
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
window.removeEventListener('keyup', this.handleKeyUp);
|
||||||
|
},
|
||||||
|
|
||||||
|
willEnter () {
|
||||||
|
return { opacity: 0, scale: 0.98 };
|
||||||
|
},
|
||||||
|
|
||||||
|
willLeave () {
|
||||||
|
return { opacity: spring(0), scale: spring(0.98) };
|
||||||
|
},
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { type, props, onClose } = this.props;
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
if (!!type) {
|
||||||
|
items.push({
|
||||||
|
key: type,
|
||||||
|
data: { type, props },
|
||||||
|
style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TransitionMotion
|
||||||
|
styles={items}
|
||||||
|
willEnter={this.willEnter}
|
||||||
|
willLeave={this.willLeave}>
|
||||||
|
{interpolatedStyles =>
|
||||||
|
<div className='modal-root'>
|
||||||
|
{interpolatedStyles.map(({ key, data: { type, props }, style }) => {
|
||||||
|
const SpecificComponent = MODAL_COMPONENTS[type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<div className='modal-root__overlay' style={{ opacity: style.opacity, transform: `translateZ(0px)` }} onClick={onClose} />
|
||||||
|
<div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
|
||||||
|
<SpecificComponent {...props} onClose={onClose} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</TransitionMotion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ModalRoot;
|
|
@ -1,15 +1,23 @@
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
const TabsBar = () => {
|
const TabsBar = React.createClass({
|
||||||
return (
|
|
||||||
<div className='tabs-bar'>
|
render () {
|
||||||
<Link className='tabs-bar__link' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /> <FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
|
return (
|
||||||
<Link className='tabs-bar__link' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /> <FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
|
<div className='tabs-bar'>
|
||||||
<Link className='tabs-bar__link' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /> <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
|
<Link className='tabs-bar__link primary' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
|
||||||
<Link className='tabs-bar__link' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link>
|
<Link className='tabs-bar__link primary' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
|
||||||
</div>
|
<Link className='tabs-bar__link primary' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
|
||||||
);
|
|
||||||
};
|
<Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public/local'><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></Link>
|
||||||
|
<Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public'><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></Link>
|
||||||
|
|
||||||
|
<Link className='tabs-bar__link primary' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
export default TabsBar;
|
export default TabsBar;
|
||||||
|
|
|
@ -1,170 +1,16 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import {
|
import { closeModal } from '../../../actions/modal';
|
||||||
closeModal,
|
import ModalRoot from '../components/modal_root';
|
||||||
decreaseIndexInModal,
|
|
||||||
increaseIndexInModal
|
|
||||||
} from '../../../actions/modal';
|
|
||||||
import Lightbox from '../../../components/lightbox';
|
|
||||||
import ImageLoader from 'react-imageloader';
|
|
||||||
import LoadingIndicator from '../../../components/loading_indicator';
|
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ExtendedVideoPlayer from '../../../components/extended_video_player';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
media: state.getIn(['modal', 'media']),
|
type: state.get('modal').modalType,
|
||||||
index: state.getIn(['modal', 'index']),
|
props: state.get('modal').modalProps
|
||||||
isVisible: state.getIn(['modal', 'open'])
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
onCloseClicked () {
|
onClose () {
|
||||||
dispatch(closeModal());
|
dispatch(closeModal());
|
||||||
},
|
},
|
||||||
|
|
||||||
onOverlayClicked () {
|
|
||||||
dispatch(closeModal());
|
|
||||||
},
|
|
||||||
|
|
||||||
onNextClicked () {
|
|
||||||
dispatch(increaseIndexInModal());
|
|
||||||
},
|
|
||||||
|
|
||||||
onPrevClicked () {
|
|
||||||
dispatch(decreaseIndexInModal());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageStyle = {
|
export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot);
|
||||||
display: 'block',
|
|
||||||
maxWidth: '80vw',
|
|
||||||
maxHeight: '80vh'
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadingStyle = {
|
|
||||||
width: '400px',
|
|
||||||
paddingBottom: '120px'
|
|
||||||
};
|
|
||||||
|
|
||||||
const preloader = () => (
|
|
||||||
<div className='modal-container--preloader' style={loadingStyle}>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const leftNavStyle = {
|
|
||||||
position: 'absolute',
|
|
||||||
background: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
padding: '30px 15px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '24px',
|
|
||||||
top: '0',
|
|
||||||
left: '-61px',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center'
|
|
||||||
};
|
|
||||||
|
|
||||||
const rightNavStyle = {
|
|
||||||
position: 'absolute',
|
|
||||||
background: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
padding: '30px 15px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '24px',
|
|
||||||
top: '0',
|
|
||||||
right: '-61px',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center'
|
|
||||||
};
|
|
||||||
|
|
||||||
const Modal = React.createClass({
|
|
||||||
|
|
||||||
propTypes: {
|
|
||||||
media: ImmutablePropTypes.list,
|
|
||||||
index: React.PropTypes.number.isRequired,
|
|
||||||
isVisible: React.PropTypes.bool,
|
|
||||||
onCloseClicked: React.PropTypes.func,
|
|
||||||
onOverlayClicked: React.PropTypes.func,
|
|
||||||
onNextClicked: React.PropTypes.func,
|
|
||||||
onPrevClicked: React.PropTypes.func
|
|
||||||
},
|
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
|
||||||
|
|
||||||
handleNextClick () {
|
|
||||||
this.props.onNextClicked();
|
|
||||||
},
|
|
||||||
|
|
||||||
handlePrevClick () {
|
|
||||||
this.props.onPrevClicked();
|
|
||||||
},
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
this._listener = e => {
|
|
||||||
if (!this.props.isVisible) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch(e.key) {
|
|
||||||
case 'ArrowLeft':
|
|
||||||
this.props.onPrevClicked();
|
|
||||||
break;
|
|
||||||
case 'ArrowRight':
|
|
||||||
this.props.onNextClicked();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keyup', this._listener);
|
|
||||||
},
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
|
||||||
window.removeEventListener('keyup', this._listener);
|
|
||||||
},
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { media, index, ...other } = this.props;
|
|
||||||
|
|
||||||
if (!media) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachment = media.get(index);
|
|
||||||
const url = attachment.get('url');
|
|
||||||
|
|
||||||
let leftNav, rightNav, content;
|
|
||||||
|
|
||||||
leftNav = rightNav = content = '';
|
|
||||||
|
|
||||||
if (media.size > 1) {
|
|
||||||
leftNav = <div style={leftNavStyle} className='modal-container--nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
|
|
||||||
rightNav = <div style={rightNavStyle} className='modal-container--nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attachment.get('type') === 'image') {
|
|
||||||
content = (
|
|
||||||
<ImageLoader
|
|
||||||
src={url}
|
|
||||||
preloader={preloader}
|
|
||||||
imgProps={{ style: imageStyle }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (attachment.get('type') === 'gifv') {
|
|
||||||
content = <ExtendedVideoPlayer src={url} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Lightbox {...other}>
|
|
||||||
{leftNav}
|
|
||||||
{content}
|
|
||||||
{rightNav}
|
|
||||||
</Lightbox>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(Modal);
|
|
||||||
|
|
|
@ -36,15 +36,33 @@ const UI = React.createClass({
|
||||||
this.setState({ width: window.innerWidth });
|
this.setState({ width: window.innerWidth });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleDragEnter (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!this.dragTargets) {
|
||||||
|
this.dragTargets = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dragTargets.indexOf(e.target) === -1) {
|
||||||
|
this.dragTargets.push(e.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
||||||
|
this.setState({ draggingOver: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
handleDragOver (e) {
|
handleDragOver (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
try {
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
} catch (err) {
|
||||||
|
|
||||||
if (e.dataTransfer.effectAllowed === 'all' || e.dataTransfer.effectAllowed === 'uninitialized') {
|
|
||||||
this.setState({ draggingOver: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
handleDrop (e) {
|
handleDrop (e) {
|
||||||
|
@ -57,14 +75,25 @@ const UI = React.createClass({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleDragLeave () {
|
handleDragLeave (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
|
||||||
|
|
||||||
|
if (this.dragTargets.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.setState({ draggingOver: false });
|
this.setState({ draggingOver: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
componentWillMount () {
|
componentWillMount () {
|
||||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||||
window.addEventListener('dragover', this.handleDragOver);
|
document.addEventListener('dragenter', this.handleDragEnter, false);
|
||||||
window.addEventListener('drop', this.handleDrop);
|
document.addEventListener('dragover', this.handleDragOver, false);
|
||||||
|
document.addEventListener('drop', this.handleDrop, false);
|
||||||
|
document.addEventListener('dragleave', this.handleDragLeave, false);
|
||||||
|
|
||||||
this.props.dispatch(refreshTimeline('home'));
|
this.props.dispatch(refreshTimeline('home'));
|
||||||
this.props.dispatch(refreshNotifications());
|
this.props.dispatch(refreshNotifications());
|
||||||
|
@ -72,8 +101,14 @@ const UI = React.createClass({
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
window.removeEventListener('resize', this.handleResize);
|
window.removeEventListener('resize', this.handleResize);
|
||||||
window.removeEventListener('dragover', this.handleDragOver);
|
document.removeEventListener('dragenter', this.handleDragEnter);
|
||||||
window.removeEventListener('drop', this.handleDrop);
|
document.removeEventListener('dragover', this.handleDragOver);
|
||||||
|
document.removeEventListener('drop', this.handleDrop);
|
||||||
|
document.removeEventListener('dragleave', this.handleDragLeave);
|
||||||
|
},
|
||||||
|
|
||||||
|
setRef (c) {
|
||||||
|
this.node = c;
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
@ -100,7 +135,7 @@ const UI = React.createClass({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='ui' onDragLeave={this.handleDragLeave}>
|
<div className='ui' ref={this.setRef}>
|
||||||
<TabsBar />
|
<TabsBar />
|
||||||
|
|
||||||
{mountedColumns}
|
{mountedColumns}
|
||||||
|
|
|
@ -25,7 +25,7 @@ const en = {
|
||||||
"getting_started.heading": "Getting started",
|
"getting_started.heading": "Getting started",
|
||||||
"getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.",
|
"getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.",
|
||||||
"getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.",
|
"getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.",
|
||||||
"getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}. {apps}.",
|
"getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.",
|
||||||
"column.home": "Home",
|
"column.home": "Home",
|
||||||
"column.community": "Local timeline",
|
"column.community": "Local timeline",
|
||||||
"column.public": "Federated timeline",
|
"column.public": "Federated timeline",
|
||||||
|
@ -40,7 +40,7 @@ const en = {
|
||||||
"compose_form.sensitive": "Mark media as sensitive",
|
"compose_form.sensitive": "Mark media as sensitive",
|
||||||
"compose_form.spoiler": "Hide text behind warning",
|
"compose_form.spoiler": "Hide text behind warning",
|
||||||
"compose_form.private": "Mark as private",
|
"compose_form.private": "Mark as private",
|
||||||
"compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}} to not leak your status?",
|
"compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
|
||||||
"compose_form.unlisted": "Do not display on public timelines",
|
"compose_form.unlisted": "Do not display on public timelines",
|
||||||
"navigation_bar.edit_profile": "Edit profile",
|
"navigation_bar.edit_profile": "Edit profile",
|
||||||
"navigation_bar.preferences": "Preferences",
|
"navigation_bar.preferences": "Preferences",
|
||||||
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
const fi = {
|
||||||
|
"column_back_button.label": "Takaisin",
|
||||||
|
"lightbox.close": "Sulje",
|
||||||
|
"loading_indicator.label": "Ladataan...",
|
||||||
|
"status.mention": "Mainitse @{name}",
|
||||||
|
"status.delete": "Poista",
|
||||||
|
"status.reply": "Vastaa",
|
||||||
|
"status.reblog": "Boostaa",
|
||||||
|
"status.favourite": "Tykkää",
|
||||||
|
"status.reblogged_by": "{name} boostattu",
|
||||||
|
"status.sensitive_warning": "Arkaluontoista sisältöä",
|
||||||
|
"status.sensitive_toggle": "Klikkaa nähdäksesi",
|
||||||
|
"video_player.toggle_sound": "Äänet päälle/pois",
|
||||||
|
"account.mention": "Mainitse @{name}",
|
||||||
|
"account.edit_profile": "Muokkaa",
|
||||||
|
"account.unblock": "Salli @{name}",
|
||||||
|
"account.unfollow": "Lopeta seuraaminen",
|
||||||
|
"account.block": "Estä @{name}",
|
||||||
|
"account.follow": "Seuraa",
|
||||||
|
"account.posts": "Postit",
|
||||||
|
"account.follows": "Seuraa",
|
||||||
|
"account.followers": "Seuraajia",
|
||||||
|
"account.follows_you": "Seuraa sinua",
|
||||||
|
"account.requested": "Odottaa hyväksyntää",
|
||||||
|
"getting_started.heading": "Päästä alkuun",
|
||||||
|
"getting_started.about_addressing": "Voit seurata ihmisiä jos tiedät heidän käyttäjänimensä ja domainin missä he ovat syöttämällä e-mail-esque osoitteen Etsi kenttään.",
|
||||||
|
"getting_started.about_shortcuts": "Jos etsimäsi henkilö on samassa domainissa kuin sinä, pelkkä käyttäjänimi kelpaa. Sama pätee kun mainitset ihmisiä statuksessasi",
|
||||||
|
"getting_started.open_source_notice": "Mastodon Mastodon on avoimen lähdekoodin ohjelma. Voit avustaa tai raportoida ongelmia githubissa {github}. {apps}.",
|
||||||
|
"column.home": "Koti",
|
||||||
|
"column.community": "Paikallinen aikajana",
|
||||||
|
"column.public": "Yhdistetty aikajana",
|
||||||
|
"column.notifications": "Ilmoitukset",
|
||||||
|
"tabs_bar.compose": "Luo",
|
||||||
|
"tabs_bar.home": "Koti",
|
||||||
|
"tabs_bar.mentions": "Maininnat",
|
||||||
|
"tabs_bar.public": "Yleinen aikajana",
|
||||||
|
"tabs_bar.notifications": "Ilmoitukset",
|
||||||
|
"compose_form.placeholder": "Mitä sinulla on mielessä?",
|
||||||
|
"compose_form.publish": "Toot",
|
||||||
|
"compose_form.sensitive": "Merkitse media herkäksi",
|
||||||
|
"compose_form.spoiler": "Piiloita teksti varoituksen taakse",
|
||||||
|
"compose_form.private": "Merkitse yksityiseksi",
|
||||||
|
"compose_form.privacy_disclaimer": "Sinun yksityinen status toimitetaan mainitsemallesi käyttäjille domaineissa {domains}. Luotatko {domainsCount, plural, one {tähän palvelimeen} other {näihin palvelimiin}}? Postauksen yksityisyys toimii van Mastodon palvelimilla. Jos {domains} {domainsCount, plural, one {ei ole Mastodon palvelin} other {eivät ole Mastodon palvelin}}, viestiin ei tule Yksityinen-merkintää, ja sitä voidaan boostata tai muuten tehdä näkyväksi muille vastaanottajille.",
|
||||||
|
"compose_form.unlisted": "Älä näytä julkisilla aikajanoilla",
|
||||||
|
"navigation_bar.edit_profile": "Muokkaa profiilia",
|
||||||
|
"navigation_bar.preferences": "Ominaisuudet",
|
||||||
|
"navigation_bar.community_timeline": "Paikallinen aikajana",
|
||||||
|
"navigation_bar.public_timeline": "Yleinen aikajana",
|
||||||
|
"navigation_bar.logout": "Kirjaudu ulos",
|
||||||
|
"reply_indicator.cancel": "Peruuta",
|
||||||
|
"search.placeholder": "Hae",
|
||||||
|
"search.account": "Tili",
|
||||||
|
"search.hashtag": "Hashtag",
|
||||||
|
"upload_button.label": "Lisää mediaa",
|
||||||
|
"upload_form.undo": "Peru",
|
||||||
|
"notification.follow": "{name} seurasi sinua",
|
||||||
|
"notification.favourite": "{name} tykkäsi statuksestasi",
|
||||||
|
"notification.reblog": "{name} boostasi statustasi",
|
||||||
|
"notification.mention": "{name} mainitsi sinut",
|
||||||
|
"notifications.column_settings.alert": "Työpöytä ilmoitukset",
|
||||||
|
"notifications.column_settings.show": "Näytä sarakkeessa",
|
||||||
|
"notifications.column_settings.follow": "Uusia seuraajia:",
|
||||||
|
"notifications.column_settings.favourite": "Tykkäyksiä:",
|
||||||
|
"notifications.column_settings.mention": "Mainintoja:",
|
||||||
|
"notifications.column_settings.reblog": "Boosteja:",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default fi;
|
|
@ -1,68 +1,91 @@
|
||||||
const fr = {
|
const fr = {
|
||||||
"account.block": "Bloquer",
|
|
||||||
"account.edit_profile": "Modifier le profil",
|
|
||||||
"account.followers": "Abonnés",
|
|
||||||
"account.follows": "Abonnements",
|
|
||||||
"account.follow": "Suivre",
|
|
||||||
"account.follows_you": "Vous suit",
|
|
||||||
"account.mention": "Mentionner",
|
|
||||||
"account.posts": "Statuts",
|
|
||||||
"account.requested": "Invitation envoyée",
|
|
||||||
"account.unblock": "Débloquer",
|
|
||||||
"account.unfollow": "Ne plus suivre",
|
|
||||||
"column_back_button.label": "Retour",
|
"column_back_button.label": "Retour",
|
||||||
"column.home": "Accueil",
|
|
||||||
"column.mentions": "Mentions",
|
|
||||||
"column.notifications": "Notifications",
|
|
||||||
"column.public": "Fil public",
|
|
||||||
"compose_form.placeholder": "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",
|
"lightbox.close": "Fermer",
|
||||||
"loading_indicator.label": "Chargement…",
|
"loading_indicator.label": "Chargement…",
|
||||||
"navigation_bar.edit_profile": "Modifier le profil",
|
|
||||||
"navigation_bar.logout": "Déconnexion",
|
|
||||||
"navigation_bar.preferences": "Préférences",
|
|
||||||
"navigation_bar.public_timeline": "Fil public",
|
|
||||||
"notification.favourite": "{name} a ajouté à ses favoris :",
|
|
||||||
"notification.follow": "{name} vous suit.",
|
|
||||||
"notification.mention": "{name} vous a mentionné⋅e :",
|
|
||||||
"notification.reblog": "{name} a partagé votre statut :",
|
|
||||||
"notifications.column_settings.alert": "Notifications locales",
|
|
||||||
"notifications.column_settings.favourite": "Favoris :",
|
|
||||||
"notifications.column_settings.follow": "Nouveaux abonnés :",
|
|
||||||
"notifications.column_settings.mention": "Mentions :",
|
|
||||||
"notifications.column_settings.reblog": "Partages :",
|
|
||||||
"notifications.column_settings.show": "Afficher dans la colonne",
|
|
||||||
"reply_indicator.cancel": "Annuler",
|
|
||||||
"search.account": "Compte",
|
|
||||||
"search.hashtag": "Mot-clé",
|
|
||||||
"search.placeholder": "Chercher",
|
|
||||||
"status.delete": "Effacer",
|
|
||||||
"status.favourite": "Ajouter aux favoris",
|
|
||||||
"status.mention": "Mentionner",
|
"status.mention": "Mentionner",
|
||||||
"status.reblogged_by": "{name} a partagé :",
|
"status.delete": "Effacer",
|
||||||
"status.reblog": "Partager",
|
|
||||||
"status.reply": "Répondre",
|
"status.reply": "Répondre",
|
||||||
"status.sensitive_toggle": "Cliquer pour dévoiler",
|
"status.reblog": "Partager",
|
||||||
|
"status.favourite": "Ajouter aux favoris",
|
||||||
|
"status.reblogged_by": "{name} a partagé :",
|
||||||
"status.sensitive_warning": "Contenu délicat",
|
"status.sensitive_warning": "Contenu délicat",
|
||||||
|
"status.sensitive_toggle": "Cliquer pour dévoiler",
|
||||||
|
"video_player.toggle_sound": "Mettre/Couper le son",
|
||||||
|
"account.mention": "Mentionner",
|
||||||
|
"account.edit_profile": "Modifier le profil",
|
||||||
|
"account.unblock": "Débloquer",
|
||||||
|
"account.unfollow": "Ne plus suivre",
|
||||||
|
"account.block": "Bloquer",
|
||||||
|
"account.mute": "Masquer",
|
||||||
|
"account.unmute": "Ne plus masquer",
|
||||||
|
"account.follow": "Suivre",
|
||||||
|
"account.posts": "Statuts",
|
||||||
|
"account.follows": "Abonnements",
|
||||||
|
"account.followers": "Abonnés",
|
||||||
|
"account.follows_you": "Vous suit",
|
||||||
|
"account.requested": "Invitation envoyée",
|
||||||
|
"account.report": "Signaler",
|
||||||
|
"account.disclaimer": "Ce compte est situé sur une autre instance. Les nombres peuvent être plus grands.",
|
||||||
|
"getting_started.heading": "Pour commencer",
|
||||||
|
"getting_started.about_addressing": "Vous pouvez suivre les statuts de 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.compose": "Composer",
|
||||||
"tabs_bar.home": "Accueil",
|
"tabs_bar.home": "Accueil",
|
||||||
"tabs_bar.mentions": "Mentions",
|
"tabs_bar.mentions": "Mentions",
|
||||||
|
"tabs_bar.public": "Fil public global",
|
||||||
"tabs_bar.notifications": "Notifications",
|
"tabs_bar.notifications": "Notifications",
|
||||||
"tabs_bar.public": "Public",
|
"compose_form.placeholder": "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_button.label": "Joindre un média",
|
||||||
"upload_form.undo": "Annuler",
|
"upload_form.undo": "Annuler",
|
||||||
"video_player.toggle_sound": "Mettre/Couper le son",
|
"notification.follow": "{name} vous suit.",
|
||||||
|
"notification.favourite": "{name} a ajouté à ses favoris :",
|
||||||
|
"notification.reblog": "{name} a partagé votre statut :",
|
||||||
|
"notification.mention": "{name} vous a mentionné⋅e :",
|
||||||
|
"notifications.column_settings.alert": "Notifications locales",
|
||||||
|
"notifications.column_settings.show": "Afficher dans la colonne",
|
||||||
|
"notifications.column_settings.follow": "Nouveaux abonnés :",
|
||||||
|
"notifications.column_settings.favourite": "Favoris :",
|
||||||
|
"notifications.column_settings.mention": "Mentions :",
|
||||||
|
"notifications.column_settings.reblog": "Partages :",
|
||||||
|
"privacy.public.short": "Public",
|
||||||
|
"privacy.public.long": "Afficher dans les fils publics",
|
||||||
|
"privacy.unlisted.short": "Non-listé",
|
||||||
|
"privacy.unlisted.long": "Ne pas afficher dans les fils publics",
|
||||||
|
"privacy.private.short": "Privé",
|
||||||
|
"privacy.private.long": "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;
|
export default fr;
|
||||||
|
|
|
@ -5,6 +5,7 @@ import hu from './hu';
|
||||||
import fr from './fr';
|
import fr from './fr';
|
||||||
import pt from './pt';
|
import pt from './pt';
|
||||||
import uk from './uk';
|
import uk from './uk';
|
||||||
|
import fi from './fi';
|
||||||
|
|
||||||
const locales = {
|
const locales = {
|
||||||
en,
|
en,
|
||||||
|
@ -13,7 +14,8 @@ const locales = {
|
||||||
hu,
|
hu,
|
||||||
fr,
|
fr,
|
||||||
pt,
|
pt,
|
||||||
uk
|
uk,
|
||||||
|
fi
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function getMessagesForLocale (locale) {
|
export default function getMessagesForLocale (locale) {
|
||||||
|
|
|
@ -33,7 +33,7 @@ import {
|
||||||
STATUS_FETCH_SUCCESS,
|
STATUS_FETCH_SUCCESS,
|
||||||
CONTEXT_FETCH_SUCCESS
|
CONTEXT_FETCH_SUCCESS
|
||||||
} from '../actions/statuses';
|
} from '../actions/statuses';
|
||||||
import { SEARCH_SUGGESTIONS_READY } from '../actions/search';
|
import { SEARCH_FETCH_SUCCESS } from '../actions/search';
|
||||||
import {
|
import {
|
||||||
NOTIFICATIONS_UPDATE,
|
NOTIFICATIONS_UPDATE,
|
||||||
NOTIFICATIONS_REFRESH_SUCCESS,
|
NOTIFICATIONS_REFRESH_SUCCESS,
|
||||||
|
@ -97,7 +97,7 @@ export default function accounts(state = initialState, action) {
|
||||||
return normalizeAccounts(state, action.accounts);
|
return normalizeAccounts(state, action.accounts);
|
||||||
case NOTIFICATIONS_REFRESH_SUCCESS:
|
case NOTIFICATIONS_REFRESH_SUCCESS:
|
||||||
case NOTIFICATIONS_EXPAND_SUCCESS:
|
case NOTIFICATIONS_EXPAND_SUCCESS:
|
||||||
case SEARCH_SUGGESTIONS_READY:
|
case SEARCH_FETCH_SUCCESS:
|
||||||
return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
|
return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
|
||||||
case TIMELINE_REFRESH_SUCCESS:
|
case TIMELINE_REFRESH_SUCCESS:
|
||||||
case TIMELINE_EXPAND_SUCCESS:
|
case TIMELINE_EXPAND_SUCCESS:
|
||||||
|
|
|
@ -1,31 +1,17 @@
|
||||||
import {
|
import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal';
|
||||||
MEDIA_OPEN,
|
|
||||||
MODAL_CLOSE,
|
|
||||||
MODAL_INDEX_DECREASE,
|
|
||||||
MODAL_INDEX_INCREASE
|
|
||||||
} from '../actions/modal';
|
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
const initialState = Immutable.Map({
|
const initialState = {
|
||||||
media: null,
|
modalType: null,
|
||||||
index: 0,
|
modalProps: {}
|
||||||
open: false
|
};
|
||||||
});
|
|
||||||
|
|
||||||
export default function modal(state = initialState, action) {
|
export default function modal(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case MEDIA_OPEN:
|
case MODAL_OPEN:
|
||||||
return state.withMutations(map => {
|
return { modalType: action.modalType, modalProps: action.modalProps };
|
||||||
map.set('media', action.media);
|
|
||||||
map.set('index', action.index);
|
|
||||||
map.set('open', true);
|
|
||||||
});
|
|
||||||
case MODAL_CLOSE:
|
case MODAL_CLOSE:
|
||||||
return state.set('open', false);
|
return initialState;
|
||||||
case MODAL_INDEX_DECREASE:
|
|
||||||
return state.update('index', index => (index - 1) % state.get('media').size);
|
|
||||||
case MODAL_INDEX_INCREASE:
|
|
||||||
return state.update('index', index => (index + 1) % state.get('media').size);
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,16 +23,16 @@ const initialState = Immutable.Map();
|
||||||
|
|
||||||
export default function relationships(state = initialState, action) {
|
export default function relationships(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case ACCOUNT_FOLLOW_SUCCESS:
|
case ACCOUNT_FOLLOW_SUCCESS:
|
||||||
case ACCOUNT_UNFOLLOW_SUCCESS:
|
case ACCOUNT_UNFOLLOW_SUCCESS:
|
||||||
case ACCOUNT_BLOCK_SUCCESS:
|
case ACCOUNT_BLOCK_SUCCESS:
|
||||||
case ACCOUNT_UNBLOCK_SUCCESS:
|
case ACCOUNT_UNBLOCK_SUCCESS:
|
||||||
case ACCOUNT_MUTE_SUCCESS:
|
case ACCOUNT_MUTE_SUCCESS:
|
||||||
case ACCOUNT_UNMUTE_SUCCESS:
|
case ACCOUNT_UNMUTE_SUCCESS:
|
||||||
return normalizeRelationship(state, action.relationship);
|
return normalizeRelationship(state, action.relationship);
|
||||||
case RELATIONSHIPS_FETCH_SUCCESS:
|
case RELATIONSHIPS_FETCH_SUCCESS:
|
||||||
return normalizeRelationships(state, action.relationships);
|
return normalizeRelationships(state, action.relationships);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
import {
|
import {
|
||||||
SEARCH_CHANGE,
|
SEARCH_CHANGE,
|
||||||
SEARCH_SUGGESTIONS_READY,
|
SEARCH_CLEAR,
|
||||||
SEARCH_RESET
|
SEARCH_FETCH_SUCCESS,
|
||||||
|
SEARCH_SHOW
|
||||||
} from '../actions/search';
|
} from '../actions/search';
|
||||||
|
import { COMPOSE_MENTION, COMPOSE_REPLY } from '../actions/compose';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
const initialState = Immutable.Map({
|
const initialState = Immutable.Map({
|
||||||
value: '',
|
value: '',
|
||||||
loaded_value: '',
|
submitted: false,
|
||||||
suggestions: []
|
hidden: false,
|
||||||
|
results: Immutable.Map()
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizeSuggestions = (state, value, accounts, hashtags, statuses) => {
|
const normalizeSuggestions = (state, value, accounts, hashtags, statuses) => {
|
||||||
|
@ -69,14 +72,24 @@ export default function search(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case SEARCH_CHANGE:
|
case SEARCH_CHANGE:
|
||||||
return state.set('value', action.value);
|
return state.set('value', action.value);
|
||||||
case SEARCH_SUGGESTIONS_READY:
|
case SEARCH_CLEAR:
|
||||||
return normalizeSuggestions(state, action.value, action.accounts, action.hashtags, action.statuses);
|
|
||||||
case SEARCH_RESET:
|
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.set('suggestions', []);
|
|
||||||
map.set('value', '');
|
map.set('value', '');
|
||||||
map.set('loaded_value', '');
|
map.set('results', Immutable.Map());
|
||||||
|
map.set('submitted', false);
|
||||||
|
map.set('hidden', false);
|
||||||
});
|
});
|
||||||
|
case SEARCH_SHOW:
|
||||||
|
return state.set('hidden', false);
|
||||||
|
case COMPOSE_REPLY:
|
||||||
|
case COMPOSE_MENTION:
|
||||||
|
return state.set('hidden', true);
|
||||||
|
case SEARCH_FETCH_SUCCESS:
|
||||||
|
return state.set('results', Immutable.Map({
|
||||||
|
accounts: Immutable.List(action.results.accounts.map(item => item.id)),
|
||||||
|
statuses: Immutable.List(action.results.statuses.map(item => item.id)),
|
||||||
|
hashtags: Immutable.List(action.results.hashtags)
|
||||||
|
})).set('submitted', true);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,7 +32,7 @@ import {
|
||||||
FAVOURITED_STATUSES_FETCH_SUCCESS,
|
FAVOURITED_STATUSES_FETCH_SUCCESS,
|
||||||
FAVOURITED_STATUSES_EXPAND_SUCCESS
|
FAVOURITED_STATUSES_EXPAND_SUCCESS
|
||||||
} from '../actions/favourites';
|
} from '../actions/favourites';
|
||||||
import { SEARCH_SUGGESTIONS_READY } from '../actions/search';
|
import { SEARCH_FETCH_SUCCESS } from '../actions/search';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
const normalizeStatus = (state, status) => {
|
const normalizeStatus = (state, status) => {
|
||||||
|
@ -109,7 +109,7 @@ export default function statuses(state = initialState, action) {
|
||||||
case NOTIFICATIONS_EXPAND_SUCCESS:
|
case NOTIFICATIONS_EXPAND_SUCCESS:
|
||||||
case FAVOURITED_STATUSES_FETCH_SUCCESS:
|
case FAVOURITED_STATUSES_FETCH_SUCCESS:
|
||||||
case FAVOURITED_STATUSES_EXPAND_SUCCESS:
|
case FAVOURITED_STATUSES_EXPAND_SUCCESS:
|
||||||
case SEARCH_SUGGESTIONS_READY:
|
case SEARCH_FETCH_SUCCESS:
|
||||||
return normalizeStatuses(state, action.statuses);
|
return normalizeStatuses(state, action.statuses);
|
||||||
case TIMELINE_DELETE:
|
case TIMELINE_DELETE:
|
||||||
return deleteStatus(state, action.id, action.references);
|
return deleteStatus(state, action.id, action.references);
|
||||||
|
|
|
@ -7,7 +7,9 @@ import {
|
||||||
TIMELINE_EXPAND_SUCCESS,
|
TIMELINE_EXPAND_SUCCESS,
|
||||||
TIMELINE_EXPAND_REQUEST,
|
TIMELINE_EXPAND_REQUEST,
|
||||||
TIMELINE_EXPAND_FAIL,
|
TIMELINE_EXPAND_FAIL,
|
||||||
TIMELINE_SCROLL_TOP
|
TIMELINE_SCROLL_TOP,
|
||||||
|
TIMELINE_CONNECT,
|
||||||
|
TIMELINE_DISCONNECT
|
||||||
} from '../actions/timelines';
|
} from '../actions/timelines';
|
||||||
import {
|
import {
|
||||||
REBLOG_SUCCESS,
|
REBLOG_SUCCESS,
|
||||||
|
@ -35,6 +37,7 @@ const initialState = Immutable.Map({
|
||||||
path: () => '/api/v1/timelines/home',
|
path: () => '/api/v1/timelines/home',
|
||||||
next: null,
|
next: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
online: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
top: true,
|
top: true,
|
||||||
unread: 0,
|
unread: 0,
|
||||||
|
@ -45,6 +48,7 @@ const initialState = Immutable.Map({
|
||||||
path: () => '/api/v1/timelines/public',
|
path: () => '/api/v1/timelines/public',
|
||||||
next: null,
|
next: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
online: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
top: true,
|
top: true,
|
||||||
unread: 0,
|
unread: 0,
|
||||||
|
@ -56,6 +60,7 @@ const initialState = Immutable.Map({
|
||||||
next: null,
|
next: null,
|
||||||
params: { local: true },
|
params: { local: true },
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
online: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
top: true,
|
top: true,
|
||||||
unread: 0,
|
unread: 0,
|
||||||
|
@ -300,6 +305,10 @@ export default function timelines(state = initialState, action) {
|
||||||
return filterTimelines(state, action.relationship, action.statuses);
|
return filterTimelines(state, action.relationship, action.statuses);
|
||||||
case TIMELINE_SCROLL_TOP:
|
case TIMELINE_SCROLL_TOP:
|
||||||
return updateTop(state, action.timeline, action.top);
|
return updateTop(state, action.timeline, action.top);
|
||||||
|
case TIMELINE_CONNECT:
|
||||||
|
return state.setIn([action.timeline, 'online'], true);
|
||||||
|
case TIMELINE_DISCONNECT:
|
||||||
|
return state.setIn([action.timeline, 'online'], false);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ const getStatuses = state => state.get('statuses');
|
||||||
const getAccounts = state => state.get('accounts');
|
const getAccounts = state => state.get('accounts');
|
||||||
|
|
||||||
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
|
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
|
||||||
const getAccountRelationship = (state, id) => state.getIn(['relationships', id]);
|
const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null);
|
||||||
|
|
||||||
export const makeGetAccount = () => {
|
export const makeGetAccount = () => {
|
||||||
return createSelector([getAccountBase, getAccountRelationship], (base, relationship) => {
|
return createSelector([getAccountBase, getAccountRelationship], (base, relationship) => {
|
||||||
|
|
|
@ -24,4 +24,17 @@ $(() => {
|
||||||
window.location.href = $(e.target).attr('href');
|
window.location.href = $(e.target).attr('href');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('.status__content__spoiler-link').on('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const contentEl = $(e.target).parent().parent().find('div');
|
||||||
|
|
||||||
|
if (contentEl.is(':visible')) {
|
||||||
|
contentEl.hide();
|
||||||
|
$(e.target).parent().attr('style', 'margin-bottom: 0');
|
||||||
|
} else {
|
||||||
|
contentEl.show();
|
||||||
|
$(e.target).parent().attr('style', null);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -311,6 +311,7 @@
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
color: $color3;
|
color: $color3;
|
||||||
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 100ms ease-in;
|
transition: all 100ms ease-in;
|
||||||
|
|
||||||
&:hover {
|
&:hover, &:active, &:focus {
|
||||||
background-color: lighten($color4, 7%);
|
background-color: lighten($color4, 7%);
|
||||||
transition: all 200ms ease-out;
|
transition: all 200ms ease-out;
|
||||||
}
|
}
|
||||||
|
@ -54,7 +54,7 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 100ms ease-in;
|
transition: all 100ms ease-in;
|
||||||
|
|
||||||
&:hover {
|
&:hover, &:active, &:focus {
|
||||||
color: lighten($color1, 33%);
|
color: lighten($color1, 33%);
|
||||||
transition: all 200ms ease-out;
|
transition: all 200ms ease-out;
|
||||||
}
|
}
|
||||||
|
@ -79,7 +79,7 @@
|
||||||
&.inverted {
|
&.inverted {
|
||||||
color: lighten($color1, 33%);
|
color: lighten($color1, 33%);
|
||||||
|
|
||||||
&:hover {
|
&:hover, &:active, &:focus {
|
||||||
color: lighten($color1, 26%);
|
color: lighten($color1, 26%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,7 +105,7 @@
|
||||||
outline: 0;
|
outline: 0;
|
||||||
transition: all 100ms ease-in;
|
transition: all 100ms ease-in;
|
||||||
|
|
||||||
&:hover {
|
&:hover, &:active, &:focus {
|
||||||
color: lighten($color1, 26%);
|
color: lighten($color1, 26%);
|
||||||
transition: all 200ms ease-out;
|
transition: all 200ms ease-out;
|
||||||
}
|
}
|
||||||
|
@ -424,6 +424,7 @@ a.status__content__spoiler-link {
|
||||||
|
|
||||||
.account__header__content {
|
.account__header__content {
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
word-break: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: $color3;
|
color: $color3;
|
||||||
|
@ -764,8 +765,19 @@ a.status__content__spoiler-link {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawer__pager {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.drawer__inner {
|
.drawer__inner {
|
||||||
//background: linear-gradient(rgba(lighten($color1, 13%), 1), rgba(lighten($color1, 13%), 0.65));
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
background: lighten($color1, 13%);
|
background: lighten($color1, 13%);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -773,7 +785,12 @@ a.status__content__spoiler-link {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex-grow: 1;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&.darker {
|
||||||
|
background: $color1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer__header {
|
.drawer__header {
|
||||||
|
@ -842,11 +859,25 @@ a.status__content__spoiler-link {
|
||||||
font-size:12px;
|
font-size:12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-bottom: 2px solid lighten($color1, 8%);
|
border-bottom: 2px solid lighten($color1, 8%);
|
||||||
|
transition: all 200ms linear;
|
||||||
|
|
||||||
|
.fa {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
border-bottom: 2px solid $color4;
|
border-bottom: 2px solid $color4;
|
||||||
color: $color4;
|
color: $color4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover, &:focus, &:active {
|
||||||
|
background: lighten($color1, 14%);
|
||||||
|
transition: all 100ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 360px) {
|
@media screen and (min-width: 360px) {
|
||||||
|
@ -854,6 +885,22 @@ a.status__content__spoiler-link {
|
||||||
margin: 10px;
|
margin: 10px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 600px) {
|
||||||
|
.tabs-bar__link {
|
||||||
|
.fa {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1025px) {
|
@media screen and (min-width: 1025px) {
|
||||||
|
@ -1102,11 +1149,9 @@ a.status__content__spoiler-link {
|
||||||
|
|
||||||
.getting-started {
|
.getting-started {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow-y: auto;
|
|
||||||
padding-bottom: 235px;
|
padding-bottom: 235px;
|
||||||
background: image-url('mastodon-getting-started.png') no-repeat bottom left;
|
background: image-url('mastodon-getting-started.png') no-repeat 0 100% local;
|
||||||
height: auto;
|
flex: 1 0 auto;
|
||||||
min-height: 100%;
|
|
||||||
|
|
||||||
p {
|
p {
|
||||||
color: $color2;
|
color: $color2;
|
||||||
|
@ -1224,26 +1269,6 @@ button.active i.fa-retweet {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search {
|
|
||||||
.fa {
|
|
||||||
color: $color3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search__input {
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
border: none;
|
|
||||||
padding: 10px;
|
|
||||||
padding-right: 30px;
|
|
||||||
font-family: inherit;
|
|
||||||
background: $color1;
|
|
||||||
color: $color3;
|
|
||||||
font-size: 14px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-indicator {
|
.loading-indicator {
|
||||||
color: $color2;
|
color: $color2;
|
||||||
}
|
}
|
||||||
|
@ -1286,7 +1311,7 @@ button.active i.fa-retweet {
|
||||||
color: $color3;
|
color: $color3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-container--nav {
|
.modal-container__nav {
|
||||||
color: $color5;
|
color: $color5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1640,7 +1665,7 @@ button.active i.fa-retweet {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover, &:active, &:focus {
|
||||||
img {
|
img {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
filter: none;
|
filter: none;
|
||||||
|
@ -1723,3 +1748,147 @@ button.active i.fa-retweet {
|
||||||
box-shadow: 2px 4px 6px rgba($color8, 0.1);
|
box-shadow: 2px 4px 6px rgba($color8, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__input {
|
||||||
|
padding-right: 30px;
|
||||||
|
color: $color2;
|
||||||
|
outline: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
padding: 10px;
|
||||||
|
padding-right: 30px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: $color1;
|
||||||
|
color: $color3;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
&::-moz-focus-inner {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-focus-inner, &:focus, &:active {
|
||||||
|
outline: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: lighten($color1, 4%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__icon {
|
||||||
|
.fa {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 2;
|
||||||
|
display: inline-block;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 100ms linear;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: $color2;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa-search {
|
||||||
|
transform: translateZ(0) rotate(90deg);
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateZ(0) rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa-times-circle {
|
||||||
|
top: 11px;
|
||||||
|
transform: translateZ(0) rotate(0deg);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
transform: translateZ(0) rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results__header {
|
||||||
|
color: lighten($color1, 26%);
|
||||||
|
background: lighten($color1, 2%);
|
||||||
|
border-bottom: 1px solid darken($color1, 4%);
|
||||||
|
padding: 15px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results__hashtag {
|
||||||
|
display: block;
|
||||||
|
padding: 10px;
|
||||||
|
color: $color2;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover, &:active, &:focus {
|
||||||
|
color: lighten($color2, 4%);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-root__overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
opacity: 0;
|
||||||
|
background: rgba($color8, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-root__container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
align-content: space-around;
|
||||||
|
z-index: 9999;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-root__modal {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-modal {
|
||||||
|
max-width: 80vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
img, video {
|
||||||
|
max-width: 80vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -97,6 +97,15 @@
|
||||||
a {
|
a {
|
||||||
color: $color4;
|
color: $color4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.status__content__spoiler-link {
|
||||||
|
color: $color5;
|
||||||
|
background: $color3;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: lighten($color3, 8%);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status__attachments {
|
.status__attachments {
|
||||||
|
@ -163,6 +172,15 @@
|
||||||
a {
|
a {
|
||||||
color: $color4;
|
color: $color4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.status__content__spoiler-link {
|
||||||
|
color: $color5;
|
||||||
|
background: $color3;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: lighten($color3, 8%);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailed-status__meta {
|
.detailed-status__meta {
|
||||||
|
|
|
@ -5,6 +5,9 @@ class AboutController < ApplicationController
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@description = Setting.site_description
|
@description = Setting.site_description
|
||||||
|
|
||||||
|
@user = User.new
|
||||||
|
@user.build_account
|
||||||
end
|
end
|
||||||
|
|
||||||
def more
|
def more
|
||||||
|
|
|
@ -9,6 +9,24 @@ class Admin::DomainBlocksController < ApplicationController
|
||||||
@blocks = DomainBlock.paginate(page: params[:page], per_page: 40)
|
@blocks = DomainBlock.paginate(page: params[:page], per_page: 40)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@domain_block = DomainBlock.new
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
@domain_block = DomainBlock.new(resource_params)
|
||||||
|
|
||||||
|
if @domain_block.save
|
||||||
|
DomainBlockWorker.perform_async(@domain_block.id)
|
||||||
|
redirect_to admin_domain_blocks_path, notice: 'Domain block is now being processed'
|
||||||
|
else
|
||||||
|
render action: :new
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def resource_params
|
||||||
|
params.require(:domain_block).permit(:domain, :severity)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,7 +7,7 @@ class Admin::ReportsController < ApplicationController
|
||||||
layout 'admin'
|
layout 'admin'
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@reports = Report.includes(:account, :target_account).paginate(page: params[:page], per_page: 40)
|
@reports = Report.includes(:account, :target_account).order('id desc').paginate(page: params[:page], per_page: 40)
|
||||||
@reports = params[:action_taken].present? ? @reports.resolved : @reports.unresolved
|
@reports = params[:action_taken].present? ? @reports.resolved : @reports.unresolved
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -16,19 +16,19 @@ class Admin::ReportsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolve
|
def resolve
|
||||||
@report.update(action_taken: true)
|
@report.update(action_taken: true, action_taken_by_account_id: current_account.id)
|
||||||
redirect_to admin_report_path(@report)
|
redirect_to admin_report_path(@report)
|
||||||
end
|
end
|
||||||
|
|
||||||
def suspend
|
def suspend
|
||||||
Admin::SuspensionWorker.perform_async(@report.target_account.id)
|
Admin::SuspensionWorker.perform_async(@report.target_account.id)
|
||||||
@report.update(action_taken: true)
|
Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id)
|
||||||
redirect_to admin_report_path(@report)
|
redirect_to admin_report_path(@report)
|
||||||
end
|
end
|
||||||
|
|
||||||
def silence
|
def silence
|
||||||
@report.target_account.update(silenced: true)
|
@report.target_account.update(silenced: true)
|
||||||
@report.update(action_taken: true)
|
Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id)
|
||||||
redirect_to admin_report_path(@report)
|
redirect_to admin_report_path(@report)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,12 @@ class Api::V1::AppsController < ApiController
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes), website: params[:website])
|
@app = Doorkeeper::Application.create!(name: app_params[:client_name], redirect_uri: app_params[:redirect_uris], scopes: (app_params[:scopes] || Doorkeeper.configuration.default_scopes), website: app_params[:website])
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def app_params
|
||||||
|
params.permit(:client_name, :redirect_uris, :scopes, :website)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,7 +7,7 @@ class Api::V1::FollowsController < ApiController
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
def create
|
def create
|
||||||
raise ActiveRecord::RecordNotFound if params[:uri].blank?
|
raise ActiveRecord::RecordNotFound if follow_params[:uri].blank?
|
||||||
|
|
||||||
@account = FollowService.new.call(current_user.account, target_uri).try(:target_account)
|
@account = FollowService.new.call(current_user.account, target_uri).try(:target_account)
|
||||||
render action: :show
|
render action: :show
|
||||||
|
@ -16,6 +16,10 @@ class Api::V1::FollowsController < ApiController
|
||||||
private
|
private
|
||||||
|
|
||||||
def target_uri
|
def target_uri
|
||||||
params[:uri].strip.gsub(/\A@/, '')
|
follow_params[:uri].strip.gsub(/\A@/, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
def follow_params
|
||||||
|
params.permit(:uri)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -10,10 +10,16 @@ class Api::V1::MediaController < ApiController
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@media = MediaAttachment.create!(account: current_user.account, file: params[:file])
|
@media = MediaAttachment.create!(account: current_user.account, file: media_params[:file])
|
||||||
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
|
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
|
||||||
render json: { error: 'File type of uploaded media could not be verified' }, status: 422
|
render json: { error: 'File type of uploaded media could not be verified' }, status: 422
|
||||||
rescue Paperclip::Error
|
rescue Paperclip::Error
|
||||||
render json: { error: 'Error processing thumbnail for uploaded media' }, status: 500
|
render json: { error: 'Error processing thumbnail for uploaded media' }, status: 500
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def media_params
|
||||||
|
params.permit(:file)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -12,13 +12,19 @@ class Api::V1::ReportsController < ApiController
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
status_ids = params[:status_ids].is_a?(Enumerable) ? params[:status_ids] : [params[:status_ids]]
|
status_ids = report_params[:status_ids].is_a?(Enumerable) ? report_params[:status_ids] : [report_params[:status_ids]]
|
||||||
|
|
||||||
@report = Report.create!(account: current_account,
|
@report = Report.create!(account: current_account,
|
||||||
target_account: Account.find(params[:account_id]),
|
target_account: Account.find(report_params[:account_id]),
|
||||||
status_ids: Status.find(status_ids).pluck(:id),
|
status_ids: Status.find(status_ids).pluck(:id),
|
||||||
comment: params[:comment])
|
comment: report_params[:comment])
|
||||||
|
|
||||||
render :show
|
render :show
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def report_params
|
||||||
|
params.permit(:account_id, :comment, status_ids: [])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -62,11 +62,11 @@ class Api::V1::StatusesController < ApiController
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids],
|
@status = PostStatusService.new.call(current_user.account, status_params[:status], status_params[:in_reply_to_id].blank? ? nil : Status.find(status_params[:in_reply_to_id]), media_ids: status_params[:media_ids],
|
||||||
sensitive: params[:sensitive],
|
sensitive: status_params[:sensitive],
|
||||||
spoiler_text: params[:spoiler_text],
|
spoiler_text: status_params[:spoiler_text],
|
||||||
visibility: params[:visibility],
|
visibility: status_params[:visibility],
|
||||||
application: doorkeeper_token.application)
|
application: doorkeeper_token.application)
|
||||||
render action: :show
|
render action: :show
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -111,4 +111,8 @@ class Api::V1::StatusesController < ApiController
|
||||||
@status = Status.find(params[:id])
|
@status = Status.find(params[:id])
|
||||||
raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account)
|
raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def status_params
|
||||||
|
params.permit(:status, :in_reply_to_id, :sensitive, :spoiler_text, :visibility, media_ids: [])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,8 +11,8 @@ class Api::V1::TimelinesController < ApiController
|
||||||
@statuses = cache_collection(@statuses)
|
@statuses = cache_collection(@statuses)
|
||||||
|
|
||||||
set_maps(@statuses)
|
set_maps(@statuses)
|
||||||
set_counters_maps(@statuses)
|
# set_counters_maps(@statuses)
|
||||||
set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
|
# set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
|
||||||
|
|
||||||
next_path = api_v1_home_timeline_url(max_id: @statuses.last.id) unless @statuses.empty?
|
next_path = api_v1_home_timeline_url(max_id: @statuses.last.id) unless @statuses.empty?
|
||||||
prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
|
prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
|
||||||
|
@ -27,8 +27,8 @@ class Api::V1::TimelinesController < ApiController
|
||||||
@statuses = cache_collection(@statuses)
|
@statuses = cache_collection(@statuses)
|
||||||
|
|
||||||
set_maps(@statuses)
|
set_maps(@statuses)
|
||||||
set_counters_maps(@statuses)
|
# set_counters_maps(@statuses)
|
||||||
set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
|
# set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
|
||||||
|
|
||||||
next_path = api_v1_public_timeline_url(max_id: @statuses.last.id) unless @statuses.empty?
|
next_path = api_v1_public_timeline_url(max_id: @statuses.last.id) unless @statuses.empty?
|
||||||
prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
|
prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
|
||||||
|
@ -44,8 +44,8 @@ class Api::V1::TimelinesController < ApiController
|
||||||
@statuses = cache_collection(@statuses)
|
@statuses = cache_collection(@statuses)
|
||||||
|
|
||||||
set_maps(@statuses)
|
set_maps(@statuses)
|
||||||
set_counters_maps(@statuses)
|
# set_counters_maps(@statuses)
|
||||||
set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
|
# set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
|
||||||
|
|
||||||
next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id) unless @statuses.empty?
|
next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id) unless @statuses.empty?
|
||||||
prev_path = api_v1_hashtag_timeline_url(params[:id], since_id: @statuses.first.id) unless @statuses.empty?
|
prev_path = api_v1_hashtag_timeline_url(params[:id], since_id: @statuses.first.id) unless @statuses.empty?
|
||||||
|
|
|
@ -39,7 +39,14 @@ class ApplicationController < ActionController::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_user_activity
|
def set_user_activity
|
||||||
current_user.touch(:current_sign_in_at) if !current_user.nil? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < 24.hours.ago)
|
return unless !current_user.nil? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < 24.hours.ago)
|
||||||
|
|
||||||
|
# Mark user as signed-in today
|
||||||
|
current_user.update_tracked_fields(request)
|
||||||
|
|
||||||
|
# If the sign in is after a two week break, we need to regenerate their feed
|
||||||
|
RegenerationWorker.perform_async(current_user.account_id) if current_user.last_sign_in_at < 14.days.ago
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_suspension
|
def check_suspension
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
||||||
skip_before_action :authenticate_resource_owner!
|
skip_before_action :authenticate_resource_owner!
|
||||||
|
|
||||||
|
before_action :set_locale
|
||||||
before_action :store_current_location
|
before_action :store_current_location
|
||||||
before_action :authenticate_resource_owner!
|
before_action :authenticate_resource_owner!
|
||||||
|
|
||||||
|
@ -11,4 +12,10 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
||||||
def store_current_location
|
def store_current_location
|
||||||
store_location_for(:user, request.url)
|
store_location_for(:user, request.url)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_locale
|
||||||
|
I18n.locale = current_user.try(:locale) || I18n.default_locale
|
||||||
|
rescue I18n::InvalidLocale
|
||||||
|
I18n.locale = I18n.default_locale
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Settings::ImportsController < ApplicationController
|
||||||
|
layout 'admin'
|
||||||
|
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :set_account
|
||||||
|
|
||||||
|
def show
|
||||||
|
@import = Import.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@import = Import.new(import_params)
|
||||||
|
@import.account = @account
|
||||||
|
|
||||||
|
if @import.save
|
||||||
|
ImportWorker.perform_async(@import.id)
|
||||||
|
redirect_to settings_import_path, notice: I18n.t('imports.success')
|
||||||
|
else
|
||||||
|
render action: :show
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_account
|
||||||
|
@account = current_user.account
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_params
|
||||||
|
params.require(:import).permit(:data, :type)
|
||||||
|
end
|
||||||
|
end
|
|
@ -10,6 +10,7 @@ module SettingsHelper
|
||||||
hu: 'Magyar',
|
hu: 'Magyar',
|
||||||
uk: 'Українська',
|
uk: 'Українська',
|
||||||
'zh-CN': '简体中文',
|
'zh-CN': '简体中文',
|
||||||
|
fi: 'Suomi',
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def human_locale(locale)
|
def human_locale(locale)
|
||||||
|
|
|
@ -4,4 +4,5 @@ module Mastodon
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
class NotPermittedError < Error; end
|
class NotPermittedError < Error; end
|
||||||
class ValidationError < Error; end
|
class ValidationError < Error; end
|
||||||
|
class RaceConditionError < Error; end
|
||||||
end
|
end
|
||||||
|
|
|
@ -52,7 +52,7 @@ class FeedManager
|
||||||
timeline_key = key(:home, into_account.id)
|
timeline_key = key(:home, into_account.id)
|
||||||
|
|
||||||
from_account.statuses.limit(MAX_ITEMS).each do |status|
|
from_account.statuses.limit(MAX_ITEMS).each do |status|
|
||||||
next if filter?(:home, status, into_account)
|
next if status.direct_visibility? || filter?(:home, status, into_account)
|
||||||
redis.zadd(timeline_key, status.id, status.id)
|
redis.zadd(timeline_key, status.id, status.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -10,17 +10,9 @@ class Feed
|
||||||
max_id = '+inf' if max_id.blank?
|
max_id = '+inf' if max_id.blank?
|
||||||
since_id = '-inf' if since_id.blank?
|
since_id = '-inf' if since_id.blank?
|
||||||
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:last).map(&:to_i)
|
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:last).map(&:to_i)
|
||||||
|
status_map = Status.where(id: unhydrated).cache_ids.map { |s| [s.id, s] }.to_h
|
||||||
|
|
||||||
# If we're after most recent items and none are there, we need to precompute the feed
|
unhydrated.map { |id| status_map[id] }.compact
|
||||||
if unhydrated.empty? && max_id == '+inf' && since_id == '-inf'
|
|
||||||
RegenerationWorker.perform_async(@account.id, @type)
|
|
||||||
@statuses = Status.send("as_#{@type}_timeline", @account).cache_ids.paginate_by_max_id(limit, nil, nil)
|
|
||||||
else
|
|
||||||
status_map = Status.where(id: unhydrated).cache_ids.map { |s| [s.id, s] }.to_h
|
|
||||||
@statuses = unhydrated.map { |id| status_map[id] }.compact
|
|
||||||
end
|
|
||||||
|
|
||||||
@statuses
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Import < ApplicationRecord
|
||||||
|
self.inheritance_column = false
|
||||||
|
|
||||||
|
enum type: [:following, :blocking]
|
||||||
|
|
||||||
|
belongs_to :account
|
||||||
|
|
||||||
|
FILE_TYPES = ['text/plain', 'text/csv'].freeze
|
||||||
|
|
||||||
|
has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV['PAPERCLIP_SECRET']
|
||||||
|
validates_attachment_content_type :data, content_type: FILE_TYPES
|
||||||
|
end
|
|
@ -3,6 +3,7 @@
|
||||||
class Report < ApplicationRecord
|
class Report < ApplicationRecord
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :target_account, class_name: 'Account'
|
belongs_to :target_account, class_name: 'Account'
|
||||||
|
belongs_to :action_taken_by_account, class_name: 'Account'
|
||||||
|
|
||||||
scope :unresolved, -> { where(action_taken: false) }
|
scope :unresolved, -> { where(action_taken: false) }
|
||||||
scope :resolved, -> { where(action_taken: true) }
|
scope :resolved, -> { where(action_taken: true) }
|
||||||
|
|
|
@ -188,7 +188,7 @@ class Status < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
before_validation do
|
before_validation do
|
||||||
text.strip!
|
text&.strip!
|
||||||
spoiler_text&.strip!
|
spoiler_text&.strip!
|
||||||
|
|
||||||
self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply
|
self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class BlockDomainService < BaseService
|
class BlockDomainService < BaseService
|
||||||
def call(domain, severity)
|
def call(domain_block)
|
||||||
DomainBlock.where(domain: domain).first_or_create!(domain: domain, severity: severity)
|
if domain_block.silence?
|
||||||
|
Account.where(domain: domain_block.domain).update_all(silenced: true)
|
||||||
if severity == :silence
|
|
||||||
Account.where(domain: domain).update_all(silenced: true)
|
|
||||||
else
|
else
|
||||||
Account.where(domain: domain).find_each do |account|
|
Account.where(domain: domain_block.domain).find_each do |account|
|
||||||
account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed?
|
account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed?
|
||||||
SuspendAccountService.new.call(account)
|
SuspendAccountService.new.call(account)
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,9 +4,15 @@ class FanOutOnWriteService < BaseService
|
||||||
# Push a status into home and mentions feeds
|
# Push a status into home and mentions feeds
|
||||||
# @param [Status] status
|
# @param [Status] status
|
||||||
def call(status)
|
def call(status)
|
||||||
|
raise Mastodon::RaceConditionError if status.visibility.nil?
|
||||||
|
|
||||||
deliver_to_self(status) if status.account.local?
|
deliver_to_self(status) if status.account.local?
|
||||||
|
|
||||||
status.direct_visibility? ? deliver_to_mentioned_followers(status) : deliver_to_followers(status)
|
if status.direct_visibility?
|
||||||
|
deliver_to_mentioned_followers(status)
|
||||||
|
else
|
||||||
|
deliver_to_followers(status)
|
||||||
|
end
|
||||||
|
|
||||||
return if status.account.silenced? || !status.public_visibility? || status.reblog?
|
return if status.account.silenced? || !status.public_visibility? || status.reblog?
|
||||||
|
|
||||||
|
|
|
@ -4,10 +4,10 @@ class PrecomputeFeedService < BaseService
|
||||||
# Fill up a user's home/mentions feed from DB and return a subset
|
# Fill up a user's home/mentions feed from DB and return a subset
|
||||||
# @param [Symbol] type :home or :mentions
|
# @param [Symbol] type :home or :mentions
|
||||||
# @param [Account] account
|
# @param [Account] account
|
||||||
def call(type, account)
|
def call(_, account)
|
||||||
Status.send("as_#{type}_timeline", account).limit(FeedManager::MAX_ITEMS).each do |status|
|
Status.as_home_timeline(account).limit(FeedManager::MAX_ITEMS).each do |status|
|
||||||
next if FeedManager.instance.filter?(type, status, account)
|
next if status.direct_visibility? || FeedManager.instance.filter?(:home, status, account)
|
||||||
redis.zadd(FeedManager.instance.key(type, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
|
redis.zadd(FeedManager.instance.key(:home, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
class SearchService < BaseService
|
class SearchService < BaseService
|
||||||
def call(query, limit, resolve = false, account = nil)
|
def call(query, limit, resolve = false, account = nil)
|
||||||
return if query.blank?
|
|
||||||
|
|
||||||
results = { accounts: [], hashtags: [], statuses: [] }
|
results = { accounts: [], hashtags: [], statuses: [] }
|
||||||
|
|
||||||
|
return results if query.blank?
|
||||||
|
|
||||||
if query =~ /\Ahttps?:\/\//
|
if query =~ /\Ahttps?:\/\//
|
||||||
resource = FetchRemoteResourceService.new.call(query)
|
resource = FetchRemoteResourceService.new.call(query)
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
.screenshot-with-signup
|
.screenshot-with-signup
|
||||||
.mascot= image_tag 'fluffy-elephant-friend.png'
|
.mascot= image_tag 'fluffy-elephant-friend.png'
|
||||||
|
|
||||||
= simple_form_for(:user, url: user_registration_path) do |f|
|
= simple_form_for(@user, url: user_registration_path) do |f|
|
||||||
= f.simple_fields_for :account do |ff|
|
= f.simple_fields_for :account do |ff|
|
||||||
= ff.input :username, autofocus: true, placeholder: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username') }
|
= ff.input :username, autofocus: true, placeholder: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username') }
|
||||||
|
|
||||||
|
|
|
@ -23,12 +23,12 @@
|
||||||
.counter{ class: active_nav_class(short_account_url(@account)) }
|
.counter{ class: active_nav_class(short_account_url(@account)) }
|
||||||
= link_to short_account_url(@account), class: 'u-url u-uid' do
|
= link_to short_account_url(@account), class: 'u-url u-uid' do
|
||||||
%span.counter-label= t('accounts.posts')
|
%span.counter-label= t('accounts.posts')
|
||||||
%span.counter-number= number_with_delimiter @account.statuses.count
|
%span.counter-number= number_with_delimiter @account.statuses_count
|
||||||
.counter{ class: active_nav_class(following_account_url(@account)) }
|
.counter{ class: active_nav_class(following_account_url(@account)) }
|
||||||
= link_to following_account_url(@account) do
|
= link_to following_account_url(@account) do
|
||||||
%span.counter-label= t('accounts.following')
|
%span.counter-label= t('accounts.following')
|
||||||
%span.counter-number= number_with_delimiter @account.following.count
|
%span.counter-number= number_with_delimiter @account.following_count
|
||||||
.counter{ class: active_nav_class(followers_account_url(@account)) }
|
.counter{ class: active_nav_class(followers_account_url(@account)) }
|
||||||
= link_to followers_account_url(@account) do
|
= link_to followers_account_url(@account) do
|
||||||
%span.counter-label= t('accounts.followers')
|
%span.counter-label= t('accounts.followers')
|
||||||
%span.counter-number= number_with_delimiter @account.followers.count
|
%span.counter-number= number_with_delimiter @account.followers_count
|
||||||
|
|
|
@ -47,13 +47,13 @@
|
||||||
|
|
||||||
%tr
|
%tr
|
||||||
%th Follows
|
%th Follows
|
||||||
%td= @account.following.count
|
%td= @account.following_count
|
||||||
%tr
|
%tr
|
||||||
%th Followers
|
%th Followers
|
||||||
%td= @account.followers.count
|
%td= @account.followers_count
|
||||||
%tr
|
%tr
|
||||||
%th Statuses
|
%th Statuses
|
||||||
%td= @account.statuses.count
|
%td= @account.statuses_count
|
||||||
%tr
|
%tr
|
||||||
%th Media attachments
|
%th Media attachments
|
||||||
%td
|
%td
|
||||||
|
|
|
@ -14,3 +14,4 @@
|
||||||
%td= block.severity
|
%td= block.severity
|
||||||
|
|
||||||
= will_paginate @blocks, pagination_options
|
= will_paginate @blocks, pagination_options
|
||||||
|
= link_to 'Add new', new_admin_domain_block_path, class: 'button'
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
- content_for :page_title do
|
||||||
|
New domain block
|
||||||
|
|
||||||
|
= simple_form_for @domain_block, url: admin_domain_blocks_path do |f|
|
||||||
|
= render 'shared/error_messages', object: @domain_block
|
||||||
|
|
||||||
|
%p.hint The domain block will not prevent creation of account entries in the database, but will retroactively and automatically apply specific moderation methods on those accounts.
|
||||||
|
|
||||||
|
= f.input :domain, placeholder: 'Domain'
|
||||||
|
= f.input :severity, collection: DomainBlock.severities.keys, wrapper: :with_label, include_blank: false
|
||||||
|
|
||||||
|
%p.hint
|
||||||
|
%strong Silence
|
||||||
|
will make the account's posts invisible to anyone who isn't following them.
|
||||||
|
%strong Suspend
|
||||||
|
will remove all of the account's content, media, and profile data.
|
||||||
|
.actions
|
||||||
|
= f.button :button, 'Create block', type: :submit
|
|
@ -8,20 +8,25 @@
|
||||||
%li= filter_link_to 'Unresolved', action_taken: nil
|
%li= filter_link_to 'Unresolved', action_taken: nil
|
||||||
%li= filter_link_to 'Resolved', action_taken: '1'
|
%li= filter_link_to 'Resolved', action_taken: '1'
|
||||||
|
|
||||||
%table.table
|
= form_tag do
|
||||||
%thead
|
|
||||||
%tr
|
%table.table
|
||||||
%th ID
|
%thead
|
||||||
%th Target
|
|
||||||
%th Reported by
|
|
||||||
%th Comment
|
|
||||||
%th
|
|
||||||
%tbody
|
|
||||||
- @reports.each do |report|
|
|
||||||
%tr
|
%tr
|
||||||
%td= "##{report.id}"
|
%th
|
||||||
%td= link_to report.target_account.acct, admin_account_path(report.target_account.id)
|
%th ID
|
||||||
%td= link_to report.account.acct, admin_account_path(report.account.id)
|
%th Target
|
||||||
%td= truncate(report.comment, length: 30, separator: ' ')
|
%th Reported by
|
||||||
%td= table_link_to 'circle', 'View', admin_report_path(report)
|
%th Comment
|
||||||
|
%th
|
||||||
|
%tbody
|
||||||
|
- @reports.each do |report|
|
||||||
|
%tr
|
||||||
|
%td= check_box_tag 'select', report.id
|
||||||
|
%td= "##{report.id}"
|
||||||
|
%td= link_to report.target_account.acct, admin_account_path(report.target_account.id)
|
||||||
|
%td= link_to report.account.acct, admin_account_path(report.account.id)
|
||||||
|
%td= truncate(report.comment, length: 30, separator: ' ')
|
||||||
|
%td= table_link_to 'circle', 'View', admin_report_path(report)
|
||||||
|
|
||||||
= will_paginate @reports, pagination_options
|
= will_paginate @reports, pagination_options
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
= link_to remove_admin_report_path(@report, status_id: status.id), method: :post, class: 'icon-button', style: 'font-size: 24px; width: 24px; height: 24px', title: 'Delete' do
|
= link_to remove_admin_report_path(@report, status_id: status.id), method: :post, class: 'icon-button', style: 'font-size: 24px; width: 24px; height: 24px', title: 'Delete' do
|
||||||
= fa_icon 'trash'
|
= fa_icon 'trash'
|
||||||
|
|
||||||
- unless @report.action_taken?
|
- if !@report.action_taken?
|
||||||
%hr/
|
%hr/
|
||||||
|
|
||||||
%div{ style: 'overflow: hidden' }
|
%div{ style: 'overflow: hidden' }
|
||||||
|
@ -36,3 +36,9 @@
|
||||||
= link_to 'Suspend account', suspend_admin_report_path(@report), method: :post, class: 'button'
|
= link_to 'Suspend account', suspend_admin_report_path(@report), method: :post, class: 'button'
|
||||||
%div{ style: 'float: left' }
|
%div{ style: 'float: left' }
|
||||||
= link_to 'Mark as resolved', resolve_admin_report_path(@report), method: :post, class: 'button'
|
= link_to 'Mark as resolved', resolve_admin_report_path(@report), method: :post, class: 'button'
|
||||||
|
- elsif !@report.action_taken_by_account.nil?
|
||||||
|
%hr/
|
||||||
|
|
||||||
|
%p
|
||||||
|
%strong Action taken by:
|
||||||
|
= @report.action_taken_by_account.acct
|
||||||
|
|
|
@ -6,6 +6,6 @@ node(:note) { |account| Formatter.instance.simplified_format(account)
|
||||||
node(:url) { |account| TagManager.instance.url_for(account) }
|
node(:url) { |account| TagManager.instance.url_for(account) }
|
||||||
node(:avatar) { |account| full_asset_url(account.avatar.url(:original)) }
|
node(:avatar) { |account| full_asset_url(account.avatar.url(:original)) }
|
||||||
node(:header) { |account| full_asset_url(account.header.url(:original)) }
|
node(:header) { |account| full_asset_url(account.header.url(:original)) }
|
||||||
node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : (account.try(:followers_count) || account.followers.count) }
|
node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : account.followers_count }
|
||||||
node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : (account.try(:following_count) || account.following.count) }
|
node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : account.following_count }
|
||||||
node(:statuses_count) { |account| defined?(@statuses_counts_map) ? (@statuses_counts_map[account.id] || 0) : (account.try(:statuses_count) || account.statuses.count) }
|
node(:statuses_count) { |account| defined?(@statuses_counts_map) ? (@statuses_counts_map[account.id] || 0) : account.statuses_count }
|
||||||
|
|
|
@ -3,8 +3,8 @@ attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, :sensitiv
|
||||||
node(:uri) { |status| TagManager.instance.uri_for(status) }
|
node(:uri) { |status| TagManager.instance.uri_for(status) }
|
||||||
node(:content) { |status| Formatter.instance.format(status) }
|
node(:content) { |status| Formatter.instance.format(status) }
|
||||||
node(:url) { |status| TagManager.instance.url_for(status) }
|
node(:url) { |status| TagManager.instance.url_for(status) }
|
||||||
node(:reblogs_count) { |status| defined?(@reblogs_counts_map) ? (@reblogs_counts_map[status.id] || 0) : (status.try(:reblogs_count) || status.reblogs.count) }
|
node(:reblogs_count) { |status| defined?(@reblogs_counts_map) ? (@reblogs_counts_map[status.id] || 0) : status.reblogs_count }
|
||||||
node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : (status.try(:favourites_count) || status.favourites.count) }
|
node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : status.favourites_count }
|
||||||
|
|
||||||
child :application do
|
child :application do
|
||||||
extends 'api/v1/apps/show'
|
extends 'api/v1/apps/show'
|
||||||
|
|
|
@ -12,6 +12,15 @@
|
||||||
.content-wrapper
|
.content-wrapper
|
||||||
.content
|
.content
|
||||||
%h2= yield :page_title
|
%h2= yield :page_title
|
||||||
|
|
||||||
|
- if flash[:notice]
|
||||||
|
.flash-message.notice
|
||||||
|
%strong= flash[:notice]
|
||||||
|
|
||||||
|
- if flash[:alert]
|
||||||
|
.flash-message.alert
|
||||||
|
%strong= flash[:alert]
|
||||||
|
|
||||||
= yield
|
= yield
|
||||||
|
|
||||||
= render template: "layouts/application", locals: { body_classes: 'admin' }
|
= render template: "layouts/application", locals: { body_classes: 'admin' }
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
- content_for :page_title do
|
||||||
|
= t('settings.import')
|
||||||
|
|
||||||
|
%p.hint= t('imports.preface')
|
||||||
|
|
||||||
|
= simple_form_for @import, url: settings_import_path do |f|
|
||||||
|
= f.input :type, collection: Import.types.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |type| I18n.t("imports.types.#{type}") }
|
||||||
|
= f.input :data, wrapper: :with_label, hint: t('simple_form.hints.imports.data')
|
||||||
|
|
||||||
|
.actions
|
||||||
|
= f.button :button, t('imports.upload'), type: :submit
|
|
@ -9,8 +9,10 @@
|
||||||
|
|
||||||
.status__content.e-content.p-name.emojify<
|
.status__content.e-content.p-name.emojify<
|
||||||
- unless status.spoiler_text.blank?
|
- unless status.spoiler_text.blank?
|
||||||
%p= status.spoiler_text
|
%p{ style: 'margin-bottom: 0' }<
|
||||||
%div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
|
%span>= "#{status.spoiler_text} "
|
||||||
|
%a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
|
||||||
|
%div{ style: "display: #{status.spoiler_text.blank? ? 'block' : 'none'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
|
||||||
|
|
||||||
- unless status.media_attachments.empty?
|
- unless status.media_attachments.empty?
|
||||||
- if status.media_attachments.first.video?
|
- if status.media_attachments.first.video?
|
||||||
|
@ -39,11 +41,11 @@
|
||||||
·
|
·
|
||||||
%span<
|
%span<
|
||||||
= fa_icon('retweet')
|
= fa_icon('retweet')
|
||||||
%span= status.reblogs.count
|
%span= status.reblogs_count
|
||||||
·
|
·
|
||||||
%span<
|
%span<
|
||||||
= fa_icon('star')
|
= fa_icon('star')
|
||||||
%span= status.favourites.count
|
%span= status.favourites_count
|
||||||
|
|
||||||
- if user_signed_in?
|
- if user_signed_in?
|
||||||
·
|
·
|
||||||
|
|
|
@ -14,8 +14,10 @@
|
||||||
|
|
||||||
.status__content.e-content.p-name.emojify<
|
.status__content.e-content.p-name.emojify<
|
||||||
- unless status.spoiler_text.blank?
|
- unless status.spoiler_text.blank?
|
||||||
%p= status.spoiler_text
|
%p{ style: 'margin-bottom: 0' }<
|
||||||
%div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
|
%span>= "#{status.spoiler_text} "
|
||||||
|
%a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
|
||||||
|
%div{ style: "display: #{status.spoiler_text.blank? ? 'block' : 'none'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
|
||||||
|
|
||||||
- unless status.media_attachments.empty?
|
- unless status.media_attachments.empty?
|
||||||
.status__attachments
|
.status__attachments
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
class AfterRemoteFollowRequestWorker
|
class AfterRemoteFollowRequestWorker
|
||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
|
||||||
sidekiq_options retry: 5
|
sidekiq_options queue: 'pull', retry: 5
|
||||||
|
|
||||||
def perform(follow_request_id)
|
def perform(follow_request_id)
|
||||||
follow_request = FollowRequest.find(follow_request_id)
|
follow_request = FollowRequest.find(follow_request_id)
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
class AfterRemoteFollowWorker
|
class AfterRemoteFollowWorker
|
||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
|
||||||
sidekiq_options retry: 5
|
sidekiq_options queue: 'pull', retry: 5
|
||||||
|
|
||||||
def perform(follow_id)
|
def perform(follow_id)
|
||||||
follow = Follow.find(follow_id)
|
follow = Follow.find(follow_id)
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class DomainBlockWorker
|
||||||
|
include Sidekiq::Worker
|
||||||
|
|
||||||
|
def perform(domain_block_id)
|
||||||
|
BlockDomainService.new.call(DomainBlock.find(domain_block_id))
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,54 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'csv'
|
||||||
|
|
||||||
|
class ImportWorker
|
||||||
|
include Sidekiq::Worker
|
||||||
|
|
||||||
|
sidekiq_options queue: 'pull', retry: false
|
||||||
|
|
||||||
|
def perform(import_id)
|
||||||
|
import = Import.find(import_id)
|
||||||
|
|
||||||
|
case import.type
|
||||||
|
when 'blocking'
|
||||||
|
process_blocks(import)
|
||||||
|
when 'following'
|
||||||
|
process_follows(import)
|
||||||
|
end
|
||||||
|
|
||||||
|
import.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def process_blocks(import)
|
||||||
|
from_account = import.account
|
||||||
|
|
||||||
|
CSV.foreach(import.data.path) do |row|
|
||||||
|
next if row.size != 1
|
||||||
|
|
||||||
|
begin
|
||||||
|
target_account = FollowRemoteAccountService.new.call(row[0])
|
||||||
|
next if target_account.nil?
|
||||||
|
BlockService.new.call(from_account, target_account)
|
||||||
|
rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError
|
||||||
|
next
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_follows(import)
|
||||||
|
from_account = import.account
|
||||||
|
|
||||||
|
CSV.foreach(import.data.path) do |row|
|
||||||
|
next if row.size != 1
|
||||||
|
|
||||||
|
begin
|
||||||
|
FollowService.new.call(from_account, row[0])
|
||||||
|
rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError
|
||||||
|
next
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,7 +3,7 @@
|
||||||
class LinkCrawlWorker
|
class LinkCrawlWorker
|
||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
|
||||||
sidekiq_options retry: false
|
sidekiq_options queue: 'pull', retry: false
|
||||||
|
|
||||||
def perform(status_id)
|
def perform(status_id)
|
||||||
FetchLinkCardService.new.call(Status.find(status_id))
|
FetchLinkCardService.new.call(Status.find(status_id))
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
class MergeWorker
|
class MergeWorker
|
||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
|
||||||
|
sidekiq_options queue: 'pull'
|
||||||
|
|
||||||
def perform(from_account_id, into_account_id)
|
def perform(from_account_id, into_account_id)
|
||||||
FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id))
|
FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id))
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
class NotificationWorker
|
class NotificationWorker
|
||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
|
||||||
sidekiq_options retry: 5
|
sidekiq_options queue: 'push', retry: 5
|
||||||
|
|
||||||
def perform(xml, source_account_id, target_account_id)
|
def perform(xml, source_account_id, target_account_id)
|
||||||
SendInteractionService.new.call(xml, Account.find(source_account_id), Account.find(target_account_id))
|
SendInteractionService.new.call(xml, Account.find(source_account_id), Account.find(target_account_id))
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
class ProcessingWorker
|
class ProcessingWorker
|
||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
|
||||||
sidekiq_options backtrace: true
|
sidekiq_options queue: 'pull', backtrace: true
|
||||||
|
|
||||||
def perform(account_id, body)
|
def perform(account_id, body)
|
||||||
ProcessFeedService.new.call(body, Account.find(account_id))
|
ProcessFeedService.new.call(body, Account.find(account_id))
|
||||||
|
|
|
@ -3,7 +3,9 @@
|
||||||
class RegenerationWorker
|
class RegenerationWorker
|
||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
|
||||||
def perform(account_id, timeline_type)
|
sidekiq_options queue: 'pull', backtrace: true
|
||||||
PrecomputeFeedService.new.call(timeline_type, Account.find(account_id))
|
|
||||||
|
def perform(account_id, _ = :home)
|
||||||
|
PrecomputeFeedService.new.call(:home, Account.find(account_id))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
class SalmonWorker
|
class SalmonWorker
|
||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
|
||||||
sidekiq_options backtrace: true
|
sidekiq_options queue: 'pull', backtrace: true
|
||||||
|
|
||||||
def perform(account_id, body)
|
def perform(account_id, body)
|
||||||
ProcessInteractionService.new.call(body, Account.find(account_id))
|
ProcessInteractionService.new.call(body, Account.find(account_id))
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
class ThreadResolveWorker
|
class ThreadResolveWorker
|
||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
|
||||||
sidekiq_options retry: false
|
sidekiq_options queue: 'pull', retry: false
|
||||||
|
|
||||||
def perform(child_status_id, parent_url)
|
def perform(child_status_id, parent_url)
|
||||||
child_status = Status.find(child_status_id)
|
child_status = Status.find(child_status_id)
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
class UnmergeWorker
|
class UnmergeWorker
|
||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
|
||||||
|
sidekiq_options queue: 'pull'
|
||||||
|
|
||||||
def perform(from_account_id, into_account_id)
|
def perform(from_account_id, into_account_id)
|
||||||
FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id))
|
FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id))
|
||||||
end
|
end
|
||||||
|
|
|
@ -24,7 +24,7 @@ module Mastodon
|
||||||
|
|
||||||
# The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
|
# The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
|
||||||
# config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
|
# config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
|
||||||
config.i18n.available_locales = [:en, :de, :es, :pt, :fr, :hu, :uk, 'zh-CN']
|
config.i18n.available_locales = [:en, :de, :es, :pt, :fr, :hu, :uk, 'zh-CN', :fi]
|
||||||
config.i18n.default_locale = :en
|
config.i18n.default_locale = :en
|
||||||
|
|
||||||
# config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb')
|
# config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb')
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
|
Rack::Timeout::Logger.disable
|
||||||
|
Rack::Timeout.service_timeout = false
|
||||||
|
|
||||||
if Rails.env.production?
|
if Rails.env.production?
|
||||||
Rack::Timeout.service_timeout = 90
|
Rack::Timeout.service_timeout = 90
|
||||||
Rack::Timeout::Logger.disable
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
---
|
||||||
|
fi:
|
||||||
|
devise:
|
||||||
|
confirmations:
|
||||||
|
confirmed: Sähköpostisi on onnistuneesti vahvistettu.
|
||||||
|
send_instructions: Saat kohta sähköpostiisi ohjeet kuinka voit aktivoida tilisi.
|
||||||
|
send_paranoid_instructions: Jos sähköpostisi on meidän tietokannassa, saat pian ohjeet sen varmentamiseen.
|
||||||
|
failure:
|
||||||
|
already_authenticated: Olet jo kirjautunut sisään.
|
||||||
|
inactive: Tiliäsi ei ole viellä aktivoitu.
|
||||||
|
invalid: Virheellinen %{authentication_keys} tai salasana.
|
||||||
|
last_attempt: Sinulla on yksi yritys jäljellä tai tili lukitaan.
|
||||||
|
locked: Tili on lukittu.
|
||||||
|
not_found_in_database: Virheellinen %{authentication_keys} tai salasana.
|
||||||
|
timeout: Sessiosi on umpeutunut. Kirjaudu sisään jatkaaksesi.
|
||||||
|
unauthenticated: Sinun tarvitsee kirjautua sisään tai rekisteröityä jatkaaksesi.
|
||||||
|
unconfirmed: Sinun tarvitsee varmentaa sähköpostisi jatkaaksesi.
|
||||||
|
mailer:
|
||||||
|
confirmation_instructions:
|
||||||
|
subject: 'Mastodon: Varmistus ohjeet'
|
||||||
|
password_change:
|
||||||
|
subject: 'Mastodon: Salasana vaihdettu'
|
||||||
|
reset_password_instructions:
|
||||||
|
subject: 'Mastodon: Salasanan vaihto ohjeet'
|
||||||
|
unlock_instructions:
|
||||||
|
subject: 'Mastodon: Avauksen ohjeet'
|
||||||
|
omniauth_callbacks:
|
||||||
|
failure: Varmennus %{kind} epäonnistui koska "%{reason}".
|
||||||
|
success: Onnistuneesti varmennettu %{kind} tilillä.
|
||||||
|
passwords:
|
||||||
|
no_token: Et pääse tälle sivulle ilman salasanan vaihto sähköpostia. Jos tulet tämmöisestä postista, varmista että sinulla on täydellinen URL.
|
||||||
|
send_instructions: Saat sähköpostitse ohjeet salasanan palautukseen muutaman minuutin kuluessa.
|
||||||
|
send_paranoid_instructions: Jos sähköpostisi on meidän tietokannassa, saat pian ohjeet salasanan palautukseen.
|
||||||
|
updated: Salasanasi vaihdettu onnistuneesti. Olet nyt kirjautunut sisään.
|
||||||
|
updated_not_active: Salasanasi vaihdettu onnistuneesti.
|
||||||
|
registrations:
|
||||||
|
destroyed: Näkemiin! Tilisi on onnistuneesti peruttu. Toivottavasti näemme joskus uudestaan.
|
||||||
|
signed_up: Tervetuloa! Rekisteröitymisesi onnistu.
|
||||||
|
signed_up_but_inactive: Olet onnistuneesti rekisteröitynyt, mutta emme voi kirjata sinua sisään koska tiliäsi ei ole viellä aktivoitu.
|
||||||
|
signed_up_but_locked: Olet onnistuneesti rekisteröitynyt, mutta emme voi kirjata sinua sisään koska tilisi on lukittu.
|
||||||
|
signed_up_but_unconfirmed: Varmistuslinkki on lähetty sähköpostiisi. Seuraa sitä jotta tilisi voidaan aktivoida.
|
||||||
|
update_needs_confirmation: Tilisi on onnistuneesti päivitetty, mutta meidän tarvitsee vahvistaa sinun uusi sähköpostisi. Tarkista sähköpostisi ja seuraa viestissä tullutta linkkiä varmistaaksesi uuden osoitteen..
|
||||||
|
updated: Tilisi on onnistuneesti päivitetty.
|
||||||
|
sessions:
|
||||||
|
already_signed_out: Ulos kirjautuminen onnistui.
|
||||||
|
signed_in: Sisäänkirjautuminen onnistui.
|
||||||
|
signed_out: Ulos kirjautuminen onnistui.
|
||||||
|
unlocks:
|
||||||
|
send_instructions: Saat sähköpostiisi pian ohjeet, jolla voit avata tilisi uudestaan.
|
||||||
|
send_paranoid_instructions: Jos tilisi on olemassa, saat sähköpostiisi pian ohjeet tilisi avaamiseen.
|
||||||
|
unlocked: Tilisi on avattu onnistuneesti. Kirjaudu normaalisti sisään.
|
||||||
|
errors:
|
||||||
|
messages:
|
||||||
|
already_confirmed: on jo varmistettu. Yritä kirjautua sisään
|
||||||
|
confirmation_period_expired: pitää varmistaa %{period} sisällä, ole hyvä ja pyydä uusi
|
||||||
|
expired: on erääntynyt, ole hyvä ja pyydä uusi
|
||||||
|
not_found: ei löydy
|
||||||
|
not_locked: ei ollut lukittu
|
||||||
|
not_saved:
|
||||||
|
one: '1 virhe esti %{resource} tallennuksen:'
|
||||||
|
other: "%{count} virhettä esti %{resource} tallennuksen:"
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue