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
neo4j
vendor/bundle
.DS_Store
*.swp
*~

View file

@ -1,6 +1,7 @@
# Service dependencies
REDIS_HOST=redis
REDIS_PORT=6379
# REDIS_DB=0
DB_HOST=db
DB_USER=postgres
DB_NAME=postgres
@ -11,6 +12,10 @@ DB_PORT=5432
LOCAL_DOMAIN=example.com
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
# Generate each with the `rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose)
PAPERCLIP_SECRET=
@ -41,6 +46,10 @@ SMTP_FROM_ADDRESS=notifications@example.com
#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
# CDN_HOST=assets.example.com

View file

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

View file

@ -8,7 +8,8 @@
"parser": "babel-eslint",
"plugins": [
"react"
"react",
"jsx-a11y"
],
"parserOptions": {
@ -43,9 +44,36 @@
"no-mixed-spaces-and-tabs": 1,
"no-nested-ternary": 1,
"no-trailing-spaces": 1,
"react/wrap-multilines": 2,
"react/jsx-wrap-multilines": 2,
"react/self-closing-comp": 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
config/deploy/*
# Ignore IDE files
.vscode/
# Ignore postgres + redis volume optionally created by docker-compose
postgres
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
env:
matrix:
- TRAVIS_NODE_VERSION="4"
global:
- LOCAL_DOMAIN=cb6e6126.ngrok.io
- LOCAL_HTTPS=true
@ -28,8 +26,7 @@ before_install:
- sudo apt-get -qq update
- sudo apt-get -qq install g++-4.8
install:
- nvm install $TRAVIS_NODE_VERSION
- npm install -g npm@3
- nvm install
- npm install -g yarn
- bundle install
- yarn install

View file

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

View file

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

View file

@ -99,6 +99,13 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
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)
climate_control (0.1.0)
cocaine (0.5.8)
@ -233,6 +240,10 @@ GEM
mail (2.6.4)
mime-types (>= 1.16, < 4)
method_source (0.8.2)
microformats2 (2.1.0)
activesupport
json
nokogiri
mime-types (3.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2016.0521)
@ -308,6 +319,9 @@ GEM
nokogiri (~> 1.6)
rails-html-sanitizer (1.0.3)
loofah (~> 2.0)
rails-i18n (5.0.3)
i18n (~> 0.7)
railties (~> 5.0)
rails-settings-cached (0.6.5)
rails (>= 4.2.0)
rails_12factor (0.0.3)
@ -447,6 +461,8 @@ GEM
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.2)
whatlanguage (1.0.6)
xpath (2.0.0)
nokogiri (~> 1.3)
PLATFORMS
ruby
@ -466,6 +482,7 @@ DEPENDENCIES
capistrano-rails
capistrano-rbenv
capistrano-yarn
capybara
coffee-rails (~> 4.1.0)
devise
devise-two-factor
@ -490,6 +507,7 @@ DEPENDENCIES
letter_opener_web
link_header
lograge
microformats2
nokogiri
oj
ostatus2
@ -507,6 +525,7 @@ DEPENDENCIES
rack-timeout
rails (~> 5.0.2)
rails-controller-testing
rails-i18n
rails-settings-cached
rails_12factor
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
export PATH="$HOME/.rbenv/bin::$PATH"
export PATH="$HOME/.rbenv/bin:$PATH"
eval "$(rbenv init -)"
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' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
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 = {
@ -25,6 +26,7 @@ const Account = React.createClass({
me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired,
onBlock: React.PropTypes.func.isRequired,
onMute: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired
},
@ -38,6 +40,10 @@ const Account = React.createClass({
this.props.onBlock(this.props.account);
},
handleMute () {
this.props.onMute(this.props.account);
},
render () {
const { account, me, intl } = this.props;
@ -51,11 +57,14 @@ const Account = React.createClass({
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
if (requested) {
buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
} 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 {
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'>
{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} />
</div>
))}

View file

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

View file

@ -15,13 +15,13 @@ const ColumnBackButton = React.createClass({
mixins: [PureRenderMixin],
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();
},
render () {
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} />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</div>

View file

@ -31,7 +31,7 @@ const ColumnBackButtonSlim = React.createClass({
render () {
return (
<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} />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</div>

View file

@ -46,7 +46,9 @@ const ColumnCollapsable = React.createClass({
return (
<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 }) }}>
{({ opacity, height }) =>

View file

@ -1,7 +1,7 @@
import { FormattedMessage } from 'react-intl';
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' />
</a>
);

View file

@ -220,7 +220,7 @@ const MediaGallery = React.createClass({
}
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={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>

View file

@ -6,7 +6,8 @@ const Permalink = React.createClass({
propTypes: {
href: React.PropTypes.string.isRequired,
to: React.PropTypes.string.isRequired
to: React.PropTypes.string.isRequired,
children: React.PropTypes.node
},
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 });
}
let reblogIcon = 'retweet';
if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
else if (status.get('visibility') === 'private') reblogIcon = 'lock';
return (
<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 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={{ width: '18px', height: '18px', float: 'left' }}>

View file

@ -119,7 +119,7 @@ const StatusContent = React.createClass({
return (
<div className='status__content' style={{ cursor: 'pointer' }} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<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>
{mentionsPlaceholder}

View file

@ -194,7 +194,7 @@ const VideoPlayer = React.createClass({
if (!this.state.visible) {
if (sensitive) {
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}
<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>
@ -202,7 +202,7 @@ const VideoPlayer = React.createClass({
);
} else {
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}
<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>
@ -213,7 +213,7 @@ const VideoPlayer = React.createClass({
if (this.state.preview && !autoplay) {
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}
<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>
@ -225,7 +225,7 @@ const VideoPlayer = React.createClass({
{spoilerButton}
{muteButton}
{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>
);
}

View file

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

View file

@ -43,7 +43,16 @@ const Avatar = React.createClass({
return (
<Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
{({ radius }) =>
<a href={account.get('url')} className='account__header__avatar' target='_blank' rel='noopener' style={{ display: 'block', width: '90px', height: '90px', margin: '0 auto', marginBottom: '10px', borderRadius: `${radius}px`, overflow: 'hidden' }} onMouseOver={this.handleMouseOver} onMouseOut={this.handleMouseOut}>
<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' }} />
</a>
}

View file

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

View file

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

View file

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

View file

@ -14,6 +14,7 @@ const messages = defineMessages({
sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
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' />
{followRequests}
<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='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
</div>

View file

@ -13,6 +13,7 @@ import createStream from '../../stream';
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0,
streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
accessToken: state.getIn(['meta', 'access_token'])
});
@ -21,6 +22,7 @@ const HashtagTimeline = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
streamingAPIBaseURL: React.PropTypes.string.isRequired,
accessToken: React.PropTypes.string.isRequired,
hasUnread: React.PropTypes.bool
},
@ -28,9 +30,9 @@ const HashtagTimeline = React.createClass({
mixins: [PureRenderMixin],
_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) {
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;
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' />
</div>
);

View file

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

View file

@ -71,7 +71,7 @@ const Notification = React.createClass({
);
},
render () {
render () { // eslint-disable-line consistent-return
const { notification } = this.props;
const account = notification.get('account');
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'
};
const SettingToggle = ({ settings, settingKey, label, onChange }) => (
<label style={labelStyle}>
const SettingToggle = ({ settings, settingKey, label, onChange, htmlFor = '' }) => (
<label htmlFor={htmlFor} style={labelStyle}>
<Toggle checked={settings.getIn(settingKey)} onChange={(e) => onChange(settingKey, e.target.checked)} />
<span className='setting-toggle' style={labelSpanStyle}>{label}</span>
</label>
@ -25,7 +25,8 @@ SettingToggle.propTypes = {
settings: ImmutablePropTypes.map.isRequired,
settingKey: React.PropTypes.array.isRequired,
label: React.PropTypes.node.isRequired,
onChange: React.PropTypes.func.isRequired
onChange: React.PropTypes.func.isRequired,
htmlFor: React.PropTypes.string
};
export default SettingToggle;

View file

@ -19,6 +19,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
accessToken: state.getIn(['meta', 'access_token'])
});
@ -29,6 +30,7 @@ const PublicTimeline = React.createClass({
propTypes: {
dispatch: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired,
streamingAPIBaseURL: React.PropTypes.string.isRequired,
accessToken: React.PropTypes.string.isRequired,
hasUnread: React.PropTypes.bool
},
@ -36,7 +38,7 @@ const PublicTimeline = React.createClass({
mixins: [PureRenderMixin],
componentDidMount () {
const { dispatch, accessToken } = this.props;
const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
dispatch(refreshTimeline('public'));
@ -44,7 +46,7 @@ const PublicTimeline = React.createClass({
return;
}
subscription = createStream(accessToken, 'public', {
subscription = createStream(streamingAPIBaseURL, accessToken, 'public', {
connected () {
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 });
}
let reblogIcon = 'retweet';
if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
else if (status.get('visibility') === 'private') reblogIcon = 'lock';
return (
<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 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' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" /></div>
</div>

View file

@ -25,7 +25,7 @@ const ColumnHeader = React.createClass({
}
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}
{type}
</div>

View file

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

View file

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

View file

@ -66,7 +66,7 @@ const ModalRoot = React.createClass({
return (
<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})` }}>
<SpecificComponent {...props} onClose={onClose} />
</div>

View file

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

View file

@ -14,7 +14,7 @@ Link.parseAttrs = (link, parts) => {
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()
value = match[4] || match[3] || match[2]

View file

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

View file

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

View file

@ -1,22 +1,22 @@
const nl = {
"column_back_button.label": "terug",
"lightbox.close": "Sluiten",
"loading_indicator.label": "Laden...",
"status.mention": "Vermeld @{name}",
"status.delete": "Verwijder",
"status.reply": "Reageer",
"loading_indicator.label": "Laden",
"status.mention": "@{name} vermelden",
"status.delete": "Verwijderen",
"status.reply": "Reageren",
"status.reblog": "Boost",
"status.favourite": "Favoriet",
"status.reblogged_by": "{name} boostte",
"status.sensitive_warning": "Gevoelige inhoud",
"status.sensitive_toggle": "Klik om te zien",
"video_player.toggle_sound": "Geluid omschakelen",
"account.mention": "Vermeld @{name}",
"account.edit_profile": "Bewerk profiel",
"account.unblock": "Deblokkeer @{name}",
"account.unfollow": "Ontvolg",
"account.block": "Blokkeer @{name}",
"account.follow": "Volg",
"video_player.toggle_sound": "Geluid in-/uitschakelen",
"account.mention": "@{name} vermelden",
"account.edit_profile": "Profiel bewerken",
"account.unblock": "@{name} deblokkeren",
"account.unfollow": "Ontvolgen",
"account.block": "@{name} blokkeren",
"account.follow": "Volgen",
"account.posts": "Berichten",
"account.follows": "Volgt",
"account.followers": "Volgers",
@ -25,7 +25,7 @@ const nl = {
"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_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.community": "Lokale tijdlijn",
"column.public": "Federatietijdlijn",
@ -37,30 +37,30 @@ const nl = {
"tabs_bar.notifications": "Meldingen",
"compose_form.placeholder": "Waar ben je mee bezig?",
"compose_form.publish": "Toot",
"compose_form.sensitive": "Markeer media als gevoelig",
"compose_form.spoiler": "Verberg tekst achter waarschuwing",
"compose_form.private": "Mark als privé",
"compose_form.sensitive": "Media als gevoelig markeren",
"compose_form.spoiler": "Tekst achter waarschuwing verbergen",
"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.unlisted": "Niet tonen op openbare tijdlijnen",
"navigation_bar.edit_profile": "Bewerk profiel",
"compose_form.unlisted": "Niet op openbare tijdlijnen tonen",
"navigation_bar.edit_profile": "Profiel bewerken",
"navigation_bar.preferences": "Voorkeuren",
"navigation_bar.community_timeline": "Lokale tijdlijn",
"navigation_bar.public_timeline": "Federatietijdlijn",
"navigation_bar.logout": "Uitloggen",
"navigation_bar.logout": "Afmelden",
"reply_indicator.cancel": "Annuleren",
"search.placeholder": "Zoeken",
"search.account": "Account",
"search.hashtag": "Hashtag",
"upload_button.label": "Toevoegen media",
"upload_button.label": "Media toevoegen",
"upload_form.undo": "Ongedaan maken",
"notification.follow": "{name} volgde jou",
"notification.favourite": "{name} markeerde je status als favoriet",
"notification.reblog": "{name} boostte je status",
"notification.mention": "{name} vermeldde jou",
"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.favourite": "Favoriten:",
"notifications.column_settings.favourite": "Favorieten:",
"notifications.column_settings.mention": "Vermeldingen:",
"notifications.column_settings.reblog": "Boosts:",
};

View file

@ -1,77 +1,130 @@
const no = {
"column_back_button.label": "Tilbake",
"lightbox.close": "Lukk",
"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.block": "Blokkér @{name}",
"account.disclaimer": "Denne brukeren er fra en annen instans. Dette tallet kan være høyere.",
"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.unfollow": "Avfølg",
"account.block": "Blokker @{name}",
"account.follow": "Følg",
"account.posts": "Poster",
"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",
"account.unmute": "Avdemp @{name}",
"boost_modal.combo": "You kan trykke {combo} for å hoppe over dette neste gang",
"column_back_button.label": "Tilbake",
"column.blocks": "Blokkerte brukere",
"column.community": "Lokal tidslinje",
"column.favourites": "Likt",
"tabs_bar.compose": "Komponer",
"tabs_bar.home": "Hjem",
"tabs_bar.mentions": "Nevninger",
"tabs_bar.public": "Forent tidslinje",
"tabs_bar.notifications": "Varslinger",
"column.follow_requests": "Følgeforespørsler",
"column.home": "Hjem",
"column.notifications": "Varslinger",
"column.public": "Felles tidslinje",
"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.sensitive": "Merk media som følsomt",
"compose_form.spoiler_placeholder": "Innholdsadvarsel",
"compose_form.spoiler": "Skjul tekst bak advarsel",
"compose_form.private": "Merk som privat",
"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.",
"compose_form.unlisted": "Ikke vis på offentlige tidslinjer",
"navigation_bar.edit_profile": "Rediger profil",
"navigation_bar.preferences": "Preferanser",
"navigation_bar.community_timeline": "Lokal tidslinje",
"navigation_bar.public_timeline": "Forent tidslinje",
"navigation_bar.logout": "Logg ut",
"emoji_button.label": "Sett inn emoji",
"empty_column.community": "Den lokale tidslinjen er tom. Skriv noe offentlig for å få snøballen til å rulle!",
"empty_column.hashtag": "Det er ingenting i denne hashtagen ennå.",
"empty_column.home.public_timeline": "en offentlig tidslinje",
"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.",
"empty_column.notifications": "Du har ingen varsler ennå. Kommuniser med andre for å begynne samtalen.",
"empty_column.public": "Det er ingenting her! Skriv noe offentlig, eller følg brukere manuelt fra andre instanser for å fylle den opp",
"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.info": "Utvidet informasjon",
"navigation_bar.community_timeline": "Lokal tidslinje",
"navigation_bar.edit_profile": "Rediger profil",
"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",
"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.account": "Konto",
"search.hashtag": "Hashtag",
"search.status_by": "Status fra {name}",
"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_form.undo": "Angre",
"notification.follow": "{name} fulgte deg",
"notification.favourite": "{name} likte din status",
"notification.reblog": "{name} reblogget din status",
"notification.mention": "{name} nevnte deg",
"notifications.column_settings.alert": "Skrivebordsvarslinger",
"notifications.column_settings.show": "Vis i kolonne",
"notifications.column_settings.follow": "Nye følgere:",
"notifications.column_settings.favourite": "Likt:",
"notifications.column_settings.mention": "Nevninger:",
"notifications.column_settings.reblog": "Reblogginger:",
"upload_progress.label": "Laster opp...",
"video_player.toggle_sound": "Veksle lyd",
"video_player.toggle_visible": "Veksle synlighet",
"video_player.expand": "Utvid video",
"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.",
"tabs_bar.mentions": "Nevninger",
"tabs_bar.public": "Felles tidslinje",
"compose_form.private": "Merk som privat",
"compose_form.unlisted": "Ikke vis på offentlige tidslinjer",
"search.account": "Konto",
"search.hashtag": "Hashtag",
"notification.mention": "{name} nevnte deg"
};
export default no;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,6 +16,10 @@ import {
BLOCKS_FETCH_SUCCESS,
BLOCKS_EXPAND_SUCCESS
} from '../actions/blocks';
import {
MUTES_FETCH_SUCCESS,
MUTES_EXPAND_SUCCESS
} from '../actions/mutes';
import Immutable from 'immutable';
const initialState = Immutable.Map({
@ -24,7 +28,8 @@ const initialState = Immutable.Map({
reblogged_by: Immutable.Map(),
favourited_by: Immutable.Map(),
follow_requests: Immutable.Map(),
blocks: Immutable.Map()
blocks: Immutable.Map(),
mutes: Immutable.Map()
});
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);
case BLOCKS_EXPAND_SUCCESS:
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:
return state;
}

View file

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

View file

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

View file

@ -2056,3 +2056,11 @@ button.icon-button.active i.fa-retweet {
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;
}
}
.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)
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])
end

View file

@ -30,7 +30,7 @@ class Api::PushController < ApiController
params = Rails.application.routes.recognize_path(uri.path)
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])
end

View file

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

View file

@ -28,7 +28,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
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
private

View file

@ -49,7 +49,8 @@ class Auth::SessionsController < Devise::SessionsController
end
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
def authenticate_with_two_factor

View file

@ -27,7 +27,11 @@ module Localized
def 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
def user_supplied_locale
http_accept_language.language_region_compatible_from(I18n.available_locales)
end
end

View file

@ -7,12 +7,13 @@ class HomeController < ApplicationController
@body_classes = 'app-body'
@token = find_or_create_access_token.token
@web_settings = Web::Setting.find_by(user: current_user)&.data || {}
@streaming_api_base_url = Rails.configuration.x.streaming_api_base_url
end
private
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
def find_or_create_access_token

View file

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

View file

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

View file

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

View file

@ -5,7 +5,9 @@ class User < ApplicationRecord
devise :registerable, :recoverable,
: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
accepts_nested_attributes_for :account

View file

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

View file

@ -1,26 +1,86 @@
# frozen_string_literal: true
class AccountSearchService < BaseService
attr_reader :query, :limit, :resolve, :account
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('@')
domain = nil if TagManager.instance.local_domain?(domain)
search_service_results
end
if domain.nil?
exact_match = Account.find_local(username)
results = account.nil? ? Account.search_for(username, limit) : Account.advanced_search_for(username, account, limit)
private
def search_service_results
return [] if query_blank_or_hashtag?
if resolving_non_matching_remote_account?
[FollowRemoteAccountService.new.call("#{query_username}@#{query_domain}")]
else
exact_match = Account.find_remote(username, domain)
results = account.nil? ? Account.search_for("#{username} #{domain}", limit) : Account.advanced_search_for("#{username} #{domain}", account, limit)
search_results_and_exact_match.compact.uniq
end
end
results = [exact_match] + results.reject { |a| a.id == exact_match.id } if exact_match
if resolve && !exact_match && !domain.nil?
results = [FollowRemoteAccountService.new.call("#{username}@#{domain}")]
def resolving_non_matching_remote_account?
resolve && !exact_match && !domain_is_local?
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

View file

@ -16,7 +16,7 @@ class FollowRemoteAccountService < BaseService
return Account.find_local(username) if TagManager.instance.local_domain?(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}"
@ -29,20 +29,24 @@ class FollowRemoteAccountService < BaseService
return Account.find_local(confirmed_username) if TagManager.instance.local_domain?(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}"
domain_block = DomainBlock.find_by(domain: 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.salmon_url = data.link('salmon').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.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)
hubs = get_hubs(xml)

View file

@ -164,7 +164,7 @@ class ProcessFeedService < BaseService
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/', ''))
else
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)
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?
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
.sidebar
.panel
.panel-header= t 'about.contact'
.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'
= render partial: 'contact', object: @instance_presenter
= render 'links'

View file

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

View file

@ -14,7 +14,7 @@
%meta{ property: 'og:image:height', content: '120' }/
%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 }
.h-feed

View file

@ -61,12 +61,16 @@
= surround '(', ')' do
= 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'
- else
- else
= 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'
- 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'

View file

@ -2,7 +2,9 @@
= t('auth.login')
= 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
= f.button :button, t('auth.login'), type: :submit

View file

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

View file

@ -2,6 +2,7 @@ object false
node(:meta) do
{
streaming_api_base_url: @streaming_api_base_url,
access_token: @token,
locale: I18n.locale,
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'
- else
= 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' }/
- 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 }
.activity-stream.activity-stream-headless.h-entry

View file

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

View file

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

View file

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

View file

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

View file

@ -2,18 +2,20 @@
port = ENV.fetch('PORT') { 3000 }
host = ENV.fetch('LOCAL_DOMAIN') { "localhost:#{port}" }
web_host = ENV.fetch('WEB_DOMAIN') { host }
https = ENV['LOCAL_HTTPS'] == 'true'
Rails.application.configure do
config.x.local_domain = host
config.x.web_domain = web_host
config.x.use_https = https
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'
if Rails.env.production?
config.action_cable.allowed_request_origins = ["http#{https ? 's' : ''}://#{host}"]
config.x.streaming_api_base_url = ENV.fetch('STREAMING_API_BASE_URL') { "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' : ''}://#{web_host}" }
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[:s3_host_alias] = ENV['S3_CLOUDFRONT_HOST']
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

View file

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

View file

@ -165,5 +165,3 @@ bg:
users:
invalid_email: E-mail адресът е невалиден
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_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:
already_authenticated: Vous êtes déjà connecté
inactive: Votre compte n'est pas encore activé.
invalid: Email ou mot de passe incorrect.
already_authenticated: Vous êtes déjà connecté⋅e
inactive: Votre compte nest pas encore activé.
invalid: Courriel ou mot de passe incorrect.
last_attempt: Vous avez droit à une tentative avant que votre compte ne soit 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.
unauthenticated: Vous devez vous connecter ou vous inscrire pour continuer.
unconfirmed: Vous devez valider votre compte pour continuer.
@ -25,7 +25,7 @@ fr:
unlock_instructions:
subject: Instructions pour déverrouiller votre compte
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}.
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.
@ -36,10 +36,10 @@ fr:
registrations:
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_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_unconfirmed: Un message contenant un lien de confirmation a été envoyé à votre adresse email. 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.
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 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.
sessions:
already_signed_out: Déconnecté.
@ -47,15 +47,15 @@ fr:
signed_out: Déconnecté.
unlocks:
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.
errors:
messages:
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
expired: a expiré, merci d'en faire une nouvelle demande
not_found: n'a pas été trouvé⋅e
not_locked: n'était pas verrouillé⋅e
expired: a expiré, merci den faire une nouvelle demande
not_found: na pas été trouvé⋅e
not_locked: nétait pas verrouillé⋅e
not_saved:
one: '1 erreur a empêché ce(tte) %{resource} dêtre sauvegardé⋅e :'
other: '%{count} erreurs ont empêché %{resource} dêtre sauvegardé⋅e :'

View file

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