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