Merge branch 'master' into feature-language-detection

This commit is contained in:
Eugen Rochko 2017-04-15 22:14:44 +02:00
commit 9f8375deaa
153 changed files with 2662 additions and 486 deletions

View file

@ -6,3 +6,6 @@ node_modules
storybook storybook
neo4j neo4j
vendor/bundle vendor/bundle
.DS_Store
*.swp
*~

View file

@ -1,6 +1,7 @@
# Service dependencies # Service dependencies
REDIS_HOST=redis REDIS_HOST=redis
REDIS_PORT=6379 REDIS_PORT=6379
# REDIS_DB=0
DB_HOST=db DB_HOST=db
DB_USER=postgres DB_USER=postgres
DB_NAME=postgres DB_NAME=postgres
@ -11,6 +12,10 @@ DB_PORT=5432
LOCAL_DOMAIN=example.com LOCAL_DOMAIN=example.com
LOCAL_HTTPS=true LOCAL_HTTPS=true
# Use this only if you need to run mastodon on a different domain than the one used for federation.
# Do not use this unless you know exactly what you are doing.
# WEB_DOMAIN=mastodon.example.com
# Application secrets # Application secrets
# Generate each with the `rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose) # Generate each with the `rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose)
PAPERCLIP_SECRET= PAPERCLIP_SECRET=
@ -41,6 +46,10 @@ SMTP_FROM_ADDRESS=notifications@example.com
#SMTP_ENABLE_STARTTLS_AUTO=true #SMTP_ENABLE_STARTTLS_AUTO=true
# Optional user upload path and URL (images, avatars). Default is :rails_root/public/system. If you set this variable, you are responsible for making your HTTP server (eg. nginx) serve these files.
# PAPERCLIP_ROOT_PATH=/var/lib/mastodon/public-system
# PAPERCLIP_ROOT_URL=/system
# Optional asset host for multi-server setups # Optional asset host for multi-server setups
# CDN_HOST=assets.example.com # CDN_HOST=assets.example.com

View file

@ -1,3 +1,4 @@
# Federation # Federation
LOCAL_DOMAIN=cb6e6126.ngrok.io LOCAL_DOMAIN=cb6e6126.ngrok.io
LOCAL_HTTPS=true LOCAL_HTTPS=true
OTP_SECRET=100c7faeef00caa29242f6b04156742bf76065771fd4117990c4282b8748ff3d99f8fdae97c982ab5bd2e6756a159121377cce4421f4a8ecd2d67bd7749a3fb4

View file

@ -8,7 +8,8 @@
"parser": "babel-eslint", "parser": "babel-eslint",
"plugins": [ "plugins": [
"react" "react",
"jsx-a11y"
], ],
"parserOptions": { "parserOptions": {
@ -43,9 +44,36 @@
"no-mixed-spaces-and-tabs": 1, "no-mixed-spaces-and-tabs": 1,
"no-nested-ternary": 1, "no-nested-ternary": 1,
"no-trailing-spaces": 1, "no-trailing-spaces": 1,
"react/wrap-multilines": 2,
"react/jsx-wrap-multilines": 2,
"react/self-closing-comp": 2, "react/self-closing-comp": 2,
"react/prop-types": 2, "react/prop-types": 2,
"react/no-multi-comp": 0 "react/no-multi-comp": 0,
"jsx-a11y/accessible-emoji": 1,
"jsx-a11y/anchor-has-content": 1,
"jsx-a11y/aria-activedescendant-has-tabindex": 1,
"jsx-a11y/aria-props": 1,
"jsx-a11y/aria-proptypes": 1,
"jsx-a11y/aria-role": 1,
"jsx-a11y/aria-unsupported-elements": 1,
"jsx-a11y/heading-has-content": 1,
"jsx-a11y/href-no-hash": 1,
"jsx-a11y/html-has-lang": 1,
"jsx-a11y/iframe-has-title": 1,
"jsx-a11y/img-has-alt": 1,
"jsx-a11y/img-redundant-alt": 1,
"jsx-a11y/label-has-for": 1,
"jsx-a11y/mouse-events-have-key-events": 1,
"jsx-a11y/no-access-key": 1,
"jsx-a11y/no-distracting-elements": 1,
"jsx-a11y/no-onchange": 1,
"jsx-a11y/no-redundant-roles": 1,
"jsx-a11y/onclick-has-focus": 1,
"jsx-a11y/onclick-has-role": 1,
"jsx-a11y/role-has-required-aria-props": 1,
"jsx-a11y/role-supports-aria-props": 1,
"jsx-a11y/scope": 1,
"jsx-a11y/tabindex-no-positive": 1
} }
} }

8
.gitignore vendored
View file

@ -29,10 +29,16 @@ neo4j/
# Ignore Capistrano customizations # Ignore Capistrano customizations
config/deploy/* config/deploy/*
# Ignore IDE files # Ignore IDE files
.vscode/ .vscode/
# Ignore postgres + redis volume optionally created by docker-compose # Ignore postgres + redis volume optionally created by docker-compose
postgres postgres
redis redis
# Ignore Apple files
.DS_Store
# Ignore vim files
*~
*.swp

2
.nvmrc
View file

@ -1 +1 @@
6.7.0 6

View file

@ -5,8 +5,6 @@ notifications:
email: false email: false
env: env:
matrix:
- TRAVIS_NODE_VERSION="4"
global: global:
- LOCAL_DOMAIN=cb6e6126.ngrok.io - LOCAL_DOMAIN=cb6e6126.ngrok.io
- LOCAL_HTTPS=true - LOCAL_HTTPS=true
@ -28,8 +26,7 @@ before_install:
- sudo apt-get -qq update - sudo apt-get -qq update
- sudo apt-get -qq install g++-4.8 - sudo apt-get -qq install g++-4.8
install: install:
- nvm install $TRAVIS_NODE_VERSION - nvm install
- npm install -g npm@3
- npm install -g yarn - npm install -g yarn
- bundle install - bundle install
- yarn install - yarn install

View file

@ -28,7 +28,7 @@ RUN BUILD_DEPS=" \
imagemagick \ imagemagick \
&& npm install -g npm@3 && npm install -g yarn \ && npm install -g npm@3 && npm install -g yarn \
&& bundle install --deployment --without test development \ && bundle install --deployment --without test development \
&& yarn \ && yarn --ignore-optional \
&& yarn cache clean \ && yarn cache clean \
&& npm -g cache clean \ && npm -g cache clean \
&& apk del $BUILD_DEPS \ && apk del $BUILD_DEPS \

View file

@ -44,6 +44,7 @@ gem 'rabl'
gem 'rack-attack' gem 'rack-attack'
gem 'rack-cors', require: 'rack/cors' gem 'rack-cors', require: 'rack/cors'
gem 'rack-timeout' gem 'rack-timeout'
gem 'rails-i18n'
gem 'rails-settings-cached' gem 'rails-settings-cached'
gem 'redis', '~>3.2', require: ['redis', 'redis/connection/hiredis'] gem 'redis', '~>3.2', require: ['redis', 'redis/connection/hiredis']
gem 'rqrcode' gem 'rqrcode'
@ -70,7 +71,9 @@ group :development, :test do
end end
group :test do group :test do
gem 'capybara'
gem 'faker' gem 'faker'
gem 'microformats2'
gem 'rails-controller-testing' gem 'rails-controller-testing'
gem 'rspec-sidekiq' gem 'rspec-sidekiq'
gem 'simplecov', require: false gem 'simplecov', require: false

View file

@ -99,6 +99,13 @@ GEM
sshkit (~> 1.3) sshkit (~> 1.3)
capistrano-yarn (2.0.2) capistrano-yarn (2.0.2)
capistrano (~> 3.0) capistrano (~> 3.0)
capybara (2.13.0)
addressable
mime-types (>= 1.16)
nokogiri (>= 1.3.3)
rack (>= 1.0.0)
rack-test (>= 0.5.4)
xpath (~> 2.0)
chunky_png (1.3.8) chunky_png (1.3.8)
climate_control (0.1.0) climate_control (0.1.0)
cocaine (0.5.8) cocaine (0.5.8)
@ -233,6 +240,10 @@ GEM
mail (2.6.4) mail (2.6.4)
mime-types (>= 1.16, < 4) mime-types (>= 1.16, < 4)
method_source (0.8.2) method_source (0.8.2)
microformats2 (2.1.0)
activesupport
json
nokogiri
mime-types (3.1) mime-types (3.1)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2016.0521) mime-types-data (3.2016.0521)
@ -308,6 +319,9 @@ GEM
nokogiri (~> 1.6) nokogiri (~> 1.6)
rails-html-sanitizer (1.0.3) rails-html-sanitizer (1.0.3)
loofah (~> 2.0) loofah (~> 2.0)
rails-i18n (5.0.3)
i18n (~> 0.7)
railties (~> 5.0)
rails-settings-cached (0.6.5) rails-settings-cached (0.6.5)
rails (>= 4.2.0) rails (>= 4.2.0)
rails_12factor (0.0.3) rails_12factor (0.0.3)
@ -447,6 +461,8 @@ GEM
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.2) websocket-extensions (0.1.2)
whatlanguage (1.0.6) whatlanguage (1.0.6)
xpath (2.0.0)
nokogiri (~> 1.3)
PLATFORMS PLATFORMS
ruby ruby
@ -466,6 +482,7 @@ DEPENDENCIES
capistrano-rails capistrano-rails
capistrano-rbenv capistrano-rbenv
capistrano-yarn capistrano-yarn
capybara
coffee-rails (~> 4.1.0) coffee-rails (~> 4.1.0)
devise devise
devise-two-factor devise-two-factor
@ -490,6 +507,7 @@ DEPENDENCIES
letter_opener_web letter_opener_web
link_header link_header
lograge lograge
microformats2
nokogiri nokogiri
oj oj
ostatus2 ostatus2
@ -507,6 +525,7 @@ DEPENDENCIES
rack-timeout rack-timeout
rails (~> 5.0.2) rails (~> 5.0.2)
rails-controller-testing rails-controller-testing
rails-i18n
rails-settings-cached rails-settings-cached
rails_12factor rails_12factor
react-rails react-rails

2
Vagrantfile vendored
View file

@ -43,7 +43,7 @@ echo 'eval "$(rbenv init -)"' >> ~/.bash_profile
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
export PATH="$HOME/.rbenv/bin::$PATH" export PATH="$HOME/.rbenv/bin:$PATH"
eval "$(rbenv init -)" eval "$(rbenv init -)"
cd /vagrant cd /vagrant

View file

@ -0,0 +1,82 @@
import api, { getLinks } from '../api'
import { fetchRelationships } from './accounts';
export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
export const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL';
export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST';
export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS';
export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL';
export function fetchMutes() {
return (dispatch, getState) => {
dispatch(fetchMutesRequest());
api(getState).get('/api/v1/mutes').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchMutesSuccess(response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => dispatch(fetchMutesFail(error)));
};
};
export function fetchMutesRequest() {
return {
type: MUTES_FETCH_REQUEST
};
};
export function fetchMutesSuccess(accounts, next) {
return {
type: MUTES_FETCH_SUCCESS,
accounts,
next
};
};
export function fetchMutesFail(error) {
return {
type: MUTES_FETCH_FAIL,
error
};
};
export function expandMutes() {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'mutes', 'next']);
if (url === null) {
return;
}
dispatch(expandMutesRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandMutesSuccess(response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => dispatch(expandMutesFail(error)));
};
};
export function expandMutesRequest() {
return {
type: MUTES_EXPAND_REQUEST
};
};
export function expandMutesSuccess(accounts, next) {
return {
type: MUTES_EXPAND_SUCCESS,
accounts,
next
};
};
export function expandMutesFail(error) {
return {
type: MUTES_EXPAND_FAIL,
error
};
};

View file

@ -10,7 +10,8 @@ const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock' } unblock: { id: 'account.unblock', defaultMessage: 'Unblock' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute' }
}); });
const buttonsStyle = { const buttonsStyle = {
@ -25,6 +26,7 @@ const Account = React.createClass({
me: React.PropTypes.number.isRequired, me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired, onFollow: React.PropTypes.func.isRequired,
onBlock: React.PropTypes.func.isRequired, onBlock: React.PropTypes.func.isRequired,
onMute: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired intl: React.PropTypes.object.isRequired
}, },
@ -38,6 +40,10 @@ const Account = React.createClass({
this.props.onBlock(this.props.account); this.props.onBlock(this.props.account);
}, },
handleMute () {
this.props.onMute(this.props.account);
},
render () { render () {
const { account, me, intl } = this.props; const { account, me, intl } = this.props;
@ -51,11 +57,14 @@ const Account = React.createClass({
const following = account.getIn(['relationship', 'following']); const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']); const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']); const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
if (requested) { if (requested) {
buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} /> buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
} else if (blocking) { } else if (blocking) {
buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />; buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
} else if (muting) {
buttons = <IconButton active={true} icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
} else { } else {
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
} }

View file

@ -178,7 +178,12 @@ const AutosuggestTextarea = React.createClass({
<div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'> <div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>
{suggestions.map((suggestion, i) => ( {suggestions.map((suggestion, i) => (
<div key={suggestion} className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`} onClick={this.onSuggestionClick.bind(this, suggestion)}> <div
role='button'
tabIndex='0'
key={suggestion}
className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`}
onClick={this.onSuggestionClick.bind(this, suggestion)}>
<AutosuggestAccountContainer id={suggestion} /> <AutosuggestAccountContainer id={suggestion} />
</div> </div>
))} ))}

View file

@ -9,6 +9,7 @@ const Button = React.createClass({
block: React.PropTypes.bool, block: React.PropTypes.bool,
secondary: React.PropTypes.bool, secondary: React.PropTypes.bool,
size: React.PropTypes.number, size: React.PropTypes.number,
style: React.PropTypes.object,
children: React.PropTypes.node children: React.PropTypes.node
}, },

View file

@ -15,13 +15,13 @@ const ColumnBackButton = React.createClass({
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
handleClick () { handleClick () {
if (window.history && window.history.length == 1) this.context.router.push("/"); if (window.history && window.history.length === 1) this.context.router.push("/");
else this.context.router.goBack(); else this.context.router.goBack();
}, },
render () { render () {
return ( return (
<div onClick={this.handleClick} className='column-back-button'> <div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button'>
<i className='fa fa-fw fa-chevron-left' style={iconStyle} /> <i className='fa fa-fw fa-chevron-left' style={iconStyle} />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' /> <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</div> </div>

View file

@ -31,7 +31,7 @@ const ColumnBackButtonSlim = React.createClass({
render () { render () {
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<div style={outerStyle} onClick={this.handleClick} className='column-back-button'> <div role='button' tabIndex='0' style={outerStyle} onClick={this.handleClick} className='column-back-button'>
<i className='fa fa-fw fa-chevron-left' style={iconStyle} /> <i className='fa fa-fw fa-chevron-left' style={iconStyle} />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' /> <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</div> </div>

View file

@ -46,7 +46,9 @@ const ColumnCollapsable = React.createClass({
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<div title={`${title}`} style={{...iconStyle }} className={`column-icon ${collapsedClassName}`} onClick={this.handleToggleCollapsed}><i className={`fa fa-${icon}`} /></div> <div role='button' tabIndex='0' title={`${title}`} style={{...iconStyle }} className={`column-icon ${collapsedClassName}`} onClick={this.handleToggleCollapsed}>
<i className={`fa fa-${icon}`} />
</div>
<Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}> <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}>
{({ opacity, height }) => {({ opacity, height }) =>

View file

@ -1,7 +1,7 @@
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
const LoadMore = ({ onClick }) => ( const LoadMore = ({ onClick }) => (
<a href='#' className='load-more' onClick={onClick}> <a href="#" className='load-more' role='button' onClick={onClick}>
<FormattedMessage id='status.load_more' defaultMessage='Load more' /> <FormattedMessage id='status.load_more' defaultMessage='Load more' />
</a> </a>
); );

View file

@ -220,7 +220,7 @@ const MediaGallery = React.createClass({
} }
children = ( children = (
<div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}> <div role='button' tabIndex='0' style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
<span style={spoilerSpanStyle}>{warning}</span> <span style={spoilerSpanStyle}>{warning}</span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div> </div>

View file

@ -6,7 +6,8 @@ const Permalink = React.createClass({
propTypes: { propTypes: {
href: React.PropTypes.string.isRequired, href: React.PropTypes.string.isRequired,
to: React.PropTypes.string.isRequired to: React.PropTypes.string.isRequired,
children: React.PropTypes.node
}, },
handleClick (e) { handleClick (e) {

View file

@ -92,10 +92,14 @@ const StatusActionBar = React.createClass({
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
} }
let reblogIcon = 'retweet';
if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
else if (status.get('visibility') === 'private') reblogIcon = 'lock';
return ( return (
<div style={{ marginTop: '10px', overflow: 'hidden' }}> <div style={{ marginTop: '10px', overflow: 'hidden' }}>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div> <div style={{ float: 'left', marginRight: '18px'}}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton disabled={status.get('visibility') === 'private' || status.get('visibility') === 'direct'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'direct' ? 'envelope' : (status.get('visibility') === 'private' ? 'lock' : 'retweet')} onClick={this.handleReblogClick} /></div> <div style={{ float: 'left', marginRight: '18px'}}><IconButton disabled={status.get('visibility') === 'private' || status.get('visibility') === 'direct'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> <div style={{ float: 'left', marginRight: '18px'}}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
<div style={{ width: '18px', height: '18px', float: 'left' }}> <div style={{ width: '18px', height: '18px', float: 'left' }}>

View file

@ -119,7 +119,7 @@ const StatusContent = React.createClass({
return ( return (
<div className='status__content' style={{ cursor: 'pointer' }} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> <div className='status__content' style={{ cursor: 'pointer' }} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<p style={{ marginBottom: hidden && status.get('mentions').size === 0 ? '0px' : '' }} > <p style={{ marginBottom: hidden && status.get('mentions').size === 0 ? '0px' : '' }} >
<span dangerouslySetInnerHTML={spoilerContent} /> <a className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>{toggleText}</a> <span dangerouslySetInnerHTML={spoilerContent} /> <a tabIndex='0' className='status__content__spoiler-link' role='button' onClick={this.handleSpoilerClick}>{toggleText}</a>
</p> </p>
{mentionsPlaceholder} {mentionsPlaceholder}

View file

@ -194,7 +194,7 @@ const VideoPlayer = React.createClass({
if (!this.state.visible) { if (!this.state.visible) {
if (sensitive) { if (sensitive) {
return ( return (
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}> <div role='button' tabIndex='0' style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
{spoilerButton} {spoilerButton}
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
@ -202,7 +202,7 @@ const VideoPlayer = React.createClass({
); );
} else { } else {
return ( return (
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}> <div role='button' tabIndex='0' style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
{spoilerButton} {spoilerButton}
<span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
@ -213,7 +213,7 @@ const VideoPlayer = React.createClass({
if (this.state.preview && !autoplay) { if (this.state.preview && !autoplay) {
return ( return (
<div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}> <div role='button' tabIndex='0' style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}>
{spoilerButton} {spoilerButton}
<div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div> <div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div>
</div> </div>
@ -225,7 +225,7 @@ const VideoPlayer = React.createClass({
{spoilerButton} {spoilerButton}
{muteButton} {muteButton}
{expandButton} {expandButton}
<video ref={this.setRef} src={media.get('url')} autoPlay={!isIOS()} loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} /> <video role='button' tabIndex='0' ref={this.setRef} src={media.get('url')} autoPlay={!isIOS()} loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
</div> </div>
); );
} }

View file

@ -37,6 +37,7 @@ import FollowRequests from '../features/follow_requests';
import GenericNotFound from '../features/generic_not_found'; import GenericNotFound from '../features/generic_not_found';
import FavouritedStatuses from '../features/favourited_statuses'; import FavouritedStatuses from '../features/favourited_statuses';
import Blocks from '../features/blocks'; import Blocks from '../features/blocks';
import Mutes from '../features/mutes';
import Report from '../features/report'; import Report from '../features/report';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider, addLocaleData } from 'react-intl';
import en from 'react-intl/locale-data/en'; import en from 'react-intl/locale-data/en';
@ -60,8 +61,8 @@ import { hydrateStore } from '../actions/store';
import createStream from '../stream'; import createStream from '../stream';
const store = configureStore(); const store = configureStore();
const initialState = JSON.parse(document.getElementById("initial-state").textContent);
store.dispatch(hydrateStore(window.INITIAL_STATE)); store.dispatch(hydrateStore(initialState));
const browserHistory = useRouterHistory(createBrowserHistory)({ const browserHistory = useRouterHistory(createBrowserHistory)({
basename: '/web' basename: '/web'
@ -94,9 +95,10 @@ const Mastodon = React.createClass({
componentDidMount() { componentDidMount() {
const { locale } = this.props; const { locale } = this.props;
const streamingAPIBaseURL = store.getState().getIn(['meta', 'streaming_api_base_url']);
const accessToken = store.getState().getIn(['meta', 'access_token']); const accessToken = store.getState().getIn(['meta', 'access_token']);
this.subscription = createStream(accessToken, 'user', { this.subscription = createStream(streamingAPIBaseURL, accessToken, 'user', {
connected () { connected () {
store.dispatch(connectTimeline('home')); store.dispatch(connectTimeline('home'));
@ -171,6 +173,7 @@ const Mastodon = React.createClass({
<Route path='follow_requests' component={FollowRequests} /> <Route path='follow_requests' component={FollowRequests} />
<Route path='blocks' component={Blocks} /> <Route path='blocks' component={Blocks} />
<Route path='mutes' component={Mutes} />
<Route path='report' component={Report} /> <Route path='report' component={Report} />
<Route path='*' component={GenericNotFound} /> <Route path='*' component={GenericNotFound} />

View file

@ -43,7 +43,16 @@ const Avatar = React.createClass({
return ( return (
<Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}> <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
{({ radius }) => {({ 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}> <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}
onFocus={this.handleMouseOver}
onBlur={this.handleMouseOut}>
<img src={account.get('avatar')} alt={account.get('acct')} style={{ display: 'block', width: '90px', height: '90px' }} /> <img src={account.get('avatar')} alt={account.get('acct')} style={{ display: 'block', width: '90px', height: '90px' }} />
</a> </a>
} }

View file

@ -19,6 +19,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
accessToken: state.getIn(['meta', 'access_token']) accessToken: state.getIn(['meta', 'access_token'])
}); });
@ -29,6 +30,7 @@ const CommunityTimeline = React.createClass({
propTypes: { propTypes: {
dispatch: React.PropTypes.func.isRequired, dispatch: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired, intl: React.PropTypes.object.isRequired,
streamingAPIBaseURL: React.PropTypes.string.isRequired,
accessToken: React.PropTypes.string.isRequired, accessToken: React.PropTypes.string.isRequired,
hasUnread: React.PropTypes.bool hasUnread: React.PropTypes.bool
}, },
@ -36,7 +38,7 @@ const CommunityTimeline = React.createClass({
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
componentDidMount () { componentDidMount () {
const { dispatch, accessToken } = this.props; const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
dispatch(refreshTimeline('community')); dispatch(refreshTimeline('community'));
@ -44,7 +46,7 @@ const CommunityTimeline = React.createClass({
return; return;
} }
subscription = createStream(accessToken, 'public:local', { subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', {
connected () { connected () {
dispatch(connectTimeline('community')); dispatch(connectTimeline('community'));

View file

@ -83,7 +83,7 @@ const PrivacyDropdown = React.createClass({
<div className='privacy-dropdown__value'><IconButton icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div> <div className='privacy-dropdown__value'><IconButton icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div>
<div className='privacy-dropdown__dropdown'> <div className='privacy-dropdown__dropdown'>
{options.map(item => {options.map(item =>
<div key={item.value} onClick={this.handleClick.bind(this, item.value)} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}> <div role='button' tabIndex='0' key={item.value} onClick={this.handleClick.bind(this, item.value)} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}>
<div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div> <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div>
<div className='privacy-dropdown__option__content'> <div className='privacy-dropdown__option__content'>
<strong>{item.shortText}</strong> <strong>{item.shortText}</strong>

View file

@ -36,6 +36,10 @@ const Search = React.createClass({
} }
}, },
noop () {
},
handleFocus () { handleFocus () {
this.props.onShow(); this.props.onShow();
}, },
@ -56,9 +60,9 @@ const Search = React.createClass({
onFocus={this.handleFocus} onFocus={this.handleFocus}
/> />
<div className='search__icon'> <div role='button' tabIndex='0' className='search__icon' onClick={hasValue ? this.handleClear : this.noop}>
<i className={`fa fa-search ${hasValue ? '' : 'active'}`} /> <i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
<i className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} onClick={this.handleClear} /> <i aria-label="Clear search" className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} />
</div> </div>
</div> </div>
); );

View file

@ -50,7 +50,7 @@ const Followers = React.createClass({
handleLoadMore (e) { handleLoadMore (e) {
e.preventDefault(); e.preventDefault();
this.props.dispatch(expandFollowing(Number(this.props.params.accountId))); this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
}, },
render () { render () {

View file

@ -14,6 +14,7 @@ const messages = defineMessages({
sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' } info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' }
}); });
@ -37,6 +38,7 @@ const GettingStarted = ({ intl, me }) => {
<ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' /> <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
{followRequests} {followRequests}
<ColumnLink icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' /> <ColumnLink icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
<ColumnLink icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
<ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
</div> </div>

View file

@ -13,6 +13,7 @@ import createStream from '../../stream';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0, hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0,
streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
accessToken: state.getIn(['meta', 'access_token']) accessToken: state.getIn(['meta', 'access_token'])
}); });
@ -21,6 +22,7 @@ const HashtagTimeline = React.createClass({
propTypes: { propTypes: {
params: React.PropTypes.object.isRequired, params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired, dispatch: React.PropTypes.func.isRequired,
streamingAPIBaseURL: React.PropTypes.string.isRequired,
accessToken: React.PropTypes.string.isRequired, accessToken: React.PropTypes.string.isRequired,
hasUnread: React.PropTypes.bool hasUnread: React.PropTypes.bool
}, },
@ -28,9 +30,9 @@ const HashtagTimeline = React.createClass({
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
_subscribe (dispatch, id) { _subscribe (dispatch, id) {
const { accessToken } = this.props; const { streamingAPIBaseURL, accessToken } = this.props;
this.subscription = createStream(accessToken, `hashtag&tag=${id}`, { this.subscription = createStream(streamingAPIBaseURL, accessToken, `hashtag&tag=${id}`, {
received (data) { received (data) {
switch(data.event) { switch(data.event) {

View file

@ -0,0 +1,68 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { ScrollContainer } from 'react-router-scroll';
import Column from '../ui/components/column';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import AccountContainer from '../../containers/account_container';
import { fetchMutes, expandMutes } from '../../actions/mutes';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
heading: { id: 'column.mutes', defaultMessage: 'Muted users' }
});
const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'mutes', 'items'])
});
const Mutes = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchMutes());
},
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight) {
this.props.dispatch(expandMutes());
}
},
render () {
const { intl, accountIds } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column icon='users' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<ScrollContainer scrollKey='mutes'>
<div className='scrollable' onScroll={this.handleScroll}>
{accountIds.map(id =>
<AccountContainer key={id} id={id} />
)}
</div>
</ScrollContainer>
</Column>
);
}
});
export default connect(mapStateToProps)(injectIntl(Mutes));

View file

@ -15,7 +15,7 @@ const ClearColumnButton = React.createClass({
const { intl } = this.props; const { intl } = this.props;
return ( return (
<div title={intl.formatMessage(messages.clear)} className='column-icon column-icon-clear' tabIndex='0' onClick={this.props.onClick}> <div role='button' title={intl.formatMessage(messages.clear)} className='column-icon column-icon-clear' tabIndex='0' onClick={this.props.onClick}>
<i className='fa fa-eraser' /> <i className='fa fa-eraser' />
</div> </div>
); );

View file

@ -27,9 +27,11 @@ const ColumnSettings = React.createClass({
propTypes: { propTypes: {
settings: ImmutablePropTypes.map.isRequired, settings: ImmutablePropTypes.map.isRequired,
intl: React.PropTypes.object.isRequired,
onChange: React.PropTypes.func.isRequired, onChange: React.PropTypes.func.isRequired,
onSave: React.PropTypes.func.isRequired, onSave: React.PropTypes.func.isRequired,
intl: React.PropTypes.shape({
formatMessage: React.PropTypes.func.isRequired
}).isRequired
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],

View file

@ -71,7 +71,7 @@ const Notification = React.createClass({
); );
}, },
render () { render () { // eslint-disable-line consistent-return
const { notification } = this.props; const { notification } = this.props;
const account = notification.get('account'); const account = notification.get('account');
const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username'); const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');

View file

@ -14,8 +14,8 @@ const labelSpanStyle = {
marginLeft: '8px' marginLeft: '8px'
}; };
const SettingToggle = ({ settings, settingKey, label, onChange }) => ( const SettingToggle = ({ settings, settingKey, label, onChange, htmlFor = '' }) => (
<label style={labelStyle}> <label htmlFor={htmlFor} style={labelStyle}>
<Toggle checked={settings.getIn(settingKey)} onChange={(e) => onChange(settingKey, e.target.checked)} /> <Toggle checked={settings.getIn(settingKey)} onChange={(e) => onChange(settingKey, e.target.checked)} />
<span className='setting-toggle' style={labelSpanStyle}>{label}</span> <span className='setting-toggle' style={labelSpanStyle}>{label}</span>
</label> </label>
@ -25,7 +25,8 @@ SettingToggle.propTypes = {
settings: ImmutablePropTypes.map.isRequired, settings: ImmutablePropTypes.map.isRequired,
settingKey: React.PropTypes.array.isRequired, settingKey: React.PropTypes.array.isRequired,
label: React.PropTypes.node.isRequired, label: React.PropTypes.node.isRequired,
onChange: React.PropTypes.func.isRequired onChange: React.PropTypes.func.isRequired,
htmlFor: React.PropTypes.string
}; };
export default SettingToggle; export default SettingToggle;

View file

@ -19,6 +19,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0, hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
accessToken: state.getIn(['meta', 'access_token']) accessToken: state.getIn(['meta', 'access_token'])
}); });
@ -29,6 +30,7 @@ const PublicTimeline = React.createClass({
propTypes: { propTypes: {
dispatch: React.PropTypes.func.isRequired, dispatch: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired, intl: React.PropTypes.object.isRequired,
streamingAPIBaseURL: React.PropTypes.string.isRequired,
accessToken: React.PropTypes.string.isRequired, accessToken: React.PropTypes.string.isRequired,
hasUnread: React.PropTypes.bool hasUnread: React.PropTypes.bool
}, },
@ -36,7 +38,7 @@ const PublicTimeline = React.createClass({
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
componentDidMount () { componentDidMount () {
const { dispatch, accessToken } = this.props; const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
dispatch(refreshTimeline('public')); dispatch(refreshTimeline('public'));
@ -44,7 +46,7 @@ const PublicTimeline = React.createClass({
return; return;
} }
subscription = createStream(accessToken, 'public', { subscription = createStream(streamingAPIBaseURL, accessToken, 'public', {
connected () { connected () {
dispatch(connectTimeline('public')); dispatch(connectTimeline('public'));

View file

@ -71,10 +71,14 @@ const ActionBar = React.createClass({
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
} }
let reblogIcon = 'retweet';
if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
else if (status.get('visibility') === 'private') reblogIcon = 'lock';
return ( return (
<div className='detailed-status__action-bar'> <div className='detailed-status__action-bar'>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div> <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton disabled={status.get('visibility') === 'direct' || status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'direct' ? 'envelope' : (status.get('visibility') === 'private' ? 'lock' : 'retweet')} onClick={this.handleReblogClick} /></div> <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton disabled={status.get('visibility') === 'direct' || status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" /></div> <div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" /></div>
</div> </div>

View file

@ -25,7 +25,7 @@ const ColumnHeader = React.createClass({
} }
return ( return (
<div aria-label={type} className={`column-header ${active ? 'active' : ''}`} onClick={this.handleClick}> <div role='button' tabIndex='0' aria-label={type} className={`column-header ${active ? 'active' : ''}`} onClick={this.handleClick}>
{icon} {icon}
{type} {type}
</div> </div>

View file

@ -34,7 +34,8 @@ ColumnLink.propTypes = {
icon: React.PropTypes.string.isRequired, icon: React.PropTypes.string.isRequired,
text: React.PropTypes.string.isRequired, text: React.PropTypes.string.isRequired,
to: React.PropTypes.string, to: React.PropTypes.string,
href: React.PropTypes.string href: React.PropTypes.string,
method: React.PropTypes.string
}; };
export default ColumnLink; export default ColumnLink;

View file

@ -104,8 +104,8 @@ const MediaModal = React.createClass({
leftNav = rightNav = content = ''; leftNav = rightNav = content = '';
if (media.size > 1) { if (media.size > 1) {
leftNav = <div style={leftNavStyle} className='modal-container__nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>; leftNav = <div role='button' tabIndex='0' 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>; rightNav = <div role='button' tabIndex='0' style={rightNavStyle} className='modal-container__nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
} }
if (attachment.get('type') === 'image') { if (attachment.get('type') === 'image') {

View file

@ -66,7 +66,7 @@ const ModalRoot = React.createClass({
return ( return (
<div key={key}> <div key={key}>
<div className='modal-root__overlay' style={{ opacity: style.opacity, transform: `translateZ(0px)` }} onClick={onClose} /> <div role='presentation' 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})` }}> <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
<SpecificComponent {...props} onClose={onClose} /> <SpecificComponent {...props} onClose={onClose} />
</div> </div>

View file

@ -141,7 +141,7 @@ const UI = React.createClass({
{mountedColumns} {mountedColumns}
<NotificationsContainer /> <NotificationsContainer />
<LoadingBarContainer style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} /> <LoadingBarContainer className="loading-bar" />
<ModalContainer /> <ModalContainer />
<UploadArea active={draggingOver} /> <UploadArea active={draggingOver} />
</div> </div>

View file

@ -14,7 +14,7 @@ Link.parseAttrs = (link, parts) => {
link = Link.parseParams(link, uriAttrs[1]) link = Link.parseParams(link, uriAttrs[1])
} }
while(match = Link.attrPattern.exec(attrs)) { while(match = Link.attrPattern.exec(attrs)) { // eslint-disable-line no-cond-assign
attr = match[1].toLowerCase() attr = match[1].toLowerCase()
value = match[4] || match[3] || match[2] value = match[4] || match[3] || match[2]

View file

@ -31,6 +31,7 @@ const en = {
"column.favourites": "Favourites", "column.favourites": "Favourites",
"column.follow_requests": "Follow requests", "column.follow_requests": "Follow requests",
"column.home": "Home", "column.home": "Home",
"column.mutes": "Muted users",
"column.notifications": "Notifications", "column.notifications": "Notifications",
"column.public": "Federated timeline", "column.public": "Federated timeline",
"compose_form.placeholder": "What is on your mind?", "compose_form.placeholder": "What is on your mind?",
@ -68,6 +69,7 @@ const en = {
"navigation_bar.follow_requests": "Follow requests", "navigation_bar.follow_requests": "Follow requests",
"navigation_bar.info": "Extended information", "navigation_bar.info": "Extended information",
"navigation_bar.logout": "Logout", "navigation_bar.logout": "Logout",
"navigation_bar.mutes": "Muted users",
"navigation_bar.preferences": "Preferences", "navigation_bar.preferences": "Preferences",
"navigation_bar.public_timeline": "Federated timeline", "navigation_bar.public_timeline": "Federated timeline",
"notification.favourite": "{name} favourited your status", "notification.favourite": "{name} favourited your status",

View file

@ -37,6 +37,7 @@ const ja = {
"getting_started.about_addressing": "ドメインとユーザー名を知っているなら検索フォームに入力すればフォローできます。", "getting_started.about_addressing": "ドメインとユーザー名を知っているなら検索フォームに入力すればフォローできます。",
"getting_started.about_shortcuts": "対象のアカウントがあなたと同じドメインのユーザーならばユーザー名のみで検索できます。これは返信のときも一緒です。", "getting_started.about_shortcuts": "対象のアカウントがあなたと同じドメインのユーザーならばユーザー名のみで検索できます。これは返信のときも一緒です。",
"getting_started.open_source_notice": "Mastodon はオープンソースソフトウェアです。誰でも GitHub{github})から開発に参加したり、問題を報告したりできます。 {apps}", "getting_started.open_source_notice": "Mastodon はオープンソースソフトウェアです。誰でも GitHub{github})から開発に参加したり、問題を報告したりできます。 {apps}",
"getting_started.apps": "さまざまなアプリで利用できます。",
"column.home": "ホーム", "column.home": "ホーム",
"column.community": "ローカルタイムライン", "column.community": "ローカルタイムライン",
"column.public": "連合タイムライン", "column.public": "連合タイムライン",
@ -64,7 +65,7 @@ const ja = {
"privacy.private.long": "フォロワーだけに公開", "privacy.private.long": "フォロワーだけに公開",
"privacy.direct.short": "ダイレクト", "privacy.direct.short": "ダイレクト",
"privacy.direct.long": "含んだユーザーだけに公開", "privacy.direct.long": "含んだユーザーだけに公開",
"privacy.change": "投稿のプライバシーを変更2", "privacy.change": "投稿のプライバシーを変更",
"report.heading": "新規通報", "report.heading": "新規通報",
"report.placeholder": "コメント", "report.placeholder": "コメント",
"report.target": "問題のユーザー", "report.target": "問題のユーザー",
@ -82,6 +83,7 @@ const ja = {
"search.account": "アカウント", "search.account": "アカウント",
"search.hashtag": "ハッシュタグ", "search.hashtag": "ハッシュタグ",
"search.status_by": "{uuuname}からの投稿", "search.status_by": "{uuuname}からの投稿",
"search_results.total": "{count} 件",
"upload_area.title": "ファイルをこちらにドラッグしてください", "upload_area.title": "ファイルをこちらにドラッグしてください",
"upload_button.label": "メディアを追加", "upload_button.label": "メディアを追加",
"upload_form.undo": "やり直す", "upload_form.undo": "やり直す",
@ -111,7 +113,7 @@ const ja = {
"home.column_settings.show_replies": "返信表示", "home.column_settings.show_replies": "返信表示",
"home.column_settings.filter_regex": "正規表現でフィルター", "home.column_settings.filter_regex": "正規表現でフィルター",
"home.settings": "カラム設定", "home.settings": "カラム設定",
"notification.settings": "カラム設定", "notifications.settings": "カラム設定",
"missing_indicator.label": "見つかりません", "missing_indicator.label": "見つかりません",
"boost_modal.combo": "次は{combo}を押せば、これをスキップできます。" "boost_modal.combo": "次は{combo}を押せば、これをスキップできます。"
}; };

View file

@ -1,22 +1,22 @@
const nl = { const nl = {
"column_back_button.label": "terug", "column_back_button.label": "terug",
"lightbox.close": "Sluiten", "lightbox.close": "Sluiten",
"loading_indicator.label": "Laden...", "loading_indicator.label": "Laden",
"status.mention": "Vermeld @{name}", "status.mention": "@{name} vermelden",
"status.delete": "Verwijder", "status.delete": "Verwijderen",
"status.reply": "Reageer", "status.reply": "Reageren",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.favourite": "Favoriet", "status.favourite": "Favoriet",
"status.reblogged_by": "{name} boostte", "status.reblogged_by": "{name} boostte",
"status.sensitive_warning": "Gevoelige inhoud", "status.sensitive_warning": "Gevoelige inhoud",
"status.sensitive_toggle": "Klik om te zien", "status.sensitive_toggle": "Klik om te zien",
"video_player.toggle_sound": "Geluid omschakelen", "video_player.toggle_sound": "Geluid in-/uitschakelen",
"account.mention": "Vermeld @{name}", "account.mention": "@{name} vermelden",
"account.edit_profile": "Bewerk profiel", "account.edit_profile": "Profiel bewerken",
"account.unblock": "Deblokkeer @{name}", "account.unblock": "@{name} deblokkeren",
"account.unfollow": "Ontvolg", "account.unfollow": "Ontvolgen",
"account.block": "Blokkeer @{name}", "account.block": "@{name} blokkeren",
"account.follow": "Volg", "account.follow": "Volgen",
"account.posts": "Berichten", "account.posts": "Berichten",
"account.follows": "Volgt", "account.follows": "Volgt",
"account.followers": "Volgers", "account.followers": "Volgers",
@ -25,7 +25,7 @@ const nl = {
"getting_started.heading": "Beginnen", "getting_started.heading": "Beginnen",
"getting_started.about_addressing": "Je kunt mensen volgen als je hun gebruikersnaam en het domein van hun server kent, door het e-mailachtige adres in het zoekscherm in te voeren.", "getting_started.about_addressing": "Je kunt mensen volgen als je hun gebruikersnaam en het domein van hun server kent, door het e-mailachtige adres in het zoekscherm in te voeren.",
"getting_started.about_shortcuts": "Als de gezochte gebruiker op hetzelfde domein zit als jijzelf, is invoeren van de gebruikersnaam genoeg. Dat geldt ook als je mensen in de statussen wilt vermelden.", "getting_started.about_shortcuts": "Als de gezochte gebruiker op hetzelfde domein zit als jijzelf, is invoeren van de gebruikersnaam genoeg. Dat geldt ook als je mensen in de statussen wilt vermelden.",
"getting_started.open_source_notice": "Mastodon is open source software. Je kunt bijdragen of problemen melden op GitHub via {github}. {apps}.", "getting_started.open_source_notice": "Mastodon is open-sourcesoftware. Je kunt bijdragen of problemen melden op GitHub via {github}. {apps}.",
"column.home": "Thuis", "column.home": "Thuis",
"column.community": "Lokale tijdlijn", "column.community": "Lokale tijdlijn",
"column.public": "Federatietijdlijn", "column.public": "Federatietijdlijn",
@ -37,30 +37,30 @@ const nl = {
"tabs_bar.notifications": "Meldingen", "tabs_bar.notifications": "Meldingen",
"compose_form.placeholder": "Waar ben je mee bezig?", "compose_form.placeholder": "Waar ben je mee bezig?",
"compose_form.publish": "Toot", "compose_form.publish": "Toot",
"compose_form.sensitive": "Markeer media als gevoelig", "compose_form.sensitive": "Media als gevoelig markeren",
"compose_form.spoiler": "Verberg tekst achter waarschuwing", "compose_form.spoiler": "Tekst achter waarschuwing verbergen",
"compose_form.private": "Mark als privé", "compose_form.private": "Als privé markeren",
"compose_form.privacy_disclaimer": "Je besloten status wordt afgeleverd aan vermelde gebruikers op {domains}. Vertrouw je {domainsCount, plural, one {that server} andere {those servers}}? Privé plaatsen werkt alleen op Mastodon servers. Als {domains} {domainsCount, plural, een {is not a Mastodon instance} andere {are not Mastodon instances}}, dan wordt er geen indicatie gegeven dat he bericht besloten is, waardoor het kan worden geboost of op andere manier zichtbaar worden voor niet bedoelde lezers.", "compose_form.privacy_disclaimer": "Je besloten status wordt afgeleverd aan vermelde gebruikers op {domains}. Vertrouw je {domainsCount, plural, one {that server} andere {those servers}}? Privé plaatsen werkt alleen op Mastodon servers. Als {domains} {domainsCount, plural, een {is not a Mastodon instance} andere {are not Mastodon instances}}, dan wordt er geen indicatie gegeven dat he bericht besloten is, waardoor het kan worden geboost of op andere manier zichtbaar worden voor niet bedoelde lezers.",
"compose_form.unlisted": "Niet tonen op openbare tijdlijnen", "compose_form.unlisted": "Niet op openbare tijdlijnen tonen",
"navigation_bar.edit_profile": "Bewerk profiel", "navigation_bar.edit_profile": "Profiel bewerken",
"navigation_bar.preferences": "Voorkeuren", "navigation_bar.preferences": "Voorkeuren",
"navigation_bar.community_timeline": "Lokale tijdlijn", "navigation_bar.community_timeline": "Lokale tijdlijn",
"navigation_bar.public_timeline": "Federatietijdlijn", "navigation_bar.public_timeline": "Federatietijdlijn",
"navigation_bar.logout": "Uitloggen", "navigation_bar.logout": "Afmelden",
"reply_indicator.cancel": "Annuleren", "reply_indicator.cancel": "Annuleren",
"search.placeholder": "Zoeken", "search.placeholder": "Zoeken",
"search.account": "Account", "search.account": "Account",
"search.hashtag": "Hashtag", "search.hashtag": "Hashtag",
"upload_button.label": "Toevoegen media", "upload_button.label": "Media toevoegen",
"upload_form.undo": "Ongedaan maken", "upload_form.undo": "Ongedaan maken",
"notification.follow": "{name} volgde jou", "notification.follow": "{name} volgde jou",
"notification.favourite": "{name} markeerde je status als favoriet", "notification.favourite": "{name} markeerde je status als favoriet",
"notification.reblog": "{name} boostte je status", "notification.reblog": "{name} boostte je status",
"notification.mention": "{name} vermeldde jou", "notification.mention": "{name} vermeldde jou",
"notifications.column_settings.alert": "Desktopmeldingen", "notifications.column_settings.alert": "Desktopmeldingen",
"notifications.column_settings.show": "Tonen in kolom", "notifications.column_settings.show": "In kolom tonen",
"notifications.column_settings.follow": "Nieuwe volgers:", "notifications.column_settings.follow": "Nieuwe volgers:",
"notifications.column_settings.favourite": "Favoriten:", "notifications.column_settings.favourite": "Favorieten:",
"notifications.column_settings.mention": "Vermeldingen:", "notifications.column_settings.mention": "Vermeldingen:",
"notifications.column_settings.reblog": "Boosts:", "notifications.column_settings.reblog": "Boosts:",
}; };

View file

@ -1,77 +1,130 @@
const no = { const no = {
"column_back_button.label": "Tilbake", "account.block": "Blokkér @{name}",
"lightbox.close": "Lukk", "account.disclaimer": "Denne brukeren er fra en annen instans. Dette tallet kan være høyere.",
"loading_indicator.label": "Laster...",
"status.mention": "Nevn @{name}",
"status.delete": "Slett",
"status.reply": "Svar",
"status.reblog": "Reblogg",
"status.favourite": "Lik",
"status.reblogged_by": "{name} reblogget",
"status.sensitive_warning": "Sensitivt innhold",
"status.sensitive_toggle": "Klikk for å vise",
"status.show_more": "Vis mer",
"status.show_less": "Vis mindre",
"status.open": "Utvid denne statusen",
"status.report": "Rapporter @{name}",
"video_player.toggle_sound": "Veksle lyd",
"account.mention": "Nevn @{name}",
"account.edit_profile": "Rediger profil", "account.edit_profile": "Rediger profil",
"account.follow": "Følg",
"account.followers": "Følgere",
"account.follows_you": "Følger deg",
"account.follows": "Følginger",
"account.mention": "Nevn @{name}",
"account.mute": "Demp @{name}",
"account.posts": "Poster",
"account.report": "Rapportér @{name}",
"account.requested": "Venter på godkjennelse",
"account.unblock": "Avblokker @{name}", "account.unblock": "Avblokker @{name}",
"account.unfollow": "Avfølg", "account.unfollow": "Avfølg",
"account.block": "Blokker @{name}", "account.unmute": "Avdemp @{name}",
"account.follow": "Følg", "boost_modal.combo": "You kan trykke {combo} for å hoppe over dette neste gang",
"account.posts": "Poster", "column_back_button.label": "Tilbake",
"account.follows": "Følginger",
"account.followers": "Følgere",
"account.follows_you": "Folger deg",
"account.requested": "Venter på godkjennelse",
"getting_started.heading": "Kom i gang",
"getting_started.about_addressing": "Du kan følge noen hvis du vet brukernavnet deres og domenet de er på ved å skrive en e-postadresse inn i søkeskjemaet.",
"getting_started.about_shortcuts": "Hvis målbrukeren er på samme domene som deg, vil kun brukernavnet også fungere. Den samme regelen gjelder når man nevner noen i statuser.",
"getting_started.open_source_notice": "Mastodon er programvare med fri kildekode. Du kan bidra eller rapportere problemer på GitHub på {github}. {apps}.",
"column.home": "Hjem",
"column.community": "Lokal tidslinje",
"column.public": "Forent tidslinje",
"column.notifications": "Varslinger",
"column.blocks": "Blokkerte brukere", "column.blocks": "Blokkerte brukere",
"column.community": "Lokal tidslinje",
"column.favourites": "Likt", "column.favourites": "Likt",
"tabs_bar.compose": "Komponer", "column.follow_requests": "Følgeforespørsler",
"tabs_bar.home": "Hjem", "column.home": "Hjem",
"tabs_bar.mentions": "Nevninger", "column.notifications": "Varslinger",
"tabs_bar.public": "Forent tidslinje", "column.public": "Felles tidslinje",
"tabs_bar.notifications": "Varslinger",
"compose_form.placeholder": "Hva har du på hjertet?", "compose_form.placeholder": "Hva har du på hjertet?",
"compose_form.privacy_disclaimer": "Din private status vil leveres til nevnte brukere på {domains}. Stoler du på {domainsCount, plural, one {den serveren} other {de serverne}}? Synlighet fungerer kun på Mastodon-instanser. Hvis {domains} {domainsCount, plural, one {ike er en Mastodon-instans} other {ikke er Mastodon-instanser}}, vil det ikke indikeres at posten din er privat, og den kan kanskje bli fremhevd eller på annen måte bli synlig for uventede mottakere.",
"compose_form.publish": "Tut", "compose_form.publish": "Tut",
"compose_form.sensitive": "Merk media som følsomt", "compose_form.sensitive": "Merk media som følsomt",
"compose_form.spoiler_placeholder": "Innholdsadvarsel",
"compose_form.spoiler": "Skjul tekst bak advarsel", "compose_form.spoiler": "Skjul tekst bak advarsel",
"compose_form.private": "Merk som privat", "emoji_button.label": "Sett inn emoji",
"compose_form.privacy_disclaimer": "Din private status vil leveres til nevnte brukere på {domains}. Stoler du på {domainsCount, plural, one {den serveren} other {de serverne}}? Synlighet fungerer kun på Mastodon-instanser. Hvis {domains} {domainsCount, plural, one {ike er en Mastodon-instans} other {ikke er Mastodon-instanser}}, vil det ikke indikeres at posten din er privat, og den kan kanskje bli reblogget eller på annen måte bli synlig for uventede mottakere.", "empty_column.community": "Den lokale tidslinjen er tom. Skriv noe offentlig for å få snøballen til å rulle!",
"compose_form.unlisted": "Ikke vis på offentlige tidslinjer", "empty_column.hashtag": "Det er ingenting i denne hashtagen ennå.",
"navigation_bar.edit_profile": "Rediger profil", "empty_column.home.public_timeline": "en offentlig tidslinje",
"navigation_bar.preferences": "Preferanser", "empty_column.home": "Du har ikke fulgt noen ennå. Besøk {publlic} eller bruk søk for å komme i gang og møte andre brukere.",
"navigation_bar.community_timeline": "Lokal tidslinje", "empty_column.notifications": "Du har ingen varsler ennå. Kommuniser med andre for å begynne samtalen.",
"navigation_bar.public_timeline": "Forent tidslinje", "empty_column.public": "Det er ingenting her! Skriv noe offentlig, eller følg brukere manuelt fra andre instanser for å fylle den opp",
"navigation_bar.logout": "Logg ut", "follow_request.authorize": "Autorisér",
"follow_request.reject": "Avvis",
"getting_started.apps": "Diverse apper er tilgjengelige",
"getting_started.heading": "Kom i gang",
"getting_started.open_source_notice": "Mastodon er fri programvare. Du kan bidra eller rapportere problemer på GitHub på {github}. {apps}.",
"home.column_settings.advanced": "Advansert",
"home.column_settings.basic": "Enkel",
"home.column_settings.filter_regex": "Filtrér med regulære uttrykk",
"home.column_settings.show_reblogs": "Vis fremhevinger",
"home.column_settings.show_replies": "Vis svar",
"home.settings": "Kolonneinnstillinger",
"lightbox.close": "Lukk",
"loading_indicator.label": "Laster...",
"media_gallery.toggle_visible": "Veksle synlighet",
"missing_indicator.label": "Ikke funnet",
"navigation_bar.blocks": "Blokkerte brukere", "navigation_bar.blocks": "Blokkerte brukere",
"navigation_bar.info": "Utvidet informasjon", "navigation_bar.community_timeline": "Lokal tidslinje",
"navigation_bar.edit_profile": "Rediger profil",
"navigation_bar.favourites": "Likt", "navigation_bar.favourites": "Likt",
"navigation_bar.follow_requests": "Følgeforespørsler",
"navigation_bar.info": "Utvidet informasjon",
"navigation_bar.logout": "Logg ut",
"navigation_bar.preferences": "Preferanser",
"navigation_bar.public_timeline": "Felles tidslinje",
"notification.favourite": "{name} likte din status",
"notification.follow": "{name} fulgte deg",
"notification.reblog": "{name} fremhevde din status",
"notifications.clear_confirmation": "Er du sikker på at du vil fjerne alle dine varsler?",
"notifications.clear": "Fjern varsler",
"notifications.column_settings.alert": "Skrivebordsvarslinger",
"notifications.column_settings.favourite": "Likt:",
"notifications.column_settings.follow": "Nye følgere:",
"notifications.column_settings.mention": "Nevninger:",
"notifications.column_settings.reblog": "Fremhevinger:",
"notifications.column_settings.show": "Vis i kolonne",
"notifications.column_settings.sound": "Spill lyd",
"notifications.settings": "Kolonneinstillinger",
"privacy.change": "Justér synlighet",
"privacy.direct.long": "Post kun til nevnte brukere",
"privacy.direct.short": "Direkte",
"privacy.private.long": "Post kun til følgere",
"privacy.private.short": "Privat",
"privacy.public.long": "Post kun til offentlige tidslinjer",
"privacy.public.short": "Offentlig",
"privacy.unlisted.long": "Ikke vis i offentlige tidslinjer",
"privacy.unlisted.short": "Uoppført",
"reply_indicator.cancel": "Avbryt", "reply_indicator.cancel": "Avbryt",
"report.heading": "Ny rapport",
"report.placeholder": "Tilleggskommentarer",
"report.submit": "Send inn",
"report.target": "Rapporterer",
"search_results.total": "{count} {count, plural, one {resultat} other {resultater}}",
"search.placeholder": "Søk", "search.placeholder": "Søk",
"search.account": "Konto", "search.status_by": "Status fra {name}",
"search.hashtag": "Hashtag", "status.delete": "Slett",
"status.favourite": "Lik",
"status.load_more": "Last mer",
"status.media_hidden": "Media skjult",
"status.mention": "Nevn @{name}",
"status.open": "Utvid denne statusen",
"status.reblog": "Fremhev",
"status.reblogged_by": "Fremhevd av {name}",
"status.reply": "Svar",
"status.report": "Rapporter @{name}",
"status.sensitive_toggle": "Klikk for å vise",
"status.sensitive_warning": "Følsomt innhold",
"status.show_less": "Vis mindre",
"status.show_more": "Vis mer",
"tabs_bar.compose": "Komponer",
"tabs_bar.federated_timeline": "Felles",
"tabs_bar.home": "Hjem",
"tabs_bar.local_timeline": "Lokal",
"tabs_bar.notifications": "Varslinger",
"upload_area.title": "Dra og slipp for å laste opp",
"upload_button.label": "Legg til media", "upload_button.label": "Legg til media",
"upload_form.undo": "Angre", "upload_form.undo": "Angre",
"notification.follow": "{name} fulgte deg", "upload_progress.label": "Laster opp...",
"notification.favourite": "{name} likte din status", "video_player.toggle_sound": "Veksle lyd",
"notification.reblog": "{name} reblogget din status", "video_player.toggle_visible": "Veksle synlighet",
"notification.mention": "{name} nevnte deg", "video_player.expand": "Utvid video",
"notifications.column_settings.alert": "Skrivebordsvarslinger", "getting_started.about_addressing": "Du kan følge noen hvis du vet brukernavnet deres og domenet de er på ved å skrive en e-postadresse inn i søkeskjemaet.",
"notifications.column_settings.show": "Vis i kolonne", "getting_started.about_shortcuts": "Hvis målbrukeren er på samme domene som deg, vil kun brukernavnet også fungere. Den samme regelen gjelder når man nevner noen i statuser.",
"notifications.column_settings.follow": "Nye følgere:", "tabs_bar.mentions": "Nevninger",
"notifications.column_settings.favourite": "Likt:", "tabs_bar.public": "Felles tidslinje",
"notifications.column_settings.mention": "Nevninger:", "compose_form.private": "Merk som privat",
"notifications.column_settings.reblog": "Reblogginger:", "compose_form.unlisted": "Ikke vis på offentlige tidslinjer",
"search.account": "Konto",
"search.hashtag": "Hashtag",
"notification.mention": "{name} nevnte deg"
}; };
export default no; export default no;

View file

@ -2,7 +2,9 @@ const ru = {
"column_back_button.label": "Назад", "column_back_button.label": "Назад",
"lightbox.close": "Закрыть", "lightbox.close": "Закрыть",
"loading_indicator.label": "Загрузка...", "loading_indicator.label": "Загрузка...",
"missing_indicator.label": "Не найдено",
"status.mention": "Упомянуть @{name}", "status.mention": "Упомянуть @{name}",
"status.media_hidden": "Медиаконтент скрыт",
"status.delete": "Удалить", "status.delete": "Удалить",
"status.reply": "Ответить", "status.reply": "Ответить",
"status.reblog": "Продвинуть", "status.reblog": "Продвинуть",
@ -16,18 +18,23 @@ const ru = {
"status.report": "Пожаловаться", "status.report": "Пожаловаться",
"status.load_more": "Показать еще", "status.load_more": "Показать еще",
"video_player.toggle_sound": "Вкл./выкл. звук", "video_player.toggle_sound": "Вкл./выкл. звук",
"video_player.toggle_visible": "Показать/скрыть",
"account.disclaimer": "Это пользователь с другого узла. Число может быть больше.",
"account.mention": "Упомянуть", "account.mention": "Упомянуть",
"account.edit_profile": "Изменить профиль", "account.edit_profile": "Изменить профиль",
"account.unblock": "Разблокировать", "account.unblock": "Разблокировать",
"account.unfollow": "Отписаться", "account.unfollow": "Отписаться",
"account.block": "Блокировать", "account.block": "Блокировать",
"account.mute": "Заглушить", "account.mute": "Заглушить",
"account.report": "Пожаловаться",
"account.unmute": "Снять глушение",
"account.follow": "Подписаться", "account.follow": "Подписаться",
"account.posts": "Посты", "account.posts": "Посты",
"account.follows": "Подписки", "account.follows": "Подписки",
"account.followers": "Подписаны", "account.followers": "Подписаны",
"account.follows_you": "Подписан(а) на Вас", "account.follows_you": "Подписан(а) на Вас",
"account.requested": "Ожидает подтверждения", "account.requested": "Ожидает подтверждения",
"boost_modal.combo": "Нажмите {combo}, чтобы пропустить это в следующий раз",
"getting_started.heading": "Добро пожаловать", "getting_started.heading": "Добро пожаловать",
"getting_started.about_addressing": "Вы можете подписаться на человека, зная имя пользователя и домен, на котором он находится, введя e-mail-подобный адрес в форму поиска.", "getting_started.about_addressing": "Вы можете подписаться на человека, зная имя пользователя и домен, на котором он находится, введя e-mail-подобный адрес в форму поиска.",
"getting_started.about_shortcuts": "Если пользователь находится на одном с Вами домене, можно использовать только имя. То же правило применимо к упоминанию пользователей в статусах.", "getting_started.about_shortcuts": "Если пользователь находится на одном с Вами домене, можно использовать только имя. То же правило применимо к упоминанию пользователей в статусах.",
@ -37,11 +44,16 @@ const ru = {
"column.community": "Локальная лента", "column.community": "Локальная лента",
"column.public": "Глобальная лента", "column.public": "Глобальная лента",
"column.notifications": "Уведомления", "column.notifications": "Уведомления",
"column.favourites": "Понравившееся",
"column.blocks": "Список блокировки",
"column.follow_requests": "Запросы на подписку",
"tabs_bar.compose": "Написать", "tabs_bar.compose": "Написать",
"tabs_bar.home": "Главная", "tabs_bar.home": "Главная",
"tabs_bar.mentions": "Упоминания", "tabs_bar.mentions": "Упоминания",
"tabs_bar.public": "Глобальная лента", "tabs_bar.public": "Глобальная лента",
"tabs_bar.notifications": "Уведомления", "tabs_bar.notifications": "Уведомления",
"tabs_bar.local_timeline": "Локальная",
"tabs_bar.federated_timeline": "Глобальная",
"compose_form.placeholder": "О чем Вы думаете?", "compose_form.placeholder": "О чем Вы думаете?",
"compose_form.publish": "Трубить", "compose_form.publish": "Трубить",
"compose_form.sensitive": "Отметить как чувствительный контент", "compose_form.sensitive": "Отметить как чувствительный контент",
@ -49,6 +61,7 @@ const ru = {
"compose_form.private": "Отметить как приватное", "compose_form.private": "Отметить как приватное",
"compose_form.privacy_disclaimer": "Ваш приватный статус будет доставлен упомянутым пользователям на доменах {domains}. Доверяете ли вы {domainsCount, plural, one {этому серверу} other {этим серверам}}? Приватность постов работает только на узлах Mastodon. Если {domains} {domainsCount, plural, one {не является узлом Mastodon} other {не являются узлами Mastodon}}, приватность поста не будет указана, и он может оказаться продвинут или иным образом показан не обозначенным Вами пользователям.", "compose_form.privacy_disclaimer": "Ваш приватный статус будет доставлен упомянутым пользователям на доменах {domains}. Доверяете ли вы {domainsCount, plural, one {этому серверу} other {этим серверам}}? Приватность постов работает только на узлах Mastodon. Если {domains} {domainsCount, plural, one {не является узлом Mastodon} other {не являются узлами Mastodon}}, приватность поста не будет указана, и он может оказаться продвинут или иным образом показан не обозначенным Вами пользователям.",
"compose_form.unlisted": "Не отображать в публичных лентах", "compose_form.unlisted": "Не отображать в публичных лентах",
"compose_form.spoiler_placeholder": "Не для всех",
"navigation_bar.edit_profile": "Изменить профиль", "navigation_bar.edit_profile": "Изменить профиль",
"navigation_bar.preferences": "Опции", "navigation_bar.preferences": "Опции",
"navigation_bar.community_timeline": "Локальная лента", "navigation_bar.community_timeline": "Локальная лента",
@ -57,12 +70,20 @@ const ru = {
"navigation_bar.info": "Об узле", "navigation_bar.info": "Об узле",
"navigation_bar.favourites": "Понравившееся", "navigation_bar.favourites": "Понравившееся",
"navigation_bar.blocks": "Список блокировки", "navigation_bar.blocks": "Список блокировки",
"navigation_bar.follow_requests": "Запросы на подписку",
"reply_indicator.cancel": "Отмена", "reply_indicator.cancel": "Отмена",
"report.target": "Жалуемся на",
"report.heading": "Новая жалоба",
"report.placeholder": "Комментарий",
"report.submit": "Отправить",
"search.placeholder": "Поиск", "search.placeholder": "Поиск",
"search.account": "Аккаунт", "search.account": "Аккаунт",
"search.hashtag": "Хэштег", "search.hashtag": "Хэштег",
"search.status_by": "Статус от {name}",
"upload_area.title": "Перетащите сюда, чтобы загрузить",
"upload_button.label": "Добавить медиаконтент", "upload_button.label": "Добавить медиаконтент",
"upload_form.undo": "Отменить", "upload_form.undo": "Отменить",
"upload_progress.label": "Загрузка...",
"notification.follow": "{name} подписался(-лась) на Вас", "notification.follow": "{name} подписался(-лась) на Вас",
"notification.favourite": "{name} понравился Ваш статус", "notification.favourite": "{name} понравился Ваш статус",
"notification.reblog": "{name} продвинул(а) Ваш статус", "notification.reblog": "{name} продвинул(а) Ваш статус",
@ -71,9 +92,10 @@ const ru = {
"home.column_settings.basic": "Основные", "home.column_settings.basic": "Основные",
"home.column_settings.advanced": "Дополнительные", "home.column_settings.advanced": "Дополнительные",
"home.column_settings.filter_regex": "Отфильтровать регулярным выражением", "home.column_settings.filter_regex": "Отфильтровать регулярным выражением",
"home.column_settings.show_replies": "Показывать продвижения", "home.column_settings.show_reblogs": "Показывать продвижения",
"home.column_settings.show_replies": "Показывать ответы", "home.column_settings.show_replies": "Показывать ответы",
"notifications.clear": "Очистить уведомления", "notifications.clear": "Очистить уведомления",
"notifications.clear_confirmation": "Вы уверены, что хотите очистить все уведомления?",
"notifications.settings": "Настройки колонки", "notifications.settings": "Настройки колонки",
"notifications.column_settings.alert": "Десктопные уведомления", "notifications.column_settings.alert": "Десктопные уведомления",
"notifications.column_settings.show": "Показывать в колонке", "notifications.column_settings.show": "Показывать в колонке",
@ -96,6 +118,10 @@ const ru = {
"privacy.private.long": "Показать только подписчикам", "privacy.private.long": "Показать только подписчикам",
"privacy.direct.short": "Направленный", "privacy.direct.short": "Направленный",
"privacy.direct.long": "Показать только упомянутым", "privacy.direct.long": "Показать только упомянутым",
"emoji_button.label": "Вставить эмодзи",
"follow_request.authorize": "Авторизовать",
"follow_request.reject": "Отказать",
"media_gallery.toggle_visible": "Показать/скрыть",
}; };
export default ru; export default ru;

View file

@ -19,19 +19,27 @@ export { localeData as localeData };
const zh_hk = { const zh_hk = {
"account.block": "封鎖 @{name}", "account.block": "封鎖 @{name}",
"account.disclaimer": "由於這個用戶在另一個服務站,實際數字會比這個更多。",
"account.edit_profile": "修改個人資料", "account.edit_profile": "修改個人資料",
"account.follow": "關注", "account.follow": "關注",
"account.followers": "關注的人", "account.followers": "關注的人",
"account.follows_you": "關注你", "account.follows_you": "關注你",
"account.follows": "正在關注", "account.follows": "正在關注",
"account.mention": "提及 @{name}", "account.mention": "提及 @{name}",
"account.mute": "將 @{name} 靜音",
"account.posts": "文章", "account.posts": "文章",
"account.report": "舉報 @{name}",
"account.requested": "等候審批", "account.requested": "等候審批",
"account.unblock": "解除對 @{name} 的封鎖", "account.unblock": "解除對 @{name} 的封鎖",
"account.unfollow": "取消關注", "account.unfollow": "取消關注",
"column_back_button.label": "先前顯示", "account.unmute": "取消 @{name} 的靜音",
"boost_modal.combo": "如你想在下次路過這顯示,請按{combo}",
"column_back_button.label": "返回",
"column.blocks": "封鎖用戶",
"column.community": "本站時間軸", "column.community": "本站時間軸",
"column.home": "家", "column.favourites": "喜歡的文章",
"column.follow_requests": "關注請求",
"column.home": "主頁",
"column.notifications": "通知", "column.notifications": "通知",
"column.public": "跨站公共時間軸", "column.public": "跨站公共時間軸",
"compose_form.placeholder": "你在想甚麼?", "compose_form.placeholder": "你在想甚麼?",
@ -39,35 +47,49 @@ const zh_hk = {
"compose_form.private": "標示為「只有關注你的人能看」", "compose_form.private": "標示為「只有關注你的人能看」",
"compose_form.publish": "發文", "compose_form.publish": "發文",
"compose_form.sensitive": "將媒體檔案標示為「敏感內容」", "compose_form.sensitive": "將媒體檔案標示為「敏感內容」",
"compose_form.spoiler_placeholder": "敏感內容",
"compose_form.spoiler": "將部份文字藏於警告訊息之後", "compose_form.spoiler": "將部份文字藏於警告訊息之後",
"compose_form.unlisted": "請勿在公共時間軸顯示", "compose_form.unlisted": "請勿在公共時間軸顯示",
"emoji_button.label": "加入表情符號",
"empty_column.community": "本站時間軸暫時未有內容,快貼文來搶頭香啊!", "empty_column.community": "本站時間軸暫時未有內容,快貼文來搶頭香啊!",
"empty_column.hashtag": "這個標籤暫時未有內容。", "empty_column.hashtag": "這個標籤暫時未有內容。",
"empty_column.home": "你還沒有關注任何用戶。快看看{public},向其他用戶搭訕吧。", "empty_column.home": "你還沒有關注任何用戶。快看看{public},向其他用戶搭訕吧。",
"empty_column.home.public_timeline": "公共時間軸", "empty_column.home.public_timeline": "公共時間軸",
"empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.", "empty_column.home": "你還沒有關注任何用戶。快看看{public},向其他用戶搭訕吧。",
"empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up.", "empty_column.notifications": "你沒有任何通知紀錄,快向其他用戶搭訕吧。",
"empty_column.public": "跨站公共時間軸暫時沒有內容!快寫一些公共的文章,或者關注另一些服務站的用戶吧!你和本站、友站的交流,將決定這裏出現的內容。",
"follow_request.authorize": "批准",
"follow_request.reject": "拒絕",
"getting_started.about_addressing": "只要你知道一位用戶的用戶名稱和域名,你可以用「@用戶名稱@域名」的格式在搜尋欄尋找該用戶。", "getting_started.about_addressing": "只要你知道一位用戶的用戶名稱和域名,你可以用「@用戶名稱@域名」的格式在搜尋欄尋找該用戶。",
"getting_started.about_shortcuts": "只要該用戶是在你現在的服務站開立,你可以直接輸入用戶𠱷搜尋。同樣的規則適用於在文章提及別的用戶。", "getting_started.about_shortcuts": "只要該用戶是在你現在的服務站開立,你可以直接輸入用戶𠱷搜尋。同樣的規則適用於在文章提及別的用戶。",
"getting_started.apps": "手機或桌面應用程式", "getting_started.apps": "手機或桌面應用程式",
"getting_started.heading": "開始使用", "getting_started.heading": "開始使用",
"getting_started.open_source_notice": "Mastodon 是一個開放源碼的軟件。你可以在官方 GitHub ({github}) 貢獻或者回報問題。你亦可透過{apps}閱讀 Mastodon 上的消息。", "getting_started.open_source_notice": "Mastodon 是一個開放源碼的軟件。你可以在官方 GitHub ({github}) 貢獻或者回報問題。你亦可透過{apps}閱讀 Mastodon 上的消息。",
"home.column_settings.advanced": "進階",
"home.column_settings.basic": "基本", "home.column_settings.basic": "基本",
"home.column_settings.filter_regex": "使用正規表達式 (regular expression) 過濾",
"home.column_settings.show_reblogs": "顯示被轉推的文章", "home.column_settings.show_reblogs": "顯示被轉推的文章",
"home.column_settings.show_replies": "顯示回應文章", "home.column_settings.show_replies": "顯示回應文章",
"home.column_settings.advanced": "進階", "home.settings": "欄位設定",
"lightbox.close": "關閉", "lightbox.close": "Close",
"loading_indicator.label": "載入中...", "loading_indicator.label": "載入中...",
"media_gallery.toggle_visible": "打開或關上",
"missing_indicator.label": "找不到內容", "missing_indicator.label": "找不到內容",
"navigation_bar.blocks": "被封鎖的用戶",
"navigation_bar.community_timeline": "本站時間軸", "navigation_bar.community_timeline": "本站時間軸",
"navigation_bar.edit_profile": "修改個人資料", "navigation_bar.edit_profile": "修改個人資料",
"navigation_bar.favourites": "喜歡的內容",
"navigation_bar.follow_requests": "關注請求",
"navigation_bar.info": "關於本服務站",
"navigation_bar.logout": "登出", "navigation_bar.logout": "登出",
"navigation_bar.preferences": "個人設定", "navigation_bar.preferences": "偏好設定",
"navigation_bar.public_timeline": "跨站公共時間軸", "navigation_bar.public_timeline": "跨站公共時間軸",
"notification.favourite": "{name} 喜歡你的文章", "notification.favourite": "{name} 喜歡你的文章",
"notification.follow": "{name} 開始開始你", "notification.follow": "{name} 開始關注你",
"notification.mention": "{name} 提及你", "notification.mention": "{name} 提及你",
"notification.reblog": "{name} 轉推你的文章", "notification.reblog": "{name} 轉推你的文章",
"notifications.clear_confirmation": "你確定要清空通知紀錄嗎?",
"notifications.clear": "清空通知紀錄",
"notifications.column_settings.alert": "顯示桌面通知", "notifications.column_settings.alert": "顯示桌面通知",
"notifications.column_settings.favourite": "喜歡你的文章:", "notifications.column_settings.favourite": "喜歡你的文章:",
"notifications.column_settings.follow": "關注你:", "notifications.column_settings.follow": "關注你:",
@ -75,13 +97,26 @@ const zh_hk = {
"notifications.column_settings.reblog": "轉推你的文章:", "notifications.column_settings.reblog": "轉推你的文章:",
"notifications.column_settings.show": "在通知欄顯示", "notifications.column_settings.show": "在通知欄顯示",
"notifications.column_settings.sound": "播放音效", "notifications.column_settings.sound": "播放音效",
"notifications.settings": "欄位設定",
"privacy.change": "調整私隱設定",
"privacy.direct.long": "只有提及的用戶能看到",
"privacy.direct.short": "私人訊息",
"privacy.private.long": "只有關注你用戶能看到",
"privacy.private.short": "關注者",
"privacy.public.long": "在公共時間軸顯示",
"privacy.public.short": "公共",
"privacy.unlisted.long": "公開,但不在公共時間軸顯示",
"privacy.unlisted.short": "公開",
"reply_indicator.cancel": "取消", "reply_indicator.cancel": "取消",
"report.heading": "舉報",
"report.placeholder": "額外訊息",
"report.submit": "提交",
"report.target": "Reporting", "report.target": "Reporting",
"search_results.total": "{count} 項結果",
"search.account": "用戶", "search.account": "用戶",
"search.hashtag": "標籤", "search.hashtag": "標籤",
"search.placeholder": "搜尋", "search.placeholder": "搜尋",
"search_results.total": "{count} 項結果", "search.status_by": "按{name}搜尋文章",
"search.status_by": "按用戶名稱搜尋文章",
"status.delete": "刪除", "status.delete": "刪除",
"status.favourite": "喜歡", "status.favourite": "喜歡",
"status.load_more": "載入更多", "status.load_more": "載入更多",
@ -97,17 +132,19 @@ const zh_hk = {
"status.show_less": "減少顯示", "status.show_less": "減少顯示",
"status.show_more": "顯示更多", "status.show_more": "顯示更多",
"tabs_bar.compose": "撰寫", "tabs_bar.compose": "撰寫",
"tabs_bar.home": "家", "tabs_bar.federated_timeline": "跨站",
"tabs_bar.home": "主頁",
"tabs_bar.local_timeline": "本站", "tabs_bar.local_timeline": "本站",
"tabs_bar.mentions": "提及", "tabs_bar.mentions": "提及",
"tabs_bar.notifications": "通知", "tabs_bar.notifications": "通知",
"tabs_bar.public": "跨站公共時間軸", "tabs_bar.public": "跨站公共時間軸",
"tabs_bar.federated_timeline": "跨站",
"upload_area.title": "將檔案拖放至此上載", "upload_area.title": "將檔案拖放至此上載",
"upload_button.label": "上載媒體檔案", "upload_button.label": "上載媒體檔案",
"upload_progress.label": "上載中……",
"upload_form.undo": "還原", "upload_form.undo": "還原",
"upload_progress.label": "上載中……",
"video_player.expand": "展開影片",
"video_player.toggle_sound": "開關音效", "video_player.toggle_sound": "開關音效",
"video_player.toggle_visible": "打開或關上",
}; };
export default zh_hk; export default zh_hk;

View file

@ -22,7 +22,7 @@ export default function errorsMiddleware() {
dispatch(showAlert(title, message)); dispatch(showAlert(title, message));
} else { } else {
console.error(action.error); console.error(action.error); // eslint-disable-line no-console
dispatch(showAlert('Oops!', 'An unexpected error occurred.')); dispatch(showAlert('Oops!', 'An unexpected error occurred.'));
} }
} }

View file

@ -15,6 +15,10 @@ import {
BLOCKS_FETCH_SUCCESS, BLOCKS_FETCH_SUCCESS,
BLOCKS_EXPAND_SUCCESS BLOCKS_EXPAND_SUCCESS
} from '../actions/blocks'; } from '../actions/blocks';
import {
MUTES_FETCH_SUCCESS,
MUTES_EXPAND_SUCCESS
} from '../actions/mutes';
import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose'; import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
import { import {
REBLOG_SUCCESS, REBLOG_SUCCESS,
@ -94,6 +98,8 @@ export default function accounts(state = initialState, action) {
case FOLLOW_REQUESTS_EXPAND_SUCCESS: case FOLLOW_REQUESTS_EXPAND_SUCCESS:
case BLOCKS_FETCH_SUCCESS: case BLOCKS_FETCH_SUCCESS:
case BLOCKS_EXPAND_SUCCESS: case BLOCKS_EXPAND_SUCCESS:
case MUTES_FETCH_SUCCESS:
case MUTES_EXPAND_SUCCESS:
return normalizeAccounts(state, action.accounts); return normalizeAccounts(state, action.accounts);
case NOTIFICATIONS_REFRESH_SUCCESS: case NOTIFICATIONS_REFRESH_SUCCESS:
case NOTIFICATIONS_EXPAND_SUCCESS: case NOTIFICATIONS_EXPAND_SUCCESS:

View file

@ -2,6 +2,7 @@ import { STORE_HYDRATE } from '../actions/store';
import Immutable from 'immutable'; import Immutable from 'immutable';
const initialState = Immutable.Map({ const initialState = Immutable.Map({
streaming_api_base_url: null,
access_token: null, access_token: null,
me: null me: null
}); });

View file

@ -16,6 +16,10 @@ import {
BLOCKS_FETCH_SUCCESS, BLOCKS_FETCH_SUCCESS,
BLOCKS_EXPAND_SUCCESS BLOCKS_EXPAND_SUCCESS
} from '../actions/blocks'; } from '../actions/blocks';
import {
MUTES_FETCH_SUCCESS,
MUTES_EXPAND_SUCCESS
} from '../actions/mutes';
import Immutable from 'immutable'; import Immutable from 'immutable';
const initialState = Immutable.Map({ const initialState = Immutable.Map({
@ -24,7 +28,8 @@ const initialState = Immutable.Map({
reblogged_by: Immutable.Map(), reblogged_by: Immutable.Map(),
favourited_by: Immutable.Map(), favourited_by: Immutable.Map(),
follow_requests: Immutable.Map(), follow_requests: Immutable.Map(),
blocks: Immutable.Map() blocks: Immutable.Map(),
mutes: Immutable.Map()
}); });
const normalizeList = (state, type, id, accounts, next) => { const normalizeList = (state, type, id, accounts, next) => {
@ -65,6 +70,10 @@ export default function userLists(state = initialState, action) {
return state.setIn(['blocks', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next); return state.setIn(['blocks', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
case BLOCKS_EXPAND_SUCCESS: case BLOCKS_EXPAND_SUCCESS:
return state.updateIn(['blocks', 'items'], list => list.push(...action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next); return state.updateIn(['blocks', 'items'], list => list.push(...action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
case MUTES_FETCH_SUCCESS:
return state.setIn(['mutes', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
case MUTES_EXPAND_SUCCESS:
return state.updateIn(['mutes', 'items'], list => list.push(...action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
default: default:
return state; return state;
} }

View file

@ -10,8 +10,8 @@ const createWebSocketURL = (url) => {
return a.href; return a.href;
}; };
export default function getStream(accessToken, stream, { connected, received, disconnected, reconnected }) { export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) {
const ws = new WebSocketClient(`${createWebSocketURL(STREAMING_API_BASE_URL)}/api/v1/streaming/?access_token=${accessToken}&stream=${stream}`); const ws = new WebSocketClient(`${createWebSocketURL(streamingAPIBaseURL)}/api/v1/streaming/?access_token=${accessToken}&stream=${stream}`);
ws.onopen = connected; ws.onopen = connected;
ws.onmessage = e => received(JSON.parse(e.data)); ws.onmessage = e => received(JSON.parse(e.data));

View file

@ -64,7 +64,7 @@
p, li { p, li {
font: 16px/28px 'Montserrat', sans-serif; font: 16px/28px 'Montserrat', sans-serif;
font-weight: 400; font-weight: 400;
margin-bottom: 26px; margin-bottom: 12px;
a { a {
color: $color4; color: $color4;

View file

@ -2056,3 +2056,11 @@ button.icon-button.active i.fa-retweet {
flex: 0 0 auto; flex: 0 0 auto;
} }
} }
.loading-bar {
background-color: $color4;
height: 3px;
position: absolute;
top: 0;
left: 0;
}

View file

@ -6,3 +6,12 @@
margin: 0 5px; margin: 0 5px;
} }
} }
.recovery-codes {
column-count: 2;
height: 100px;
li {
list-style: decimal;
margin-left: 20px;
}
}

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
module Admin
class ResetsController < BaseController
before_action :set_account
def create
@account.user.send_reset_password_instructions
redirect_to admin_accounts_path
end
private
def set_account
@account = Account.find(params[:account_id])
end
end
end

View file

@ -14,7 +14,7 @@ class Api::OEmbedController < ApiController
def stream_entry_from_url(url) def stream_entry_from_url(url)
params = Rails.application.routes.recognize_path(url) params = Rails.application.routes.recognize_path(url)
raise ActiveRecord::NotFound unless params[:controller] == 'stream_entries' && params[:action] == 'show' raise ActiveRecord::RecordNotFound unless params[:controller] == 'stream_entries' && params[:action] == 'show'
StreamEntry.find(params[:id]) StreamEntry.find(params[:id])
end end

View file

@ -30,7 +30,7 @@ class Api::PushController < ApiController
params = Rails.application.routes.recognize_path(uri.path) params = Rails.application.routes.recognize_path(uri.path)
domain = uri.host + (uri.port ? ":#{uri.port}" : '') domain = uri.host + (uri.port ? ":#{uri.port}" : '')
return unless TagManager.instance.local_domain?(domain) && params[:controller] == 'accounts' && params[:action] == 'show' && params[:format] == 'atom' return unless TagManager.instance.web_domain?(domain) && params[:controller] == 'accounts' && params[:action] == 'show' && params[:format] == 'atom'
Account.find_local(params[:username]) Account.find_local(params[:username])
end end

View file

@ -8,7 +8,7 @@ class ApplicationController < ActionController::Base
force_ssl if: "Rails.env.production? && ENV['LOCAL_HTTPS'] == 'true'" force_ssl if: "Rails.env.production? && ENV['LOCAL_HTTPS'] == 'true'"
include Localized include Localized
helper_method :current_account helper_method :current_account, :single_user_mode?
rescue_from ActionController::RoutingError, with: :not_found rescue_from ActionController::RoutingError, with: :not_found
rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActiveRecord::RecordNotFound, with: :not_found
@ -69,6 +69,10 @@ class ApplicationController < ActionController::Base
end end
end end
def single_user_mode?
@single_user_mode ||= Rails.configuration.x.single_user_mode && Account.first
end
def current_account def current_account
@current_account ||= current_user.try(:account) @current_account ||= current_user.try(:account)
end end

View file

@ -28,7 +28,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end end
def check_enabled_registrations def check_enabled_registrations
redirect_to root_path if Rails.configuration.x.single_user_mode || !Setting.open_registrations redirect_to root_path if single_user_mode? || !Setting.open_registrations
end end
private private

View file

@ -49,7 +49,8 @@ class Auth::SessionsController < Devise::SessionsController
end end
def valid_otp_attempt?(user) def valid_otp_attempt?(user)
user.validate_and_consume_otp!(user_params[:otp_attempt]) user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
end end
def authenticate_with_two_factor def authenticate_with_two_factor

View file

@ -27,7 +27,11 @@ module Localized
def default_locale def default_locale
ENV.fetch('DEFAULT_LOCALE') { ENV.fetch('DEFAULT_LOCALE') {
http_accept_language.compatible_language_from(I18n.available_locales) || I18n.default_locale user_supplied_locale || I18n.default_locale
} }
end end
def user_supplied_locale
http_accept_language.language_region_compatible_from(I18n.available_locales)
end
end end

View file

@ -7,12 +7,13 @@ class HomeController < ApplicationController
@body_classes = 'app-body' @body_classes = 'app-body'
@token = find_or_create_access_token.token @token = find_or_create_access_token.token
@web_settings = Web::Setting.find_by(user: current_user)&.data || {} @web_settings = Web::Setting.find_by(user: current_user)&.data || {}
@streaming_api_base_url = Rails.configuration.x.streaming_api_base_url
end end
private private
def authenticate_user! def authenticate_user!
redirect_to(Rails.configuration.x.single_user_mode ? account_path(Account.first) : about_path) unless user_signed_in? redirect_to(single_user_mode? ? account_path(Account.first) : about_path) unless user_signed_in?
end end
def find_or_create_access_token def find_or_create_access_token

View file

@ -19,9 +19,9 @@ class Settings::TwoFactorAuthsController < ApplicationController
def create def create
if current_user.validate_and_consume_otp!(confirmation_params[:code]) if current_user.validate_and_consume_otp!(confirmation_params[:code])
current_user.otp_required_for_login = true current_user.otp_required_for_login = true
@codes = current_user.generate_otp_backup_codes!
current_user.save! current_user.save!
flash[:notice] = I18n.t('two_factor_auth.enabled_success')
redirect_to settings_two_factor_auth_path, notice: I18n.t('two_factor_auth.enabled_success')
else else
@confirmation = Form::TwoFactorConfirmation.new @confirmation = Form::TwoFactorConfirmation.new
set_qr_code set_qr_code
@ -30,6 +30,12 @@ class Settings::TwoFactorAuthsController < ApplicationController
end end
end end
def recovery_codes
@codes = current_user.generate_otp_backup_codes!
current_user.save!
flash[:notice] = I18n.t('two_factor_auth.recovery_codes_regenerated')
end
def disable def disable
current_user.otp_required_for_login = false current_user.otp_required_for_login = false
current_user.save! current_user.save!

View file

@ -1,9 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
module Admin::AccountsHelper module Admin::FilterHelper
def filter_params(more_params) ACCOUNT_FILTERS = %i[local remote by_domain silenced suspended recent].freeze
params.permit(:local, :remote, :by_domain, :silenced, :suspended, :recent, :resolved).merge(more_params) REPORT_FILTERS = %i[resolved].freeze
end
FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS
def filter_link_to(text, more_params) def filter_link_to(text, more_params)
new_url = filtered_url_for(more_params) new_url = filtered_url_for(more_params)
@ -16,6 +17,10 @@ module Admin::AccountsHelper
private private
def filter_params(more_params)
params.permit(FILTERS).merge(more_params)
end
def filter_link_class(new_url) def filter_link_class(new_url)
filtered_url_for(params) == new_url ? 'selected' : '' filtered_url_for(params) == new_url ? 'selected' : ''
end end

View file

@ -56,6 +56,10 @@ class TagManager
id.start_with?("tag:#{Rails.configuration.x.local_domain}") id.start_with?("tag:#{Rails.configuration.x.local_domain}")
end end
def web_domain?(domain)
domain.nil? || domain.gsub(/[\/]/, '').casecmp(Rails.configuration.x.web_domain).zero?
end
def local_domain?(domain) def local_domain?(domain)
domain.nil? || domain.gsub(/[\/]/, '').casecmp(Rails.configuration.x.local_domain).zero? domain.nil? || domain.gsub(/[\/]/, '').casecmp(Rails.configuration.x.local_domain).zero?
end end

View file

@ -5,7 +5,9 @@ class User < ApplicationRecord
devise :registerable, :recoverable, devise :registerable, :recoverable,
:rememberable, :trackable, :validatable, :confirmable, :rememberable, :trackable, :validatable, :confirmable,
:two_factor_authenticatable, otp_secret_encryption_key: ENV['OTP_SECRET'] :two_factor_authenticatable, :two_factor_backupable,
otp_secret_encryption_key: ENV['OTP_SECRET'],
otp_number_of_backup_codes: 10
belongs_to :account, inverse_of: :user belongs_to :account, inverse_of: :user
accepts_nested_attributes_for :account accepts_nested_attributes_for :account

View file

@ -3,7 +3,7 @@
class InstancePresenter class InstancePresenter
delegate( delegate(
:closed_registrations_message, :closed_registrations_message,
:contact_email, :site_contact_email,
:open_registrations, :open_registrations,
:site_description, :site_description,
:site_extended_description, :site_extended_description,

View file

@ -1,26 +1,86 @@
# frozen_string_literal: true # frozen_string_literal: true
class AccountSearchService < BaseService class AccountSearchService < BaseService
attr_reader :query, :limit, :resolve, :account
def call(query, limit, resolve = false, account = nil) def call(query, limit, resolve = false, account = nil)
return [] if query.blank? || query.start_with?('#') @query = query
@limit = limit
@resolve = resolve
@account = account
username, domain = query.gsub(/\A@/, '').split('@') search_service_results
domain = nil if TagManager.instance.local_domain?(domain) end
if domain.nil? private
exact_match = Account.find_local(username)
results = account.nil? ? Account.search_for(username, limit) : Account.advanced_search_for(username, account, limit) def search_service_results
return [] if query_blank_or_hashtag?
if resolving_non_matching_remote_account?
[FollowRemoteAccountService.new.call("#{query_username}@#{query_domain}")]
else else
exact_match = Account.find_remote(username, domain) search_results_and_exact_match.compact.uniq
results = account.nil? ? Account.search_for("#{username} #{domain}", limit) : Account.advanced_search_for("#{username} #{domain}", account, limit) end
end end
results = [exact_match] + results.reject { |a| a.id == exact_match.id } if exact_match def resolving_non_matching_remote_account?
resolve && !exact_match && !domain_is_local?
if resolve && !exact_match && !domain.nil?
results = [FollowRemoteAccountService.new.call("#{username}@#{domain}")]
end end
results def search_results_and_exact_match
[exact_match] + search_results.to_a
end
def query_blank_or_hashtag?
query.blank? || query.start_with?('#')
end
def split_query_string
@_split_query_string ||= query.gsub(/\A@/, '').split('@')
end
def query_username
@_query_username ||= split_query_string.first
end
def query_domain
@_query_domain ||= query_without_split? ? nil : split_query_string.last
end
def query_without_split?
split_query_string.size == 1
end
def domain_is_local?
@_domain_is_local ||= TagManager.instance.local_domain?(query_domain)
end
def exact_match
@_exact_match ||= Account.find_remote(query_username, query_domain)
end
def search_results
@_search_results ||= if account
advanced_search_results
else
simple_search_results
end
end
def advanced_search_results
Account.advanced_search_for(terms_for_query, account, limit)
end
def simple_search_results
Account.search_for(terms_for_query, limit)
end
def terms_for_query
if domain_is_local?
query_username
else
"#{query_username} #{query_domain}"
end
end end
end end

View file

@ -16,7 +16,7 @@ class FollowRemoteAccountService < BaseService
return Account.find_local(username) if TagManager.instance.local_domain?(domain) return Account.find_local(username) if TagManager.instance.local_domain?(domain)
account = Account.find_remote(username, domain) account = Account.find_remote(username, domain)
return account unless account.nil? return account unless account&.last_webfingered_at.nil? || 1.day.from_now(account.last_webfingered_at) < Time.now.utc
Rails.logger.debug "Looking up webfinger for #{uri}" Rails.logger.debug "Looking up webfinger for #{uri}"
@ -29,20 +29,24 @@ class FollowRemoteAccountService < BaseService
return Account.find_local(confirmed_username) if TagManager.instance.local_domain?(confirmed_domain) return Account.find_local(confirmed_username) if TagManager.instance.local_domain?(confirmed_domain)
confirmed_account = Account.find_remote(confirmed_username, confirmed_domain) confirmed_account = Account.find_remote(confirmed_username, confirmed_domain)
return confirmed_account unless confirmed_account.nil? if confirmed_account.nil?
Rails.logger.debug "Creating new remote account for #{uri}" Rails.logger.debug "Creating new remote account for #{uri}"
domain_block = DomainBlock.find_by(domain: domain) domain_block = DomainBlock.find_by(domain: domain)
account = Account.new(username: confirmed_username, domain: confirmed_domain) account = Account.new(username: confirmed_username, domain: confirmed_domain)
account.suspended = true if domain_block && domain_block.suspend?
account.silenced = true if domain_block && domain_block.silence?
account.private_key = nil
else
account = confirmed_account
end
account.last_webfingered_at = Time.now.utc
account.remote_url = data.link('http://schemas.google.com/g/2010#updates-from').href account.remote_url = data.link('http://schemas.google.com/g/2010#updates-from').href
account.salmon_url = data.link('salmon').href account.salmon_url = data.link('salmon').href
account.url = data.link('http://webfinger.net/rel/profile-page').href account.url = data.link('http://webfinger.net/rel/profile-page').href
account.public_key = magic_key_to_pem(data.link('magic-public-key').href) account.public_key = magic_key_to_pem(data.link('magic-public-key').href)
account.private_key = nil
account.suspended = true if domain_block && domain_block.suspend?
account.silenced = true if domain_block && domain_block.silence?
body, xml = get_feed(account.remote_url) body, xml = get_feed(account.remote_url)
hubs = get_hubs(xml) hubs = get_hubs(xml)

View file

@ -164,7 +164,7 @@ class ProcessFeedService < BaseService
url = Addressable::URI.parse(link['href']) url = Addressable::URI.parse(link['href'])
mentioned_account = if TagManager.instance.local_domain?(url.host) mentioned_account = if TagManager.instance.web_domain?(url.host)
Account.find_local(url.path.gsub('/users/', '')) Account.find_local(url.path.gsub('/users/', ''))
else else
Account.find_by(url: link['href']) || FetchRemoteAccountService.new.call(link['href']) Account.find_by(url: link['href']) || FetchRemoteAccountService.new.call(link['href'])

View file

@ -4,7 +4,7 @@ class Pubsubhubbub::UnsubscribeService < BaseService
def call(account, callback) def call(account, callback)
return ['Invalid topic URL', 422] if account.nil? return ['Invalid topic URL', 422] if account.nil?
subscription = Subscription.where(account: account, callback_url: callback) subscription = Subscription.find_by(account: account, callback_url: callback)
unless subscription.nil? unless subscription.nil?
Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'unsubscribe') Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'unsubscribe')

View file

@ -0,0 +1,15 @@
.panel
.panel-header= t 'about.contact'
.panel-body
- if contact.contact_account
.owner
.avatar= image_tag contact.contact_account.avatar.url
.name
= link_to TagManager.instance.url_for(contact.contact_account) do
%span.display_name.emojify= display_name(contact.contact_account)
%span.username= "@#{contact.contact_account.acct}"
- if contact.site_contact_email
.contact-email
= t 'about.business_email'
%strong= contact.site_contact_email

View file

@ -0,0 +1,11 @@
.panel
.panel-header= t 'about.links'
.panel-list
%ul
- if user_signed_in?
%li= link_to t('about.get_started'), root_path
- else
%li= link_to t('about.get_started'), new_user_registration_path
%li= link_to t('auth.login'), new_user_session_path
%li= link_to t('about.terms'), terms_path
%li= link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon'

View file

@ -28,29 +28,5 @@
.panel= @instance_presenter.site_extended_description.html_safe .panel= @instance_presenter.site_extended_description.html_safe
.sidebar .sidebar
.panel = render partial: 'contact', object: @instance_presenter
.panel-header= t 'about.contact' = render 'links'
.panel-body
- if @instance_presenter.contact_account
.owner
.avatar= image_tag @instance_presenter.contact_account.avatar.url
.name
= link_to TagManager.instance.url_for(@instance_presenter.contact_account) do
%span.display_name.emojify= display_name(@instance_presenter.contact_account)
%span.username= "@#{@instance_presenter.contact_account.acct}"
- unless @instance_presenter.contact_email.blank?
.contact-email
= t 'about.business_email'
%strong= @instance_presenter.contact_email
.panel
.panel-header= t 'about.links'
.panel-list
%ul
- if user_signed_in?
%li= link_to t('about.get_started'), root_path
- else
%li= link_to t('about.get_started'), new_user_registration_path
%li= link_to t('auth.login'), new_user_session_path
%li= link_to t('about.terms'), terms_path
%li= link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon'

View file

@ -17,7 +17,7 @@
.wrapper .wrapper
%h1 %h1
= image_tag 'logo.png' = image_tag 'logo.png'
Mastodon = Setting.site_title
%p= t('about.about_mastodon').html_safe %p= t('about.about_mastodon').html_safe

View file

@ -14,7 +14,7 @@
%meta{ property: 'og:image:height', content: '120' }/ %meta{ property: 'og:image:height', content: '120' }/
%meta{ property: 'twitter:card', content: 'summary' }/ %meta{ property: 'twitter:card', content: 'summary' }/
- if !user_signed_in? && !Rails.configuration.x.single_user_mode - if !user_signed_in? && !single_user_mode?
= render partial: 'shared/landing_strip', locals: { account: @account } = render partial: 'shared/landing_strip', locals: { account: @account }
.h-feed .h-feed

View file

@ -61,12 +61,16 @@
= surround '(', ')' do = surround '(', ')' do
= number_to_human_size @account.media_attachments.sum('file_file_size') = number_to_human_size @account.media_attachments.sum('file_file_size')
- if @account.silenced? %div{ style: 'float: right' }
= link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button'
%div{ style: 'float: left' }
- if @account.silenced?
= link_to t('admin.accounts.undo_silenced'), admin_account_silence_path(@account.id), method: :delete, class: 'button' = link_to t('admin.accounts.undo_silenced'), admin_account_silence_path(@account.id), method: :delete, class: 'button'
- else - else
= link_to t('admin.accounts.silence'), admin_account_silence_path(@account.id), method: :post, class: 'button' = link_to t('admin.accounts.silence'), admin_account_silence_path(@account.id), method: :post, class: 'button'
- if @account.suspended? - if @account.suspended?
= link_to t('admin.accounts.undo_suspension'), admin_account_suspension_path(@account.id), method: :delete, class: 'button' = link_to t('admin.accounts.undo_suspension'), admin_account_suspension_path(@account.id), method: :delete, class: 'button'
- else - else
= link_to t('admin.accounts.perform_full_suspension'), admin_account_suspension_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' = link_to t('admin.accounts.perform_full_suspension'), admin_account_suspension_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button'

View file

@ -2,7 +2,9 @@
= t('auth.login') = t('auth.login')
= simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| = simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
= f.input :otp_attempt, type: :number, placeholder: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt') }, required: true, autofocus: true, autocomplete: 'off' = f.input :otp_attempt, type: :number, placeholder: t('simple_form.labels.defaults.otp_attempt'),
input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt') }, required: true, autofocus: true, autocomplete: 'off',
hint: t('simple_form.hints.sessions.otp')
.actions .actions
= f.button :button, t('auth.login'), type: :submit = f.button :button, t('auth.login'), type: :submit

View file

@ -1,7 +1,5 @@
- content_for :header_tags do - content_for :header_tags do
:javascript %script#initial-state{:type => 'application/json'}!= json_escape(render(file: 'home/initial_state', formats: :json))
window.STREAMING_API_BASE_URL = '#{Rails.configuration.x.streaming_api_base_url}';
window.INITIAL_STATE = #{json_escape(render(file: 'home/initial_state', formats: :json))}
= javascript_include_tag 'application', integrity: true = javascript_include_tag 'application', integrity: true

View file

@ -2,6 +2,7 @@ object false
node(:meta) do node(:meta) do
{ {
streaming_api_base_url: @streaming_api_base_url,
access_token: @token, access_token: @token,
locale: I18n.locale, locale: I18n.locale,
me: current_account.id, me: current_account.id,

View file

@ -0,0 +1,7 @@
%p.hint= t('two_factor_auth.recovery_instructions')
%h3= t('two_factor_auth.recovery_codes')
%ol.recovery-codes
- @codes.each do |code|
%li
%samp= code

View file

@ -0,0 +1,4 @@
- content_for :page_title do
= t('settings.two_factor_auth')
= render 'recovery_codes'

View file

@ -0,0 +1,4 @@
- content_for :page_title do
= t('settings.two_factor_auth')
= render 'recovery_codes'

View file

@ -8,3 +8,8 @@
= link_to t('two_factor_auth.disable'), disable_settings_two_factor_auth_path, data: { method: 'POST' }, class: 'block-button' = link_to t('two_factor_auth.disable'), disable_settings_two_factor_auth_path, data: { method: 'POST' }, class: 'block-button'
- else - else
= link_to t('two_factor_auth.setup'), new_settings_two_factor_auth_path, class: 'block-button' = link_to t('two_factor_auth.setup'), new_settings_two_factor_auth_path, class: 'block-button'
- if current_user.otp_required_for_login
.simple_form
%p.hint= t('two_factor_auth.lost_recovery_codes')
= link_to t('two_factor_auth.generate_recovery_codes'), recovery_codes_settings_two_factor_auth_path, data: { method: 'POST' }, class: 'block-button'

View file

@ -20,7 +20,7 @@
%meta{ property: 'twitter:card', content: 'summary' }/ %meta{ property: 'twitter:card', content: 'summary' }/
- if !user_signed_in? && !Rails.configuration.x.single_user_mode - if !user_signed_in? && !single_user_mode?
= render partial: 'shared/landing_strip', locals: { account: @stream_entry.account } = render partial: 'shared/landing_strip', locals: { account: @stream_entry.account }
.activity-stream.activity-stream-headless.h-entry .activity-stream.activity-stream-headless.h-entry

View file

@ -3,7 +3,7 @@
.compact-header .compact-header
%h1< %h1<
= link_to 'Mastodon', root_path = link_to site_title, root_path
%small= "##{@tag.name}" %small= "##{@tag.name}"
- if @statuses.empty? - if @statuses.empty?

View file

@ -42,6 +42,7 @@ module Mastodon
:uk, :uk,
'zh-CN', 'zh-CN',
:'zh-HK', :'zh-HK',
:'zh-TW',
] ]
config.i18n.default_locale = :en config.i18n.default_locale = :en

View file

@ -55,6 +55,8 @@ Rails.application.configure do
ENV['REDIS_HOST'] = redis_url.host ENV['REDIS_HOST'] = redis_url.host
ENV['REDIS_PORT'] = redis_url.port.to_s ENV['REDIS_PORT'] = redis_url.port.to_s
ENV['REDIS_PASSWORD'] = redis_url.password ENV['REDIS_PASSWORD'] = redis_url.password
db_num = redis_url.path[1..-1]
ENV['REDIS_DB'] = db_num if db_num.present?
end end
# Use a different cache store in production. # Use a different cache store in production.
@ -62,7 +64,7 @@ Rails.application.configure do
host: ENV.fetch('REDIS_HOST') { 'localhost' }, host: ENV.fetch('REDIS_HOST') { 'localhost' },
port: ENV.fetch('REDIS_PORT') { 6379 }, port: ENV.fetch('REDIS_PORT') { 6379 },
password: ENV.fetch('REDIS_PASSWORD') { false }, password: ENV.fetch('REDIS_PASSWORD') { false },
db: 0, db: ENV.fetch('REDIS_DB') { 0 },
namespace: 'cache', namespace: 'cache',
expires_in: 10.minutes, expires_in: 10.minutes,
} }

View file

@ -1,6 +1,7 @@
Devise.setup do |config| Devise.setup do |config|
config.warden do |manager| config.warden do |manager|
manager.default_strategies(scope: :user).unshift :two_factor_authenticatable manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
manager.default_strategies(scope: :user).unshift :two_factor_backupable
end end
# The secret key used by Devise. Devise uses this key to generate # The secret key used by Devise. Devise uses this key to generate

View file

@ -2,18 +2,20 @@
port = ENV.fetch('PORT') { 3000 } port = ENV.fetch('PORT') { 3000 }
host = ENV.fetch('LOCAL_DOMAIN') { "localhost:#{port}" } host = ENV.fetch('LOCAL_DOMAIN') { "localhost:#{port}" }
web_host = ENV.fetch('WEB_DOMAIN') { host }
https = ENV['LOCAL_HTTPS'] == 'true' https = ENV['LOCAL_HTTPS'] == 'true'
Rails.application.configure do Rails.application.configure do
config.x.local_domain = host config.x.local_domain = host
config.x.web_domain = web_host
config.x.use_https = https config.x.use_https = https
config.x.use_s3 = ENV['S3_ENABLED'] == 'true' config.x.use_s3 = ENV['S3_ENABLED'] == 'true'
config.action_mailer.default_url_options = { host: host, protocol: https ? 'https://' : 'http://', trailing_slash: false } config.action_mailer.default_url_options = { host: web_host, protocol: https ? 'https://' : 'http://', trailing_slash: false }
config.x.streaming_api_base_url = 'http://localhost:4000' config.x.streaming_api_base_url = 'http://localhost:4000'
if Rails.env.production? if Rails.env.production?
config.action_cable.allowed_request_origins = ["http#{https ? 's' : ''}://#{host}"] config.action_cable.allowed_request_origins = ["http#{https ? 's' : ''}://#{web_host}"]
config.x.streaming_api_base_url = ENV.fetch('STREAMING_API_BASE_URL') { "http#{https ? 's' : ''}://#{host}" } config.x.streaming_api_base_url = ENV.fetch('STREAMING_API_BASE_URL') { "http#{https ? 's' : ''}://#{web_host}" }
end end
end end

View file

@ -38,4 +38,7 @@ if ENV['S3_ENABLED'] == 'true'
Paperclip::Attachment.default_options[:url] = ':s3_alias_url' Paperclip::Attachment.default_options[:url] = ':s3_alias_url'
Paperclip::Attachment.default_options[:s3_host_alias] = ENV['S3_CLOUDFRONT_HOST'] Paperclip::Attachment.default_options[:s3_host_alias] = ENV['S3_CLOUDFRONT_HOST']
end end
else
Paperclip::Attachment.default_options[:path] = (ENV['PAPERCLIP_ROOT_PATH'] || ':rails_root/public/system') + '/:class/:attachment/:id_partition/:style/:filename'
Paperclip::Attachment.default_options[:url] = (ENV['PAPERCLIP_ROOT_URL'] || '/system') + '/:class/:attachment/:id_partition/:style/:filename'
end end

View file

@ -1,11 +1,12 @@
host = ENV.fetch('REDIS_HOST') { 'localhost' } host = ENV.fetch('REDIS_HOST') { 'localhost' }
port = ENV.fetch('REDIS_PORT') { 6379 } port = ENV.fetch('REDIS_PORT') { 6379 }
password = ENV.fetch('REDIS_PASSWORD') { false } password = ENV.fetch('REDIS_PASSWORD') { false }
db = ENV.fetch('REDIS_DB') { 0 }
Sidekiq.configure_server do |config| Sidekiq.configure_server do |config|
config.redis = { host: host, port: port, password: password} config.redis = { host: host, port: port, db: db, password: password }
end end
Sidekiq.configure_client do |config| Sidekiq.configure_client do |config|
config.redis = { host: host, port: port, password: password } config.redis = { host: host, port: port, db: db, password: password }
end end

View file

@ -165,5 +165,3 @@ bg:
users: users:
invalid_email: E-mail адресът е невалиден invalid_email: E-mail адресът е невалиден
invalid_otp_token: Невалиден код invalid_otp_token: Невалиден код
will_paginate:
page_gap: "&hellip;"

View file

@ -6,12 +6,12 @@ fr:
send_instructions: Vous allez recevoir les instructions nécessaires à la confirmation de votre compte dans quelques minutes. send_instructions: Vous allez recevoir les instructions nécessaires à la confirmation de votre compte dans quelques minutes.
send_paranoid_instructions: Si votre e-mail existe dans notre base de données, vous allez bientôt recevoir un e-mail contenant les instructions de confirmation de votre compte. send_paranoid_instructions: Si votre e-mail existe dans notre base de données, vous allez bientôt recevoir un e-mail contenant les instructions de confirmation de votre compte.
failure: failure:
already_authenticated: Vous êtes déjà connecté already_authenticated: Vous êtes déjà connecté⋅e
inactive: Votre compte n'est pas encore activé. inactive: Votre compte nest pas encore activé.
invalid: Email ou mot de passe incorrect. invalid: Courriel ou mot de passe incorrect.
last_attempt: Vous avez droit à une tentative avant que votre compte ne soit verrouillé. last_attempt: Vous avez droit à une tentative avant que votre compte ne soit verrouillé.
locked: Votre compte est verrouillé. locked: Votre compte est verrouillé.
not_found_in_database: Email ou mot de passe invalide. not_found_in_database: Courriel ou mot de passe invalide.
timeout: Votre session a expiré. Veuillez vous reconnecter pour continuer. timeout: Votre session a expiré. Veuillez vous reconnecter pour continuer.
unauthenticated: Vous devez vous connecter ou vous inscrire pour continuer. unauthenticated: Vous devez vous connecter ou vous inscrire pour continuer.
unconfirmed: Vous devez valider votre compte pour continuer. unconfirmed: Vous devez valider votre compte pour continuer.
@ -25,7 +25,7 @@ fr:
unlock_instructions: unlock_instructions:
subject: Instructions pour déverrouiller votre compte subject: Instructions pour déverrouiller votre compte
omniauth_callbacks: omniauth_callbacks:
failure: 'Nous n''avons pas pu vous authentifier via %{kind} : ''%{reason}''.' failure: 'Nous navons pas pu vous authentifier via %{kind} : ''%{reason}''.'
success: Authentifié avec succès via %{kind}. success: Authentifié avec succès via %{kind}.
passwords: passwords:
no_token: Vous ne pouvez accéder à cette page sans passer par un e-mail de réinitialisation de mot de passe. Si vous êtes passé⋅e par un e-mail de ce type, assurez-vous d'utiliser l'URL complète. no_token: Vous ne pouvez accéder à cette page sans passer par un e-mail de réinitialisation de mot de passe. Si vous êtes passé⋅e par un e-mail de ce type, assurez-vous d'utiliser l'URL complète.
@ -36,10 +36,10 @@ fr:
registrations: registrations:
destroyed: Votre compte a été supprimé avec succès. Nous espérons vous revoir bientôt. destroyed: Votre compte a été supprimé avec succès. Nous espérons vous revoir bientôt.
signed_up: Bienvenue, vous êtes connecté⋅e. signed_up: Bienvenue, vous êtes connecté⋅e.
signed_up_but_inactive: Vous êtes bien enregistré⋅e. Vous ne pouvez cependant pas vous connecter car votre compte n'est pas encore activé. signed_up_but_inactive: Vous êtes bien enregistré⋅e. Vous ne pouvez cependant pas vous connecter car votre compte nest pas encore activé.
signed_up_but_locked: Vous êtes bien enregistré⋅e. Vous ne pouvez cependant pas vous connecter car votre compte est verrouillé. signed_up_but_locked: Vous êtes bien enregistré⋅e. Vous ne pouvez cependant pas vous connecter car votre compte est verrouillé.
signed_up_but_unconfirmed: Un message contenant un lien de confirmation a été envoyé à votre adresse email. Ouvrez ce lien pour activer votre compte. signed_up_but_unconfirmed: Un message contenant un lien de confirmation a été envoyé à votre adresse courriel. Ouvrez ce lien pour activer votre compte.
update_needs_confirmation: Votre compte a bien été mis à jour mais nous devons vérifier votre nouvelle adresse email. Merci de vérifier vos emails et de cliquer sur le lien de confirmation pour finaliser la validation de votre nouvelle adresse. update_needs_confirmation: Votre compte a bien été mis à jour mais nous devons vérifier votre nouvelle adresse courriel. Merci de vérifier vos courriels et de cliquer sur le lien de confirmation pour finaliser la validation de votre nouvelle adresse.
updated: Votre compte a été modifié avec succès. updated: Votre compte a été modifié avec succès.
sessions: sessions:
already_signed_out: Déconnecté. already_signed_out: Déconnecté.
@ -47,15 +47,15 @@ fr:
signed_out: Déconnecté. signed_out: Déconnecté.
unlocks: unlocks:
send_instructions: Vous allez recevoir les instructions nécessaires au déverrouillage de votre compte dans quelques instants send_instructions: Vous allez recevoir les instructions nécessaires au déverrouillage de votre compte dans quelques instants
send_paranoid_instructions: Si votre compte existe, vous allez bientôt recevoir un email contenant les instructions pour le déverrouiller. send_paranoid_instructions: Si votre compte existe, vous allez bientôt recevoir un courriel contenant les instructions pour le déverrouiller.
unlocked: Votre compte a été déverrouillé avec succès, vous êtes maintenant connecté⋅e. unlocked: Votre compte a été déverrouillé avec succès, vous êtes maintenant connecté⋅e.
errors: errors:
messages: messages:
already_confirmed: a déjà été validé⋅e, veuillez essayer de vous connecter already_confirmed: a déjà été validé⋅e, veuillez essayer de vous connecter
confirmation_period_expired: à confirmer dans les %{period}, merci de faire une nouvelle demande confirmation_period_expired: à confirmer dans les %{period}, merci de faire une nouvelle demande
expired: a expiré, merci d'en faire une nouvelle demande expired: a expiré, merci den faire une nouvelle demande
not_found: n'a pas été trouvé⋅e not_found: na pas été trouvé⋅e
not_locked: n'était pas verrouillé⋅e not_locked: nétait pas verrouillé⋅e
not_saved: not_saved:
one: '1 erreur a empêché ce(tte) %{resource} dêtre sauvegardé⋅e :' one: '1 erreur a empêché ce(tte) %{resource} dêtre sauvegardé⋅e :'
other: '%{count} erreurs ont empêché %{resource} dêtre sauvegardé⋅e :' other: '%{count} erreurs ont empêché %{resource} dêtre sauvegardé⋅e :'

View file

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

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