Merge branch 'master' into skylight

skylight
Eugen Rochko 2017-04-16 23:33:35 +02:00
commit 9a97beb3a5
221 changed files with 4052 additions and 871 deletions

View File

@ -1,12 +1,6 @@
engines: engines:
duplication: duplication:
enabled: true enabled: false
exclude_paths:
- app/assets/javascripts/components/locales/
config:
languages:
- ruby
- javascript
rubocop: rubocop:
enabled: true enabled: true
eslint: eslint:

View File

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

View File

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

View File

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

View File

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

8
.gitignore vendored
View File

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

2
.nvmrc
View File

@ -1 +1 @@
6.7.0 6

View File

@ -5,8 +5,6 @@ notifications:
email: false email: false
env: env:
matrix:
- TRAVIS_NODE_VERSION="4"
global: global:
- LOCAL_DOMAIN=cb6e6126.ngrok.io - LOCAL_DOMAIN=cb6e6126.ngrok.io
- LOCAL_HTTPS=true - LOCAL_HTTPS=true
@ -16,6 +14,7 @@ addons:
postgresql: 9.4 postgresql: 9.4
rvm: rvm:
- 2.3.4
- 2.4.1 - 2.4.1
services: services:
@ -28,8 +27,7 @@ before_install:
- sudo apt-get -qq update - sudo apt-get -qq update
- sudo apt-get -qq install g++-4.8 - sudo apt-get -qq install g++-4.8
install: install:
- nvm install $TRAVIS_NODE_VERSION - nvm install
- npm install -g npm@3
- npm install -g yarn - npm install -g yarn
- bundle install - bundle install
- yarn install - yarn install
@ -40,3 +38,4 @@ before_script:
script: script:
- bundle exec rspec - bundle exec rspec
- npm test - npm test
- i18n-tasks unused

View File

@ -12,23 +12,25 @@ WORKDIR /mastodon
COPY Gemfile Gemfile.lock package.json yarn.lock /mastodon/ COPY Gemfile Gemfile.lock package.json yarn.lock /mastodon/
RUN BUILD_DEPS=" \ RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/repositories \
&& BUILD_DEPS=" \
postgresql-dev \ postgresql-dev \
libxml2-dev \ libxml2-dev \
libxslt-dev \ libxslt-dev \
build-base" \ build-base" \
&& apk -U upgrade && apk add \ && apk -U upgrade && apk add \
$BUILD_DEPS \ $BUILD_DEPS \
nodejs \ nodejs@edge \
nodejs-npm@edge \
libpq \ libpq \
libxml2 \ libxml2 \
libxslt \ libxslt \
ffmpeg \ ffmpeg \
file \ file \
imagemagick \ imagemagick@edge \
&& npm install -g npm@3 && npm install -g yarn \ && npm install -g npm@3 && npm install -g yarn \
&& bundle install --deployment --without test development \ && bundle install --deployment --without test development \
&& yarn \ && yarn --ignore-optional \
&& yarn cache clean \ && yarn cache clean \
&& npm -g cache clean \ && npm -g cache clean \
&& apk del $BUILD_DEPS \ && apk del $BUILD_DEPS \

14
Gemfile
View File

@ -1,12 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '2.4.1' ruby '>= 2.3.0', '< 2.5.0'
gem 'pkg-config'
gem 'rails', '~> 5.0.2' gem 'rails', '~> 5.0.2'
gem 'sass-rails', '~> 5.0' gem 'sass-rails', '~> 5.0'
gem 'uglifier', '>= 1.3.0' gem 'uglifier', '>= 1.3.0'
gem 'coffee-rails', '~> 4.1.0'
gem 'jquery-rails' gem 'jquery-rails'
gem 'puma' gem 'puma'
@ -36,12 +37,13 @@ gem 'kaminari'
gem 'link_header' gem 'link_header'
gem 'nokogiri' gem 'nokogiri'
gem 'oj' gem 'oj'
gem 'ostatus2' gem 'ostatus2', '~> 1.1'
gem 'ox' gem 'ox'
gem 'rabl' gem 'rabl'
gem 'rack-attack' gem 'rack-attack'
gem 'rack-cors', require: 'rack/cors' gem 'rack-cors', require: 'rack/cors'
gem 'rack-timeout' gem 'rack-timeout'
gem 'rails-i18n'
gem 'rails-settings-cached' gem 'rails-settings-cached'
gem 'redis', '~>3.2', require: ['redis', 'redis/connection/hiredis'] gem 'redis', '~>3.2', require: ['redis', 'redis/connection/hiredis']
gem 'rqrcode' gem 'rqrcode'
@ -50,9 +52,11 @@ gem 'sidekiq'
gem 'sidekiq-unique-jobs' gem 'sidekiq-unique-jobs'
gem 'simple-navigation' gem 'simple-navigation'
gem 'simple_form' gem 'simple_form'
gem 'sprockets-rails', :require => 'sprockets/railtie'
gem 'statsd-instrument' gem 'statsd-instrument'
gem 'twitter-text' gem 'twitter-text'
gem 'tzinfo-data' gem 'tzinfo-data'
gem 'whatlanguage'
gem 'react-rails' gem 'react-rails'
gem 'browserify-rails' gem 'browserify-rails'
@ -70,7 +74,9 @@ group :development, :test do
end end
group :test do group :test do
gem 'capybara'
gem 'faker' gem 'faker'
gem 'microformats2'
gem 'rails-controller-testing' gem 'rails-controller-testing'
gem 'rspec-sidekiq' gem 'rspec-sidekiq'
gem 'simplecov', require: false gem 'simplecov', require: false
@ -86,7 +92,7 @@ group :development do
gem 'bullet' gem 'bullet'
gem 'active_record_query_trace' gem 'active_record_query_trace'
gem 'capistrano' gem 'capistrano', '3.8.0'
gem 'capistrano-rails' gem 'capistrano-rails'
gem 'capistrano-rbenv' gem 'capistrano-rbenv'
gem 'capistrano-yarn' gem 'capistrano-yarn'

View File

@ -41,7 +41,7 @@ GEM
tzinfo (~> 1.1) tzinfo (~> 1.1)
addressable (2.5.1) addressable (2.5.1)
public_suffix (~> 2.0, >= 2.0.2) public_suffix (~> 2.0, >= 2.0.2)
airbrussh (1.1.2) airbrussh (1.2.0)
sshkit (>= 1.6.1, != 1.7.0) sshkit (>= 1.6.1, != 1.7.0)
arel (7.1.4) arel (7.1.4)
ast (2.3.0) ast (2.3.0)
@ -99,18 +99,18 @@ GEM
sshkit (~> 1.3) sshkit (~> 1.3)
capistrano-yarn (2.0.2) capistrano-yarn (2.0.2)
capistrano (~> 3.0) capistrano (~> 3.0)
capybara (2.13.0)
addressable
mime-types (>= 1.16)
nokogiri (>= 1.3.3)
rack (>= 1.0.0)
rack-test (>= 0.5.4)
xpath (~> 2.0)
chunky_png (1.3.8) chunky_png (1.3.8)
climate_control (0.1.0) climate_control (0.1.0)
cocaine (0.5.8) cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0) climate_control (>= 0.0.3, < 1.0)
coderay (1.1.1) coderay (1.1.1)
coffee-rails (4.1.1)
coffee-script (>= 2.2.0)
railties (>= 4.0.0, < 5.1.x)
coffee-script (2.4.1)
coffee-script-source
execjs
coffee-script-source (1.12.2)
colorize (0.8.1) colorize (0.8.1)
concurrent-ruby (1.0.5) concurrent-ruby (1.0.5)
connection_pool (2.2.1) connection_pool (2.2.1)
@ -233,6 +233,10 @@ GEM
mail (2.6.4) mail (2.6.4)
mime-types (>= 1.16, < 4) mime-types (>= 1.16, < 4)
method_source (0.8.2) method_source (0.8.2)
microformats2 (2.1.0)
activesupport
json
nokogiri
mime-types (3.1) mime-types (3.1)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2016.0521) mime-types-data (3.2016.0521)
@ -246,11 +250,13 @@ GEM
nokogiri (1.7.1) nokogiri (1.7.1)
mini_portile2 (~> 2.1.0) mini_portile2 (~> 2.1.0)
oj (2.18.5) oj (2.18.5)
openssl (2.0.3)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ostatus2 (1.0.2) ostatus2 (1.1.0)
addressable (~> 2.4) addressable (~> 2.4)
http (~> 2.0) http (~> 2.0)
nokogiri (~> 1.6) nokogiri (~> 1.6)
openssl (~> 2.0)
ox (2.4.11) ox (2.4.11)
paperclip (5.1.0) paperclip (5.1.0)
activemodel (>= 4.2.0) activemodel (>= 4.2.0)
@ -266,6 +272,7 @@ GEM
pg (0.20.0) pg (0.20.0)
pghero (1.6.4) pghero (1.6.4)
activerecord activerecord
pkg-config (1.1.7)
powerpack (0.1.1) powerpack (0.1.1)
pry (0.10.4) pry (0.10.4)
coderay (~> 1.1.0) coderay (~> 1.1.0)
@ -307,6 +314,9 @@ GEM
nokogiri (~> 1.6) nokogiri (~> 1.6)
rails-html-sanitizer (1.0.3) rails-html-sanitizer (1.0.3)
loofah (~> 2.0) loofah (~> 2.0)
rails-i18n (5.0.3)
i18n (~> 0.7)
railties (~> 5.0)
rails-settings-cached (0.6.5) rails-settings-cached (0.6.5)
rails (>= 4.2.0) rails (>= 4.2.0)
rails_12factor (0.0.3) rails_12factor (0.0.3)
@ -438,7 +448,7 @@ GEM
execjs (>= 0.3.0, < 3) execjs (>= 0.3.0, < 3)
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.7.2) unf_ext (0.0.7.3)
unicode-display_width (1.1.3) unicode-display_width (1.1.3)
uniform_notifier (1.10.0) uniform_notifier (1.10.0)
warden (1.2.7) warden (1.2.7)
@ -450,6 +460,9 @@ GEM
websocket-driver (0.6.5) websocket-driver (0.6.5)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.2) websocket-extensions (0.1.2)
whatlanguage (1.0.6)
xpath (2.0.0)
nokogiri (~> 1.3)
PLATFORMS PLATFORMS
ruby ruby
@ -464,12 +477,12 @@ DEPENDENCIES
binding_of_caller binding_of_caller
browserify-rails browserify-rails
bullet bullet
capistrano capistrano (= 3.8.0)
capistrano-faster-assets (~> 1.0) capistrano-faster-assets (~> 1.0)
capistrano-rails capistrano-rails
capistrano-rbenv capistrano-rbenv
capistrano-yarn capistrano-yarn
coffee-rails (~> 4.1.0) capybara
devise devise
devise-two-factor devise-two-factor
doorkeeper doorkeeper
@ -493,14 +506,16 @@ DEPENDENCIES
letter_opener_web letter_opener_web
link_header link_header
lograge lograge
microformats2
nokogiri nokogiri
oj oj
ostatus2 ostatus2 (~> 1.1)
ox ox
paperclip (~> 5.1) paperclip (~> 5.1)
paperclip-av-transcoder paperclip-av-transcoder
pg pg
pghero pghero
pkg-config
pry-rails pry-rails
puma puma
rabl rabl
@ -509,6 +524,7 @@ DEPENDENCIES
rack-timeout rack-timeout
rails (~> 5.0.2) rails (~> 5.0.2)
rails-controller-testing rails-controller-testing
rails-i18n
rails-settings-cached rails-settings-cached
rails_12factor rails_12factor
react-rails react-rails
@ -527,11 +543,13 @@ DEPENDENCIES
simple_form simple_form
simplecov simplecov
skylight skylight
sprockets-rails
statsd-instrument statsd-instrument
twitter-text twitter-text
tzinfo-data tzinfo-data
uglifier (>= 1.3.0) uglifier (>= 1.3.0)
webmock webmock
whatlanguage
RUBY VERSION RUBY VERSION
ruby 2.4.1p111 ruby 2.4.1p111

View File

@ -3,3 +3,4 @@
* * * * * * * *
- [ ] I searched or browsed the repos other issues to ensure this is not a duplicate. - [ ] I searched or browsed the repos other issues to ensure this is not a duplicate.
- [ ] This bug happens on a [tagged release](https://github.com/tootsuite/mastodon/releases) and not on `master` (If you're a user, don't worry about this).

View File

@ -48,6 +48,14 @@ If you would like, you can [support the development of this project on Patreon][
- **Deployable via Docker** - **Deployable via Docker**
You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy
## Checking out
If you want a stable release for production use, you should use tagged releases. To checkout the latest available tagged version:
git clone https://github.com/tootsuite/mastodon.git
cd mastodon
git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
## Configuration ## Configuration
- `LOCAL_DOMAIN` should be the domain/hostname of your instance. This is **absolutely required** as it is used for generating unique IDs for everything federation-related - `LOCAL_DOMAIN` should be the domain/hostname of your instance. This is **absolutely required** as it is used for generating unique IDs for everything federation-related
@ -72,10 +80,6 @@ The project now includes a `Dockerfile` and a `docker-compose.yml` file (which r
Review the settings in `docker-compose.yml`. Note that it is not default to store the postgresql database and redis databases in a persistent storage location, Review the settings in `docker-compose.yml`. Note that it is not default to store the postgresql database and redis databases in a persistent storage location,
so you may need or want to adjust the settings there. so you may need or want to adjust the settings there.
Before running the first time, you need to build the images:
docker-compose build
Then, you need to fill in the `.env.production` file: Then, you need to fill in the `.env.production` file:
cp .env.production.sample .env.production cp .env.production.sample .env.production
@ -85,6 +89,11 @@ Do NOT change the `REDIS_*` or `DB_*` settings when running with the default doc
You will need to fill in, at least: `LOCAL_DOMAIN`, `LOCAL_HTTPS`, `PAPERCLIP_SECRET`, `SECRET_KEY_BASE`, `OTP_SECRET`, and the `SMTP_*` settings. To generate the `PAPERCLIP_SECRET`, `SECRET_KEY_BASE`, and `OTP_SECRET`, you may use: You will need to fill in, at least: `LOCAL_DOMAIN`, `LOCAL_HTTPS`, `PAPERCLIP_SECRET`, `SECRET_KEY_BASE`, `OTP_SECRET`, and the `SMTP_*` settings. To generate the `PAPERCLIP_SECRET`, `SECRET_KEY_BASE`, and `OTP_SECRET`, you may use:
Before running the first time, you need to build the images:
docker-compose build
docker-compose run --rm web rake secret docker-compose run --rm web rake secret
Do this once for each of those keys, and copy the result into the `.env.production` file in the appropriate field. Do this once for each of those keys, and copy the result into the `.env.production` file in the appropriate field.

2
Vagrantfile vendored
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

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

@ -0,0 +1,14 @@
import { openModal } from './modal';
import { changeSetting, saveSettings } from './settings';
export function showOnboardingOnce() {
return (dispatch, getState) => {
const alreadySeen = getState().getIn(['settings', 'onboarded']);
if (!alreadySeen) {
dispatch(openModal('ONBOARDING'));
dispatch(changeSetting(['onboarded'], true));
dispatch(saveSettings());
}
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,8 @@ import { isIOS } from '../is_mobile';
const messages = defineMessages({ const messages = defineMessages({
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }, toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' }, toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' },
expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' } expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' },
expand_video: { id: 'video_player.video_error', defaultMessage: 'Video could not be played' }
}); });
const videoStyle = { const videoStyle = {
@ -30,7 +31,7 @@ const muteStyle = {
zIndex: '5' zIndex: '5'
}; };
const spoilerStyle = { const coverStyle = {
marginTop: '8px', marginTop: '8px',
textAlign: 'center', textAlign: 'center',
height: '100%', height: '100%',
@ -94,7 +95,8 @@ const VideoPlayer = React.createClass({
visible: !this.props.sensitive, visible: !this.props.sensitive,
preview: true, preview: true,
muted: true, muted: true,
hasAudio: true hasAudio: true,
videoError: false
}; };
}, },
@ -142,12 +144,17 @@ const VideoPlayer = React.createClass({
} }
}, },
handleVideoError () {
this.setState({ videoError: true });
},
componentDidMount () { componentDidMount () {
if (!this.video) { if (!this.video) {
return; return;
} }
this.video.addEventListener('loadeddata', this.handleLoadedData); this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}, },
componentDidUpdate () { componentDidUpdate () {
@ -156,6 +163,7 @@ const VideoPlayer = React.createClass({
} }
this.video.addEventListener('loadeddata', this.handleLoadedData); this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}, },
componentWillUnmount () { componentWillUnmount () {
@ -164,6 +172,7 @@ const VideoPlayer = React.createClass({
} }
this.video.removeEventListener('loadeddata', this.handleLoadedData); this.video.removeEventListener('loadeddata', this.handleLoadedData);
this.video.removeEventListener('error', this.handleVideoError);
}, },
render () { render () {
@ -194,7 +203,7 @@ const VideoPlayer = React.createClass({
if (!this.state.visible) { if (!this.state.visible) {
if (sensitive) { if (sensitive) {
return ( return (
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}> <div role='button' tabIndex='0' style={{...coverStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
{spoilerButton} {spoilerButton}
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
@ -202,7 +211,7 @@ const VideoPlayer = React.createClass({
); );
} else { } else {
return ( return (
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}> <div role='button' tabIndex='0' style={{...coverStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
{spoilerButton} {spoilerButton}
<span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
@ -213,19 +222,27 @@ const VideoPlayer = React.createClass({
if (this.state.preview && !autoplay) { if (this.state.preview && !autoplay) {
return ( return (
<div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}> <div role='button' tabIndex='0' style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}>
{spoilerButton} {spoilerButton}
<div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div> <div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div>
</div> </div>
); );
} }
if (this.state.videoError) {
return (
<div style={{...coverStyle, width: `${width}px`, height: `${height}px` }} className='video-error-cover' >
<span style={spoilerSpanStyle}><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span>
</div>
);
}
return ( return (
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}> <div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
{spoilerButton} {spoilerButton}
{muteButton} {muteButton}
{expandButton} {expandButton}
<video ref={this.setRef} src={media.get('url')} autoPlay={!isIOS()} loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} /> <video role='button' tabIndex='0' ref={this.setRef} src={media.get('url')} autoPlay={!isIOS()} loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
</div> </div>
); );
} }

View File

@ -8,6 +8,7 @@ import {
connectTimeline, connectTimeline,
disconnectTimeline disconnectTimeline
} from '../actions/timelines'; } from '../actions/timelines';
import { showOnboardingOnce } from '../actions/onboarding';
import { updateNotifications, refreshNotifications } from '../actions/notifications'; import { updateNotifications, refreshNotifications } from '../actions/notifications';
import createBrowserHistory from 'history/lib/createBrowserHistory'; import createBrowserHistory from 'history/lib/createBrowserHistory';
import { import {
@ -37,6 +38,7 @@ import FollowRequests from '../features/follow_requests';
import GenericNotFound from '../features/generic_not_found'; import GenericNotFound from '../features/generic_not_found';
import FavouritedStatuses from '../features/favourited_statuses'; import FavouritedStatuses from '../features/favourited_statuses';
import Blocks from '../features/blocks'; import Blocks from '../features/blocks';
import Mutes from '../features/mutes';
import Report from '../features/report'; import Report from '../features/report';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider, addLocaleData } from 'react-intl';
import en from 'react-intl/locale-data/en'; import en from 'react-intl/locale-data/en';
@ -60,8 +62,8 @@ import { hydrateStore } from '../actions/store';
import createStream from '../stream'; import createStream from '../stream';
const store = configureStore(); const store = configureStore();
const initialState = JSON.parse(document.getElementById("initial-state").textContent);
store.dispatch(hydrateStore(window.INITIAL_STATE)); store.dispatch(hydrateStore(initialState));
const browserHistory = useRouterHistory(createBrowserHistory)({ const browserHistory = useRouterHistory(createBrowserHistory)({
basename: '/web' basename: '/web'
@ -94,9 +96,10 @@ const Mastodon = React.createClass({
componentDidMount() { componentDidMount() {
const { locale } = this.props; const { locale } = this.props;
const streamingAPIBaseURL = store.getState().getIn(['meta', 'streaming_api_base_url']);
const accessToken = store.getState().getIn(['meta', 'access_token']); const accessToken = store.getState().getIn(['meta', 'access_token']);
this.subscription = createStream(accessToken, 'user', { this.subscription = createStream(streamingAPIBaseURL, accessToken, 'user', {
connected () { connected () {
store.dispatch(connectTimeline('home')); store.dispatch(connectTimeline('home'));
@ -132,6 +135,8 @@ const Mastodon = React.createClass({
if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
Notification.requestPermission(); Notification.requestPermission();
} }
store.dispatch(showOnboardingOnce());
}, },
componentWillUnmount () { componentWillUnmount () {
@ -171,6 +176,7 @@ const Mastodon = React.createClass({
<Route path='follow_requests' component={FollowRequests} /> <Route path='follow_requests' component={FollowRequests} />
<Route path='blocks' component={Blocks} /> <Route path='blocks' component={Blocks} />
<Route path='mutes' component={Mutes} />
<Route path='report' component={Report} /> <Route path='report' component={Report} />
<Route path='*' component={GenericNotFound} /> <Route path='*' component={GenericNotFound} />

View File

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

View File

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

View File

@ -92,7 +92,7 @@ const ComposeForm = React.createClass({
}, },
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
// This statement does several things: // This statement does several things:
// - If we're beginning a reply, and, // - If we're beginning a reply, and,
// - Replying to zero or one users, places the cursor at the end of the textbox. // - Replying to zero or one users, places the cursor at the end of the textbox.
// - Replying to more than one user, selects any usernames past the first; // - Replying to more than one user, selects any usernames past the first;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -64,7 +64,7 @@ const Column = React.createClass({
} }
return ( return (
<div className='column' onWheel={this.handleWheel}> <div role='section' className='column' onWheel={this.handleWheel}>
{header} {header}
{children} {children}
</div> </div>

View File

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

View File

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

View File

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

View File

@ -1,11 +1,13 @@
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import MediaModal from './media_modal'; import MediaModal from './media_modal';
import OnboardingModal from './onboarding_modal';
import VideoModal from './video_modal'; import VideoModal from './video_modal';
import BoostModal from './boost_modal'; import BoostModal from './boost_modal';
import { TransitionMotion, spring } from 'react-motion'; import { TransitionMotion, spring } from 'react-motion';
const MODAL_COMPONENTS = { const MODAL_COMPONENTS = {
'MEDIA': MediaModal, 'MEDIA': MediaModal,
'ONBOARDING': OnboardingModal,
'VIDEO': VideoModal, 'VIDEO': VideoModal,
'BOOST': BoostModal 'BOOST': BoostModal
}; };
@ -66,7 +68,7 @@ const ModalRoot = React.createClass({
return ( return (
<div key={key}> <div key={key}>
<div className='modal-root__overlay' style={{ opacity: style.opacity, transform: `translateZ(0px)` }} onClick={onClose} /> <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity, transform: `translateZ(0px)` }} onClick={onClose} />
<div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}> <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
<SpecificComponent {...props} onClose={onClose} /> <SpecificComponent {...props} onClose={onClose} />
</div> </div>

View File

@ -0,0 +1,251 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Permalink from '../../../components/permalink';
import { TransitionMotion, spring } from 'react-motion';
import ComposeForm from '../../compose/components/compose_form';
import Search from '../../compose/components/search';
import NavigationBar from '../../compose/components/navigation_bar';
import ColumnHeader from './column_header';
import Immutable from 'immutable';
const messages = defineMessages({
home_title: { id: 'column.home', defaultMessage: 'Home' },
notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' },
local_title: { id: 'column.community', defaultMessage: 'Local timeline' },
federated_title: { id: 'column.public', defaultMessage: 'Federated timeline' }
});
const PageOne = ({ acct, domain }) => (
<div className='onboarding-modal__page onboarding-modal__page-one'>
<div style={{ flex: '0 0 auto' }}>
<div className='onboarding-modal__page-one__elephant-friend' />
</div>
<div>
<h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to Mastodon!' /></h1>
<p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a social network that belongs to everyone.' /></p>
<p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, one of many independent Mastodon instances. Your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }}/></p>
</div>
</div>
);
PageOne.propTypes = {
acct: React.PropTypes.string.isRequired,
domain: React.PropTypes.string.isRequired
};
const PageTwo = () => (
<div className='onboarding-modal__page onboarding-modal__page-two'>
<div className='figure non-interactive'>
<ComposeForm
text='Awoo! #introductions'
suggestions={Immutable.List()}
mentionedDomains={[]}
onChange={() => {}}
onSubmit={() => {}}
onPaste={() => {}}
onPickEmoji={() => {}}
onChangeSpoilerText={() => {}}
onClearSuggestions={() => {}}
onFetchSuggestions={() => {}}
onSuggestionSelected={() => {}}
/>
</div>
<p><FormattedMessage id='onboarding.page_two.compose' defaultMessage='Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.' /></p>
</div>
);
const PageThree = ({ me, domain }) => (
<div className='onboarding-modal__page onboarding-modal__page-three'>
<div className='figure non-interactive'>
<Search
value=''
onChange={() => {}}
onSubmit={() => {}}
onClear={() => {}}
onShow={() => {}}
/>
<div className='pseudo-drawer'>
<NavigationBar account={me} />
</div>
</div>
<p><FormattedMessage id='onboarding.page_three.search' defaultMessage='Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.' values={{ illustration: <Permalink to='/timelines/tag/illustration' href='/tags/illustration'>#illustration</Permalink>, introductions: <Permalink to='/timelines/tag/introductions' href='/tags/introductions'>#introductions</Permalink> }}/></p>
<p><FormattedMessage id='onboarding.page_three.profile' defaultMessage='Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.' /></p>
</div>
);
PageThree.propTypes = {
me: ImmutablePropTypes.map.isRequired,
domain: React.PropTypes.string.isRequired
};
const PageFour = ({ domain, intl }) => (
<div className='onboarding-modal__page onboarding-modal__page-four'>
<div className='onboarding-modal__page-four__columns'>
<div className='row'>
<div>
<div className='figure non-interactive'><ColumnHeader icon='home' type={intl.formatMessage(messages.home_title)} /></div>
<p><FormattedMessage id='onboarding.page_four.home' defaultMessage='Home timeline shows posts from people you follow'/></p>
</div>
<div>
<div className='figure non-interactive'><ColumnHeader icon='bell' type={intl.formatMessage(messages.notifications_title)} /></div>
<p><FormattedMessage id='onboarding.page_four.notifications' defaultMessage='Notifications show when someone interacts with you' /></p>
</div>
</div>
<div className='row'>
<div>
<div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='globe' type={intl.formatMessage(messages.federated_title)} /></div>
</div>
<div>
<div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='users' type={intl.formatMessage(messages.local_title)} /></div>
</div>
</div>
<p><FormattedMessage id='onboarding.page_five.public_timelines' defaultMessage='Federated timeline lists public posts from everyone who people on {domain} follow. Local timeline is the same, but limited to people on {domain}.' values={{ domain }} /></p>
</div>
</div>
);
PageFour.propTypes = {
domain: React.PropTypes.string.isRequired,
intl: React.PropTypes.object.isRequired
};
const PageSix = ({ admin }) => {
let adminSection = '';
if (admin) {
adminSection = (
<p>
<FormattedMessage id='onboarding.page_six.admin' defaultMessage="Your instance's admin is {admin}." values={{ admin: <Permalink href={admin.get('url')} to={`/accounts/${admin.get('id')}`}>@{admin.get('acct')}</Permalink> }} />
<br />
<FormattedMessage id='onboarding.page_six.read_guidelines' defaultMessage='Please, do not forget to read the {guidelines}!' values={{ guidelines: <a href='/about/more' target='_blank'><FormattedMessage id='onboarding.page_six.guidelines' defaultMessage='community guidelines' /></a> }}/>
</p>
);
}
return (
<div className='onboarding-modal__page onboarding-modal__page-six'>
<h1><FormattedMessage id='onboarding.page_six.almost_done' defaultMessage='Almost done...' /></h1>
{adminSection}
<p><FormattedMessage id='onboarding.page_six.github' defaultMessage='Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ github: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p>
<p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms. And now... Bon Appetoot!' values={{ apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='various mobile apps' /></a> }} /></p>
</div>
);
};
PageSix.propTypes = {
admin: ImmutablePropTypes.map
};
const mapStateToProps = state => ({
me: state.getIn(['accounts', state.getIn(['meta', 'me'])]),
admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]),
domain: state.getIn(['meta', 'domain'])
});
const OnboardingModal = React.createClass({
propTypes: {
onClose: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired,
me: ImmutablePropTypes.map.isRequired,
domain: React.PropTypes.string.isRequired,
admin: ImmutablePropTypes.map
},
getInitialState () {
return {
currentIndex: 0
};
},
mixins: [PureRenderMixin],
handleSkip (e) {
e.preventDefault();
this.props.onClose();
},
handleDot (i, e) {
e.preventDefault();
this.setState({ currentIndex: i });
},
handleNext (maxNum, e) {
e.preventDefault();
if (this.state.currentIndex < maxNum - 1) {
this.setState({ currentIndex: this.state.currentIndex + 1 });
} else {
this.props.onClose();
}
},
render () {
const { me, admin, domain, intl } = this.props;
const pages = [
<PageOne acct={me.get('acct')} domain={domain} />,
<PageTwo />,
<PageThree me={me} domain={domain} />,
<PageFour domain={domain} intl={intl} />,
<PageSix admin={admin} />
];
const { currentIndex } = this.state;
const hasMore = currentIndex < pages.length - 1;
let nextOrDoneBtn;
if(hasMore) {
nextOrDoneBtn = <a href='#' onClick={this.handleNext.bind(null, pages.length)} className='onboarding-modal__nav onboarding-modal__next'><FormattedMessage id='onboarding.next' defaultMessage='Next' /></a>;
} else {
nextOrDoneBtn = <a href='#' onClick={this.handleNext.bind(null, pages.length)} className='onboarding-modal__nav onboarding-modal__done'><FormattedMessage id='onboarding.next' defaultMessage='Done' /></a>;
}
const styles = pages.map((page, i) => ({
key: i,
style: { opacity: spring(i === currentIndex ? 1 : 0) }
}));
return (
<div className='modal-root__modal onboarding-modal'>
<TransitionMotion styles={styles}>
{interpolatedStyles =>
<div className='onboarding-modal__pager'>
{pages.map((page, i) =>
<div key={i} style={{ opacity: interpolatedStyles[i].style.opacity, pointerEvents: i === currentIndex ? 'auto' : 'none' }}>{page}</div>
)}
</div>
}
</TransitionMotion>
<div className='onboarding-modal__paginator'>
<div>
<a href='#' className='onboarding-modal__skip' onClick={this.handleSkip}><FormattedMessage id='onboarding.skip' defaultMessage='Skip' /></a>
</div>
<div className='onboarding-modal__dots'>
{pages.map((_, i) => <div key={i} onClick={this.handleDot.bind(null, i)} className={`onboarding-modal__dot ${i === currentIndex ? 'active' : ''}`} />)}
</div>
<div>
{nextOrDoneBtn}
</div>
</div>
</div>
);
}
});
export default connect(mapStateToProps)(injectIntl(OnboardingModal));

View File

@ -24,8 +24,10 @@ const makeGetStatusIds = () => createSelector([
if (columnSettings.getIn(['regex', 'body'], '').trim().length > 0) { if (columnSettings.getIn(['regex', 'body'], '').trim().length > 0) {
try { try {
const regex = new RegExp(columnSettings.getIn(['regex', 'body']).trim(), 'i'); if (showStatus) {
showStatus = showStatus && !regex.test(statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'content']) : statusForId.get('content')); const regex = new RegExp(columnSettings.getIn(['regex', 'body']).trim(), 'i');
showStatus = !regex.test(statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'unescaped_content']) : statusForId.get('unescaped_content'));
}
} catch(e) { } catch(e) {
// Bad regex, don't affect filters // Bad regex, don't affect filters
} }

View File

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

View File

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

View File

@ -31,6 +31,7 @@ const en = {
"column.favourites": "Favourites", "column.favourites": "Favourites",
"column.follow_requests": "Follow requests", "column.follow_requests": "Follow requests",
"column.home": "Home", "column.home": "Home",
"column.mutes": "Muted users",
"column.notifications": "Notifications", "column.notifications": "Notifications",
"column.public": "Federated timeline", "column.public": "Federated timeline",
"compose_form.placeholder": "What is on your mind?", "compose_form.placeholder": "What is on your mind?",
@ -68,10 +69,12 @@ const en = {
"navigation_bar.follow_requests": "Follow requests", "navigation_bar.follow_requests": "Follow requests",
"navigation_bar.info": "Extended information", "navigation_bar.info": "Extended information",
"navigation_bar.logout": "Logout", "navigation_bar.logout": "Logout",
"navigation_bar.mutes": "Muted users",
"navigation_bar.preferences": "Preferences", "navigation_bar.preferences": "Preferences",
"navigation_bar.public_timeline": "Federated timeline", "navigation_bar.public_timeline": "Federated timeline",
"notification.favourite": "{name} favourited your status", "notification.favourite": "{name} favourited your status",
"notification.follow": "{name} followed you", "notification.follow": "{name} followed you",
"notification.mention": "{name} mentioned you",
"notification.reblog": "{name} boosted your status", "notification.reblog": "{name} boosted your status",
"notifications.clear_confirmation": "Are you sure you want to clear all your notifications?", "notifications.clear_confirmation": "Are you sure you want to clear all your notifications?",
"notifications.clear": "Clear notifications", "notifications.clear": "Clear notifications",
@ -126,6 +129,7 @@ const en = {
"video_player.toggle_sound": "Toggle sound", "video_player.toggle_sound": "Toggle sound",
"video_player.toggle_visible": "Toggle visibility", "video_player.toggle_visible": "Toggle visibility",
"video_player.expand": "Expand video", "video_player.expand": "Expand video",
"video_player.video_error": "Video could not be played",
}; };
export default en; export default en;

View File

@ -73,7 +73,7 @@ const es = {
"notifications.column_settings.mention": "Menciones:", "notifications.column_settings.mention": "Menciones:",
"notifications.column_settings.reblog": "Retoots:", "notifications.column_settings.reblog": "Retoots:",
"emoji_button.label": "Insertar emoji", "emoji_button.label": "Insertar emoji",
"privacy.public.short": "Público", "privacy.public.short": "Público",
"privacy.public.long": "Mostrar en la historia federada", "privacy.public.long": "Mostrar en la historia federada",
"privacy.unlisted.short": "Sin federar", "privacy.unlisted.short": "Sin federar",
"privacy.unlisted.long": "No mostrar en la historia federada", "privacy.unlisted.long": "No mostrar en la historia federada",

View File

@ -75,6 +75,7 @@ const fr = {
"navigation_bar.favourites": "Favoris", "navigation_bar.favourites": "Favoris",
"navigation_bar.info": "Plus d'informations", "navigation_bar.info": "Plus d'informations",
"navigation_bar.logout": "Déconnexion", "navigation_bar.logout": "Déconnexion",
"navigation_bar.mutes": "Utilisateurs muets",
"navigation_bar.follow_requests": "Demandes de suivi", "navigation_bar.follow_requests": "Demandes de suivi",
"reply_indicator.cancel": "Annuler", "reply_indicator.cancel": "Annuler",
"search.placeholder": "Rechercher", "search.placeholder": "Rechercher",

View File

@ -1,119 +1,125 @@
const ja = { const ja = {
"column_back_button.label": "戻る", "account.block": "@{name} さんをブロック",
"lightbox.close": "閉じる", "account.disclaimer": "このユーザーは他のインスタンスに所属しているため、数字が正確で無い場合があります。",
"loading_indicator.label": "読み込み中...",
"status.mention": "@{name} さんへの返信",
"status.delete": "削除",
"status.reply": "返信",
"status.reblog": "ブースト",
"status.favourite": "お気に入り",
"status.reblogged_by": "{name} さんにブーストされました",
"status.sensitive_warning": "不適切なコンテンツ",
"status.sensitive_toggle": "クリックして表示",
"status.show_more": "もっと見る",
"status.load_more": "もっと見る",
"status.show_less": "隠す",
"status.open": "Expand this status",
"status.report": "@{name} さんを通報",
"status.media_hidden": "非表示のメデイア",
"video_player.toggle_sound": "音の切り替え",
"account.mention": "@{name} さんに返信",
"account.edit_profile": "プロフィールを編集", "account.edit_profile": "プロフィールを編集",
"account.follow": "フォロー",
"account.followers": "フォロワー",
"account.follows": "フォロー",
"account.follows_you": "フォローされています",
"account.mention": "@{name} さんに返信",
"account.mute": "ミュート",
"account.posts": "投稿",
"account.report": "@{name}を通報する",
"account.requested": "承認待ち",
"account.unblock": "@{name} さんのブロックを解除", "account.unblock": "@{name} さんのブロックを解除",
"account.unfollow": "フォロー解除", "account.unfollow": "フォロー解除",
"account.block": "@{name} さんをブロック",
"account.mute": "ミュート",
"account.unmute": "ミュート解除", "account.unmute": "ミュート解除",
"account.follow": "フォロー", "boost_modal.combo": "次からは{combo}を押せば、これをスキップできます。",
"account.report": "@{name}を通報する", "column.blocks": "ブロックしたユーザー",
"account.posts": "投稿",
"account.follows": "フォロー",
"account.followers": "フォロワー",
"account.follows_you": "フォローされています",
"account.requested": "承認待ち",
"follow_request.authorize": "許可",
"follow_request.reject": "拒否",
"getting_started.heading": "スタート",
"getting_started.about_addressing": "ドメインとユーザー名を知っているなら検索フォームに入力すればフォローできます。",
"getting_started.about_shortcuts": "対象のアカウントがあなたと同じドメインのユーザーならばユーザー名のみで検索できます。これは返信のときも一緒です。",
"getting_started.open_source_notice": "Mastodon はオープンソースソフトウェアです。誰でも GitHub{github})から開発に参加したり、問題を報告したりできます。 {apps}",
"column.home": "ホーム",
"column.community": "ローカルタイムライン", "column.community": "ローカルタイムライン",
"column.public": "連合タイムライン",
"column.notifications": "通知",
"column.favourites": "お気に入り", "column.favourites": "お気に入り",
"tabs_bar.compose": "投稿", "column.follow_requests": "フォローリクエスト",
"tabs_bar.home": "ホーム", "column.home": "ホーム",
"tabs_bar.mentions": "返信", "column.mutes": "ミュートしたユーザー",
"tabs_bar.local_timeline": "ローカル", "column.notifications": "通知",
"tabs_bar.federated_timeline": "連合", "column.public": "連合タイムライン",
"tabs_bar.notifications": "通知", "column_back_button.label": "戻る",
"compose_form.placeholder": "今なにしてる?", "compose_form.placeholder": "今なにしてる?",
"compose_form.privacy_disclaimer": "あなたの非公開トゥートは返信先のユーザーat {domains})に公開されます。{domainsCount, plural, one {that server} other {those servers}}を信頼しますか投稿のプライバシー保護はMastodonサーバー内でのみ有効です。 もし{domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}ならばあなたの投稿のプライバシーは保護されず、ブーストされたり予期しないユーザーに見られる可能性があります。",
"compose_form.publish": "トゥート", "compose_form.publish": "トゥート",
"compose_form.sensitive": "メディアを不適切なコンテンツとしてマークする", "compose_form.sensitive": "メディアを不適切なコンテンツとしてマークする",
"compose_form.spoiler": "テキストを隠す", "compose_form.spoiler": "テキストを隠す",
"compose_form.spoiler_placeholder": "内容注意メッセージ", "compose_form.spoiler_placeholder": "閲覧注意",
"compose_form.private": "非公開にする", "emoji_button.label": "絵文字を追加",
"compose_form.privacy_disclaimer": "あなたの非公開トゥートは返信先のユーザーat {domains})に公開されます。{domainsCount, plural, one {that server} other {those servers}}を信頼しますか投稿のプライバシー保護はMastodonサーバー内でのみ有効です。 もし{domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}ならばあなたの投稿のプライバシーは保護されず、ブーストされたり予期しないユーザーに見られる可能性があります。", "empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!",
"compose_form.unlisted": "公開タイムラインに表示しない", "empty_column.hashtag": "このハッシュタグはまだ使われていません。",
"privacy.public.short": "公開",
"privacy.public.long": "公開TLに投稿する",
"privacy.unlisted.short": "未収載",
"privacy.unlisted.long": "公開TLで表示しない",
"privacy.private.short": "非公開",
"privacy.private.long": "フォロワーだけに公開",
"privacy.direct.short": "ダイレクト",
"privacy.direct.long": "含んだユーザーだけに公開",
"privacy.change": "投稿のプライバシーを変更2",
"report.heading": "新規通報",
"report.placeholder": "コメント",
"report.target": "問題のユーザー",
"report.submit": "通報する",
"navigation_bar.edit_profile": "プロフィールを編集",
"navigation_bar.preferences": "ユーザー設定",
"navigation_bar.community_timeline": "ローカルタイムライン",
"navigation_bar.public_timeline": "連合タイムライン",
"navigation_bar.logout": "ログアウト",
"navigation_bar.favourites": "お気に入り",
"navigation_bar.blocks": "ブロックしたユーザー",
"navigation_bar.info": "サーバー情報",
"reply_indicator.cancel": "キャンセル",
"search.placeholder": "検索",
"search.account": "アカウント",
"search.hashtag": "ハッシュタグ",
"search.status_by": "{uuuname}からの投稿",
"upload_area.title": "ファイルをこちらにドラッグしてください",
"upload_button.label": "メディアを追加",
"upload_form.undo": "やり直す",
"notification.follow": "{name} さんにフォローされました",
"notification.favourite": "{name} さんがあなたのトゥートをお気に入りに登録しました",
"notification.reblog": "{name} さんがあなたのトゥートをブーストしました",
"notification.mention": "{name} さんがあなたに返信しました",
"notifications.clear": "通知を片付ける",
"notifications.clear_confirmation": "通知を全部片付けます。大丈夫ですか?",
"notifications.column_settings.alert": "デスクトップ通知",
"notifications.column_settings.show": "カラムに表示",
"notifications.column_settings.follow": "新しいフォロワー",
"notifications.column_settings.favourite": "お気に入り",
"notifications.column_settings.mention": "返信",
"notifications.column_settings.reblog": "ブースト",
"notifications.column_settings.sound": "通知音を再生",
"empty_column.home": "まだ誰もフォローしていません。{public}を見に行くか、検索を使って他のユーザーを見つけましょう。", "empty_column.home": "まだ誰もフォローしていません。{public}を見に行くか、検索を使って他のユーザーを見つけましょう。",
"empty_column.home.public_timeline": "連合タイムライン", "empty_column.home.public_timeline": "連合タイムライン",
"empty_column.notifications": "まだ通知がありません。他の人とふれ合って会話を始めましょう。", "empty_column.notifications": "まだ通知がありません。他の人とふれ合って会話を始めましょう。",
"empty_column.public": "ここにはまだ何もありません!公開で何かを投稿したり、他のインスタンスのユーザーをフォローしたりしていっぱいにしましょう!", "empty_column.public": "ここにはまだ何もありません!公開で何かを投稿したり、他のインスタンスのユーザーをフォローしたりしていっぱいにしましょう!",
"empty_column.hashtag": "このハッシュタグはまだ使っていません。", "follow_request.authorize": "許可",
"upload_progress.label": "アップロード中…", "follow_request.reject": "拒否",
"emoji_button.label": "絵文字を追加", "getting_started.apps": "さまざまなアプリで利用できます。",
"getting_started.heading": "スタート",
"getting_started.open_source_notice": "Mastodon はオープンソースソフトウェアです。誰でも GitHub{github})から開発に参加したり、問題を報告したりできます。 {apps}",
"home.column_settings.advanced": "上級者向け",
"home.column_settings.basic": "シンプル", "home.column_settings.basic": "シンプル",
"home.column_settings.advanced": "エキスパート", "home.column_settings.filter_regex": "正規表現でフィルター",
"home.column_settings.show_reblogs": "ブースト表示", "home.column_settings.show_reblogs": "ブースト表示",
"home.column_settings.show_replies": "返信表示", "home.column_settings.show_replies": "返信表示",
"home.column_settings.filter_regex": "正規表現でフィルター",
"home.settings": "カラム設定", "home.settings": "カラム設定",
"notification.settings": "カラム設定", "lightbox.close": "閉じる",
"loading_indicator.label": "読み込み中...",
"media_gallery.toggle_visible": "表示切り替え",
"missing_indicator.label": "見つかりません", "missing_indicator.label": "見つかりません",
"boost_modal.combo": "次は{combo}を押せば、これをスキップできます。" "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.mutes": "ミュートしたユーザー",
"navigation_bar.preferences": "ユーザー設定",
"navigation_bar.public_timeline": "連合タイムライン",
"notification.favourite": "{name} さんがあなたのトゥートをお気に入りに登録しました",
"notification.follow": "{name} さんにフォローされました",
"notification.mention": "{name} さんがあなたに返信しました",
"notification.reblog": "{name} さんがあなたのトゥートをブーストしました",
"notifications.clear": "通知を消去",
"notifications.clear_confirmation": "本当に通知を消去しますか?",
"notifications.column_settings.alert": "デスクトップ通知",
"notifications.column_settings.favourite": "お気に入り",
"notifications.column_settings.follow": "新しいフォロワー",
"notifications.column_settings.mention": "返信",
"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": "公開TLに投稿する",
"privacy.public.short": "公開",
"privacy.unlisted.long": "公開TLで表示しない",
"privacy.unlisted.short": "未収載",
"reply_indicator.cancel": "キャンセル",
"report.heading": "新規通報",
"report.placeholder": "コメント",
"report.submit": "通報する",
"report.target": "問題のユーザー",
"search.placeholder": "検索",
"search.status_by": "{name}からの投稿",
"search_results.total": "{count} {count, plural, one {result} other {results}} 件",
"status.delete": "削除",
"status.favourite": "お気に入り",
"status.load_more": "もっと見る",
"status.media_hidden": "非表示のメデイア",
"status.mention": "@{name} さんへの返信",
"status.open": "詳細を表示",
"status.reblog": "ブースト",
"status.reblogged_by": "{name} さんにブーストされました",
"status.reply": "返信",
"status.report": "@{name} さんを通報",
"status.sensitive_toggle": "クリックして表示",
"status.sensitive_warning": "不適切なコンテンツ",
"status.show_less": "隠す",
"status.show_more": "もっと見る",
"tabs_bar.compose": "投稿",
"tabs_bar.federated_timeline": "連合",
"tabs_bar.home": "ホーム",
"tabs_bar.local_timeline": "ローカル",
"tabs_bar.notifications": "通知",
"upload_area.title": "ドラッグ&ドロップでアップロード",
"upload_button.label": "メディアを追加",
"upload_form.undo": "やり直す",
"upload_progress.label": "アップロード中…",
"video_player.expand": "動画の詳細",
"video_player.toggle_sound": "音の切り替え",
"video_player.toggle_visible": "表示切り替え",
"video_player.video_error": "動画の再生に失敗しました",
}; };
export default ja; export default ja;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,17 +9,17 @@ const initialState = Immutable.List([]);
export default function alerts(state = initialState, action) { export default function alerts(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ALERT_SHOW: case ALERT_SHOW:
return state.push(Immutable.Map({ return state.push(Immutable.Map({
key: state.size > 0 ? state.last().get('key') + 1 : 0, key: state.size > 0 ? state.last().get('key') + 1 : 0,
title: action.title, title: action.title,
message: action.message message: action.message
})); }));
case ALERT_DISMISS: case ALERT_DISMISS:
return state.filterNot(item => item.get('key') === action.alert.key); return state.filterNot(item => item.get('key') === action.alert.key);
case ALERT_CLEAR: case ALERT_CLEAR:
return state.clear(); return state.clear();
default: default:
return state; return state;
} }
}; };

View File

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

View File

@ -3,6 +3,8 @@ import { STORE_HYDRATE } from '../actions/store';
import Immutable from 'immutable'; import Immutable from 'immutable';
const initialState = Immutable.Map({ const initialState = Immutable.Map({
onboarded: false,
home: Immutable.Map({ home: Immutable.Map({
shows: Immutable.Map({ shows: Immutable.Map({
reblog: true, reblog: true,

View File

@ -48,6 +48,9 @@ const normalizeStatus = (state, status) => {
normalStatus.reblog = status.reblog.id; normalStatus.reblog = status.reblog.id;
} }
const linebreakComplemented = status.content.replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n');
normalStatus.unescaped_content = new DOMParser().parseFromString(linebreakComplemented, 'text/html').documentElement.textContent;
return state.update(status.id, Immutable.Map(), map => map.mergeDeep(Immutable.fromJS(normalStatus))); return state.update(status.id, Immutable.Map(), map => map.mergeDeep(Immutable.fromJS(normalStatus)));
}; };

View File

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

View File

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

View File

@ -64,7 +64,7 @@
p, li { p, li {
font: 16px/28px 'Montserrat', sans-serif; font: 16px/28px 'Montserrat', sans-serif;
font-weight: 400; font-weight: 400;
margin-bottom: 26px; margin-bottom: 12px;
a { a {
color: $color4; color: $color4;
@ -352,7 +352,7 @@
} }
} }
} }
@media screen and (max-width: 625px) { @media screen and (max-width: 625px) {
.mascot { .mascot {
display: none; display: none;

View File

@ -932,6 +932,12 @@ a.status__content__spoiler-link {
} }
} }
.pseudo-drawer {
background: lighten($color1, 13%);
font-size: 13px;
text-align: left;
}
.drawer__header { .drawer__header {
flex: 0 0 auto; flex: 0 0 auto;
font-size: 16px; font-size: 16px;
@ -1203,6 +1209,10 @@ a.status__content__spoiler-link {
&:focus { &:focus {
outline: 0; outline: 0;
} }
@media screen and (max-width: 600px) {
font-size: 16px;
}
} }
.spoiler-input__input { .spoiler-input__input {
@ -1267,6 +1277,10 @@ a.status__content__spoiler-link {
color: $color5; color: $color5;
border-bottom-color: $color4; border-bottom-color: $color4;
} }
@media screen and (max-width: 600px) {
font-size: 16px;
}
} }
@import 'boost'; @import 'boost';
@ -1383,7 +1397,7 @@ button.icon-button.active i.fa-retweet {
} }
} }
.media-spoiler { .media-spoiler, .video-error-cover {
background: $color8; background: $color8;
color: $color5; color: $color5;
} }
@ -1906,6 +1920,10 @@ button.icon-button.active i.fa-retweet {
&:focus { &:focus {
background: lighten($color1, 4%); background: lighten($color1, 4%);
} }
@media screen and (max-width: 600px) {
font-size: 16px;
}
} }
.search__icon { .search__icon {
@ -2006,6 +2024,7 @@ button.icon-button.active i.fa-retweet {
.modal-root__modal { .modal-root__modal {
pointer-events: auto; pointer-events: auto;
display: flex; display: flex;
z-index: 9999;
} }
.media-modal { .media-modal {
@ -2019,6 +2038,237 @@ button.icon-button.active i.fa-retweet {
} }
} }
.onboarding-modal {
background: $color2;
color: $color1;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.onboarding-modal__pager {
height: 80vh;
width: 80vw;
max-width: 500px;
max-height: 350px;
position: relative;
& > div {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 25px;
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
display: flex;
opacity: 0;
user-select: text;
}
}
@media screen and (max-width: 550px) {
.onboarding-modal {
width: 100%;
height: 100%;
border-radius: 0;
}
.onboarding-modal__pager {
width: 100%;
height: auto;
max-width: none;
max-height: none;
flex: 1 1 auto;
}
}
.onboarding-modal__paginator {
flex: 0 0 auto;
background: darken($color2, 8%);
display: flex;
padding: 25px;
& > div {
min-width: 33px;
}
a {
color: darken($color2, 34%);
text-decoration: none;
font-size: 14px;
font-weight: 500;
&:hover, &:focus, &:active {
color: darken($color2, 38%);
}
&.onboarding-modal__done, &.onboarding-modal__next {
color: $color4;
}
}
}
.onboarding-modal__dots {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
}
.onboarding-modal__dot {
width: 14px;
height: 14px;
border-radius: 14px;
background: darken($color2, 16%);
margin: 0 3px;
cursor: pointer;
&:hover {
background: darken($color2, 18%);
}
&.active {
cursor: default;
background: darken($color2, 24%);
}
}
.onboarding-modal__page {
cursor: default;
line-height: 21px;
h1 {
font-size: 18px;
font-weight: 500;
color: $color1;
margin-bottom: 20px;
}
a {
color: $color4;
&:hover, &:focus, &:active {
color: lighten($color4, 4%);
}
}
p {
font-size: 16px;
color: lighten($color1, 8%);
margin-top: 10px;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
strong {
font-weight: 500;
background: $color1;
color: $color2;
border-radius: 4px;
font-size: 14px;
padding: 3px 6px;
}
}
}
.onboarding-modal__page-one {
display: flex;
}
.onboarding-modal__page-one__elephant-friend {
background: image-url('elephant-friend.png') no-repeat 0 0;
width: 147px;
height: 160px;
margin-right: 10px;
}
.onboarding-modal__page-two,
.onboarding-modal__page-three,
.onboarding-modal__page-four,
.onboarding-modal__page-five {
p {
text-align: left;
}
.figure {
background: darken($color1, 8%);
color: $color2;
margin-bottom: 20px;
border-radius: 4px;
padding: 10px;
text-align: center;
font-size: 14px;
box-shadow: 1px 2px 6px rgba($color8, 0.3);
.onboarding-modal__image {
border-radius: 4px;
margin-bottom: 10px;
}
&.non-interactive {
pointer-events: none;
text-align: left;
}
}
}
.onboarding-modal__page-four__columns {
.row {
display: flex;
margin-bottom: 20px;
& > div {
flex: 1 1 0;
margin: 0 10px;
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
p {
text-align: center;
}
}
&:last-child {
margin-bottom: 0;
}
}
.column-header {
color: $color5;
}
}
.onboarding-modal__image {
border-radius: 8px;
width: 70vw;
max-width: 450px;
max-height: auto;
display: block;
margin: auto;
margin-bottom: 20px;
}
.onboard-sliders {
display: inline-block;
max-width: 30px;
max-height: auto;
margin-left: 10px;
}
.boost-modal { .boost-modal {
background: lighten($color2, 8%); background: lighten($color2, 8%);
color: $color1; color: $color1;
@ -2056,3 +2306,11 @@ button.icon-button.active i.fa-retweet {
flex: 0 0 auto; flex: 0 0 auto;
} }
} }
.loading-bar {
background-color: $color4;
height: 3px;
position: absolute;
top: 0;
left: 0;
}

View File

@ -6,3 +6,7 @@
margin: 0 5px; margin: 0 5px;
} }
} }
.recovery-codes {
list-style: none;
}

16
app/assets/stylesheets/variables.scss Normal file → Executable file
View File

@ -1,8 +1,8 @@
$color1: #282c37; // darkest $color1: #282c37 !default; // darkest
$color2: #d9e1e8; // lightest $color2: #d9e1e8 !default; // lightest
$color3: #9baec8; // lighter $color3: #9baec8 !default; // lighter
$color4: #2b90d9; // vibrant $color4: #2b90d9 !default; // vibrant
$color5: #ffffff; // white $color5: #ffffff !default; // white
$color6: #df405a; // error red $color6: #df405a !default; // error red
$color7: #79bd9a; // succ green $color7: #79bd9a !default; // succ green
$color8: #000000; // black $color8: #000000 !default; // black

View File

@ -15,16 +15,26 @@ module Admin
if @domain_block.save if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id) DomainBlockWorker.perform_async(@domain_block.id)
redirect_to admin_domain_blocks_path, notice: 'Domain block is now being processed' redirect_to admin_domain_blocks_path, notice: I18n.t('admin.domain_blocks.created_msg')
else else
render action: :new render action: :new
end end
end end
def show
@domain_block = DomainBlock.find(params[:id])
end
def destroy
@domain_block = DomainBlock.find(params[:id])
UnblockDomainService.new.call(@domain_block, resource_params[:retroactive])
redirect_to admin_domain_blocks_path, notice: I18n.t('admin.domain_blocks.destroyed_msg')
end
private private
def resource_params def resource_params
params.require(:domain_block).permit(:domain, :severity) params.require(:domain_block).permit(:domain, :severity, :reject_media, :retroactive)
end end
end end
end end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
module Admin
class ReportedStatusesController < BaseController
def destroy
status = Status.find params[:id]
RemovalWorker.perform_async(status.id)
redirect_to admin_report_path(report)
end
private
def report
Report.find(params[:report_id])
end
end
end

View File

@ -5,38 +5,60 @@ module Admin
before_action :set_report, except: [:index] before_action :set_report, except: [:index]
def index def index
@reports = Report.includes(:account, :target_account).order('id desc').page(params[:page]) @reports = filtered_reports.page(params[:page])
@reports = params[:action_taken].present? ? @reports.resolved : @reports.unresolved
end end
def show def show; end
@statuses = Status.where(id: @report.status_ids)
end
def resolve def update
@report.update(action_taken: true, action_taken_by_account_id: current_account.id) process_report
redirect_to admin_report_path(@report)
end
def suspend
Admin::SuspensionWorker.perform_async(@report.target_account.id)
Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id)
redirect_to admin_report_path(@report)
end
def silence
@report.target_account.update(silenced: true)
Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id)
redirect_to admin_report_path(@report)
end
def remove
RemovalWorker.perform_async(params[:status_id])
redirect_to admin_report_path(@report) redirect_to admin_report_path(@report)
end end
private private
def process_report
case params[:outcome].to_s
when 'resolve'
@report.update(action_taken_by_current_attributes)
when 'suspend'
Admin::SuspensionWorker.perform_async(@report.target_account.id)
resolve_all_target_account_reports
when 'silence'
@report.target_account.update(silenced: true)
resolve_all_target_account_reports
else
raise ActiveRecord::RecordNotFound
end
end
def action_taken_by_current_attributes
{ action_taken: true, action_taken_by_account_id: current_account.id }
end
def resolve_all_target_account_reports
unresolved_reports_for_target_account.update_all(
action_taken_by_current_attributes
)
end
def unresolved_reports_for_target_account
Report.where(
target_account: @report.target_account
).unresolved
end
def filtered_reports
filtering_scope.order('id desc').includes(
:account,
:target_account
)
end
def filtering_scope
params[:resolved].present? ? Report.resolved : Report.unresolved
end
def set_report def set_report
@report = Report.find(params[:id]) @report = Report.find(params[:id])
end end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,4 +4,8 @@ module ApplicationHelper
def active_nav_class(path) def active_nav_class(path)
current_page?(path) ? 'active' : '' current_page?(path) ? 'active' : ''
end end
def show_landing_strip?
!user_signed_in? && !single_user_mode?
end
end end

19
app/helpers/style_helper.rb Executable file
View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module StyleHelper
def stylesheet_for_layout
if asset_exist? 'custom.css'
'custom'
else
'application'
end
end
def asset_exist?(path)
if Rails.configuration.assets.compile
Rails.application.precompiled_assets.include? path
else
Rails.application.assets_manifest.assets[path].present?
end
end
end

View File

@ -3,6 +3,8 @@
class AtomSerializer class AtomSerializer
include RoutingHelper include RoutingHelper
INVALID_XML_CHARS = /[^\u0009\u000a\u000d\u0020-\uD7FF\uE000-\uFFFD\u10000-\u10FFFF]/
class << self class << self
def render(element) def render(element)
document = Ox::Document.new(version: '1.0') document = Ox::Document.new(version: '1.0')
@ -39,7 +41,7 @@ class AtomSerializer
add_namespaces(feed) add_namespaces(feed)
append_element(feed, 'id', account_url(account, format: 'atom')) append_element(feed, 'id', account_url(account, format: 'atom'))
append_element(feed, 'title', account.display_name) append_element(feed, 'title', account.display_name.presence || account.username)
append_element(feed, 'subtitle', account.note) append_element(feed, 'subtitle', account.note)
append_element(feed, 'updated', account.updated_at.iso8601) append_element(feed, 'updated', account.updated_at.iso8601)
append_element(feed, 'logo', full_asset_url(account.avatar.url(:original))) append_element(feed, 'logo', full_asset_url(account.avatar.url(:original)))
@ -311,11 +313,15 @@ class AtomSerializer
def append_element(parent, name, content = nil, attributes = {}) def append_element(parent, name, content = nil, attributes = {})
element = Ox::Element.new(name) element = Ox::Element.new(name)
attributes.each { |k, v| element[k] = v.to_s } attributes.each { |k, v| element[k] = sanitize_str(v) }
element << content.to_s unless content.nil? element << sanitize_str(content) unless content.nil?
parent << element parent << element
end end
def sanitize_str(raw_str)
raw_str.to_s.gsub(INVALID_XML_CHARS, '')
end
def add_namespaces(parent) def add_namespaces(parent)
parent['xmlns'] = TagManager::XMLNS parent['xmlns'] = TagManager::XMLNS
parent['xmlns:thr'] = TagManager::THR_XMLNS parent['xmlns:thr'] = TagManager::THR_XMLNS
@ -327,8 +333,8 @@ class AtomSerializer
end end
def serialize_status_attributes(entry, status) def serialize_status_attributes(entry, status)
append_element(entry, 'summary', status.spoiler_text) if status.spoiler_text? append_element(entry, 'summary', status.spoiler_text, 'xml:lang': status.language) if status.spoiler_text?
append_element(entry, 'content', Formatter.instance.format(status.proper).to_str, type: 'html') append_element(entry, 'content', Formatter.instance.format(status.proper).to_str, type: 'html', 'xml:lang': status.language)
status.mentions.each do |mentioned| status.mentions.each do |mentioned|
append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': TagManager::TYPES[:person], href: TagManager.instance.uri_for(mentioned.account)) append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': TagManager::TYPES[:person], href: TagManager.instance.uri_for(mentioned.account))

View File

@ -15,7 +15,7 @@ class Formatter
html = status.text html = status.text
html = encode(html) html = encode(html)
html = simple_format(html, {}, sanitize: false) html = simple_format(html, {}, sanitize: false)
html = html.gsub(/\n/, '') html = html.delete("\n")
html = link_urls(html) html = link_urls(html)
html = link_mentions(html, status.mentions) html = link_mentions(html, status.mentions)
html = link_hashtags(html) html = link_hashtags(html)

View File

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

View File

@ -59,7 +59,12 @@ class NotificationMailer < ApplicationMailer
return if @notifications.empty? return if @notifications.empty?
I18n.with_locale(@me.user.locale || I18n.default_locale) do I18n.with_locale(@me.user.locale || I18n.default_locale) do
mail to: @me.user.email, subject: I18n.t('notification_mailer.digest.subject', count: @notifications.size) mail to: @me.user.email,
subject: I18n.t(
:subject,
scope: [:notification_mailer, :digest],
count: @notifications.size
)
end end
end end
end end

View File

View File

@ -3,8 +3,13 @@
class DomainBlock < ApplicationRecord class DomainBlock < ApplicationRecord
enum severity: [:silence, :suspend] enum severity: [:silence, :suspend]
attr_accessor :retroactive
validates :domain, presence: true, uniqueness: true validates :domain, presence: true, uniqueness: true
has_many :accounts, foreign_key: :domain, primary_key: :domain
delegate :count, to: :accounts, prefix: true
def self.blocked?(domain) def self.blocked?(domain)
where(domain: domain, severity: :suspend).exists? where(domain: domain, severity: :suspend).exists?
end end

View File

@ -1,13 +1,15 @@
# frozen_string_literal: true # frozen_string_literal: true
class Import < ApplicationRecord class Import < ApplicationRecord
FILE_TYPES = ['text/plain', 'text/csv'].freeze
self.inheritance_column = false self.inheritance_column = false
belongs_to :account, required: true
enum type: [:following, :blocking, :muting] enum type: [:following, :blocking, :muting]
belongs_to :account validates :type, presence: true
FILE_TYPES = ['text/plain', 'text/csv'].freeze
has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV['PAPERCLIP_SECRET'] has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV['PAPERCLIP_SECRET']
validates_attachment_content_type :data, content_type: FILE_TYPES validates_attachment_content_type :data, content_type: FILE_TYPES

View File

@ -7,4 +7,8 @@ class Report < ApplicationRecord
scope :unresolved, -> { where(action_taken: false) } scope :unresolved, -> { where(action_taken: false) }
scope :resolved, -> { where(action_taken: true) } scope :resolved, -> { where(action_taken: true) }
def statuses
Status.where(id: status_ids)
end
end end

View File

@ -110,6 +110,10 @@ class Status < ApplicationRecord
results results
end end
def non_sensitive_with_media?
!sensitive? && media_attachments.any?
end
class << self class << self
def as_home_timeline(account) def as_home_timeline(account)
where(account: [account] + account.following) where(account: [account] + account.following)

View File

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

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