Merge branch 'master' into skylight

skylight
Eugen Rochko 2017-04-12 11:28:37 +02:00
commit 7a1903cdf7
193 changed files with 2890 additions and 3137 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
# EditorConfig is awesome: http://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = space
indent_size = 2

View File

@ -35,6 +35,10 @@ SMTP_PORT=587
SMTP_LOGIN=
SMTP_PASSWORD=
SMTP_FROM_ADDRESS=notifications@example.com
#SMTP_AUTH_METHOD=plain
#SMTP_OPENSSL_VERIFY_MODE=peer
#SMTP_ENABLE_STARTTLS_AUTO=true
# Optional asset host for multi-server setups
# CDN_HOST=assets.example.com

30
.eslintignore Normal file
View File

@ -0,0 +1,30 @@
# See https://help.github.com/articles/ignoring-files for more about ignoring files.
#
# If you find yourself ignoring temporary files generated by your text editor
# or operating system, you probably want to add a global ignore instead:
# git config --global core.excludesfile '~/.gitignore_global'
# Ignore bundler config.
/.bundle
# Ignore the default SQLite database.
/db/*.sqlite3
/db/*.sqlite3-journal
# Ignore all logfiles and tempfiles.
/log/*
!/log/.keep
/tmp
coverage
public/system
public/assets
.env
.env.production
node_modules/
neo4j/
# Ignore Vagrant files
.vagrant/
# Ignore Capistrano customizations
config/deploy/*

View File

@ -1 +1 @@
2.3.1
2.4.1

View File

@ -16,7 +16,7 @@ addons:
postgresql: 9.4
rvm:
- 2.3.1
- 2.4.1
services:
- redis-server

View File

@ -1,4 +1,4 @@
FROM ruby:2.3.1-alpine
FROM ruby:2.4.1-alpine
LABEL maintainer="https://github.com/tootsuite/mastodon" \
description="A GNU Social-compatible microblogging server"

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
source 'https://rubygems.org'
ruby '2.3.1'
ruby '2.4.1'
gem 'rails', '~> 5.0.2'
gem 'sass-rails', '~> 5.0'
@ -32,6 +32,7 @@ gem 'htmlentities'
gem 'http'
gem 'http_accept_language'
gem 'httplog'
gem 'kaminari'
gem 'link_header'
gem 'nokogiri'
gem 'oj'
@ -52,7 +53,6 @@ gem 'simple_form'
gem 'statsd-instrument'
gem 'twitter-text'
gem 'tzinfo-data'
gem 'will_paginate'
gem 'react-rails'
gem 'browserify-rails'
@ -71,6 +71,7 @@ end
group :test do
gem 'faker'
gem 'rails-controller-testing'
gem 'rspec-sidekiq'
gem 'simplecov', require: false
gem 'webmock'

View File

@ -24,7 +24,7 @@ GEM
erubis (~> 2.7.0)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.3)
active_record_query_trace (1.5.3)
active_record_query_trace (1.5.4)
activejob (5.0.2)
activesupport (= 5.0.2)
globalid (>= 0.3.6)
@ -39,7 +39,7 @@ GEM
i18n (~> 0.7)
minitest (~> 5.1)
tzinfo (~> 1.1)
addressable (2.5.0)
addressable (2.5.1)
public_suffix (~> 2.0, >= 2.0.2)
airbrussh (1.1.2)
sshkit (>= 1.6.1, != 1.7.0)
@ -47,17 +47,17 @@ GEM
ast (2.3.0)
attr_encrypted (3.0.3)
encryptor (~> 3.0.0)
autoprefixer-rails (6.5.0.2)
autoprefixer-rails (6.7.7.1)
execjs
av (0.9.0)
cocaine (~> 0.5.3)
aws-sdk (2.6.28)
aws-sdk-resources (= 2.6.28)
aws-sdk-core (2.6.28)
aws-sdk (2.9.6)
aws-sdk-resources (= 2.9.6)
aws-sdk-core (2.9.6)
aws-sigv4 (~> 1.0)
jmespath (~> 1.0)
aws-sdk-resources (2.6.28)
aws-sdk-core (= 2.6.28)
aws-sdk-resources (2.9.6)
aws-sdk-core (= 2.9.6)
aws-sigv4 (1.0.0)
babel-source (5.8.35)
babel-transpiler (0.7.0)
@ -78,12 +78,11 @@ GEM
railties (>= 4.0.0, < 5.1)
sprockets (>= 3.6.0)
builder (3.2.3)
bullet (5.3.0)
bullet (5.5.1)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.10.0)
capistrano (3.7.2)
capistrano (3.8.0)
airbrussh (>= 1.0.0)
capistrano-harrow
i18n
rake (>= 10.0.0)
sshkit (>= 1.9.0)
@ -92,8 +91,7 @@ GEM
sshkit (~> 1.2)
capistrano-faster-assets (1.0.2)
capistrano (>= 3.1)
capistrano-harrow (0.5.3)
capistrano-rails (1.2.2)
capistrano-rails (1.2.3)
capistrano (~> 3.1)
capistrano-bundler (~> 1.1)
capistrano-rbenv (2.1.0)
@ -119,7 +117,7 @@ GEM
crack (0.4.3)
safe_yaml (~> 1.0.0)
debug_inspector (0.0.2)
devise (4.2.0)
devise (4.2.1)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0, < 5.1)
@ -131,16 +129,16 @@ GEM
devise (~> 4.0)
railties
rotp (~> 2.0)
diff-lcs (1.2.5)
diff-lcs (1.3)
docile (1.1.5)
domain_name (0.5.20161129)
domain_name (0.5.20170404)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (4.2.0)
doorkeeper (4.2.5)
railties (>= 4.2)
dotenv (2.1.1)
dotenv-rails (2.1.1)
dotenv (= 2.1.1)
railties (>= 4.0, < 5.1)
dotenv (2.2.0)
dotenv-rails (2.2.0)
dotenv (= 2.2.0)
railties (>= 3.2, < 5.1)
easy_translate (0.5.0)
json
thread
@ -148,14 +146,14 @@ GEM
encryptor (3.0.0)
erubis (2.7.0)
execjs (2.7.0)
fabrication (2.15.2)
faker (1.6.6)
fabrication (2.16.1)
faker (1.7.3)
i18n (~> 0.5)
fast_blank (1.0.0)
font-awesome-rails (4.6.3.1)
font-awesome-rails (4.7.0.1)
railties (>= 3.2, < 5.1)
fuubar (2.1.1)
rspec (~> 3.0)
fuubar (2.2.0)
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
globalid (0.3.7)
activesupport (>= 4.1.0)
@ -163,20 +161,20 @@ GEM
addressable (~> 2.4)
http (~> 2.0)
nokogiri (~> 1.6)
hamlit (2.7.2)
temple (~> 0.7.6)
hamlit (2.8.1)
temple (>= 0.8.0)
thor
tilt
hamlit-rails (0.1.0)
hamlit-rails (0.2.0)
actionpack (>= 4.0.1)
activesupport (>= 4.0.1)
hamlit (>= 1.2.0)
railties (>= 4.0.1)
hashdiff (0.3.0)
hashdiff (0.3.2)
highline (1.7.8)
hiredis (0.6.1)
htmlentities (4.3.4)
http (2.1.0)
http (2.2.1)
addressable (~> 2.3)
http-cookie (~> 1.0)
http-form_data (~> 1.0.1)
@ -186,10 +184,10 @@ GEM
http-form_data (1.0.1)
http_accept_language (2.1.0)
http_parser.rb (0.6.0)
httplog (0.3.2)
httplog (0.99.2)
colorize
i18n (0.8.1)
i18n-tasks (0.9.6)
i18n-tasks (0.9.13)
activesupport (>= 4.0.2)
ast (>= 2.1.0)
easy_translate (>= 0.5.0)
@ -197,19 +195,31 @@ GEM
highline (>= 1.7.3)
i18n
parser (>= 2.2.3.0)
term-ansicolor (>= 1.3.2)
rainbow (~> 2.2)
terminal-table (>= 1.5.1)
jmespath (1.3.1)
jquery-rails (4.1.1)
jquery-rails (4.3.1)
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (1.8.3)
json (2.0.3)
kaminari (1.0.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.0.1)
kaminari-activerecord (= 1.0.1)
kaminari-core (= 1.0.1)
kaminari-actionview (1.0.1)
actionview
kaminari-core (= 1.0.1)
kaminari-activerecord (1.0.1)
activerecord
kaminari-core (= 1.0.1)
kaminari-core (1.0.1)
launchy (2.4.3)
addressable (~> 2.3)
letter_opener (1.4.1)
launchy (~> 2.2)
letter_opener_web (1.3.0)
letter_opener_web (1.3.1)
actionmailer (>= 3.2)
letter_opener (~> 1.0)
railties (>= 3.2)
@ -231,11 +241,11 @@ GEM
minitest (5.10.1)
net-scp (1.2.1)
net-ssh (>= 2.6.5)
net-ssh (4.0.1)
net-ssh (4.1.0)
nio4r (2.0.0)
nokogiri (1.7.1)
mini_portile2 (~> 2.1.0)
oj (2.17.3)
oj (2.18.5)
orm_adapter (0.5.0)
ostatus2 (1.0.2)
addressable (~> 2.4)
@ -251,26 +261,26 @@ GEM
paperclip-av-transcoder (0.6.4)
av (~> 0.9.0)
paperclip (>= 2.5.2)
parser (2.3.1.2)
parser (2.4.0.0)
ast (~> 2.2)
pg (0.18.4)
pghero (1.6.2)
pg (0.20.0)
pghero (1.6.4)
activerecord
powerpack (0.1.1)
pry (0.10.4)
coderay (~> 1.1.0)
method_source (~> 0.8.1)
slop (~> 3.4)
pry-rails (0.3.4)
pry (>= 0.9.10)
public_suffix (2.0.4)
puma (3.6.0)
pry-rails (0.3.6)
pry (>= 0.10.4)
public_suffix (2.0.5)
puma (3.8.2)
rabl (0.13.1)
activesupport (>= 2.3.14)
rack (2.0.1)
rack-attack (5.0.1)
rack
rack-cors (0.4.0)
rack-cors (0.4.1)
rack-protection (1.5.3)
rack
rack-test (0.6.3)
@ -288,6 +298,10 @@ GEM
bundler (>= 1.3.0, < 2.0)
railties (= 5.0.2)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.1)
actionpack (~> 5.x)
actionview (~> 5.x)
activesupport (~> 5.x)
rails-dom-testing (2.0.2)
activesupport (>= 4.2.0, < 6.0)
nokogiri (~> 1.6)
@ -306,42 +320,37 @@ GEM
method_source
rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0)
rainbow (2.1.0)
rainbow (2.2.1)
rake (12.0.0)
react-rails (1.10.0)
react-rails (1.11.0)
babel-transpiler (>= 0.7.0)
coffee-script-source (~> 1.8)
connection_pool
execjs
railties (>= 3.2)
tilt
redis (3.3.2)
redis-actionpack (5.0.0)
actionpack (>= 4.0.0, < 6)
redis-rack (~> 2.0.0.pre)
redis-store (~> 1.2.0.pre)
redis-activesupport (5.0.1)
redis (3.3.3)
redis-actionpack (5.0.1)
actionpack (>= 4.0, < 6)
redis-rack (>= 1, < 3)
redis-store (>= 1.1.0, < 1.4.0)
redis-activesupport (5.0.2)
activesupport (>= 3, < 6)
redis-store (~> 1.2.0)
redis-rack (2.0.0)
rack (~> 2.0)
redis-store (~> 1.2.0)
redis-rails (5.0.1)
redis-actionpack (~> 5.0.0)
redis-activesupport (~> 5.0.0)
redis-store (~> 1.2.0)
redis-store (1.2.0)
redis-store (~> 1.3.0)
redis-rack (2.0.1)
rack (>= 2.0, < 3)
redis-store (>= 1.2, < 1.4)
redis-rails (5.0.2)
redis-actionpack (>= 5.0, < 6)
redis-activesupport (>= 5.0, < 6)
redis-store (>= 1.2, < 2)
redis-store (1.3.0)
redis (>= 2.2)
responders (2.3.0)
railties (>= 4.2.0, < 5.1)
rotp (2.1.2)
rqrcode (0.10.1)
chunky_png (~> 1.0)
rspec (3.5.0)
rspec-core (~> 3.5.0)
rspec-expectations (~> 3.5.0)
rspec-mocks (~> 3.5.0)
rspec-core (3.5.2)
rspec-core (3.5.4)
rspec-support (~> 3.5.0)
rspec-expectations (3.5.0)
diff-lcs (>= 1.2.0, < 2.0)
@ -349,7 +358,7 @@ GEM
rspec-mocks (3.5.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.5.0)
rspec-rails (3.5.1)
rspec-rails (3.5.2)
actionpack (>= 3.0)
activesupport (>= 3.0)
railties (>= 3.0)
@ -357,27 +366,27 @@ GEM
rspec-expectations (~> 3.5.0)
rspec-mocks (~> 3.5.0)
rspec-support (~> 3.5.0)
rspec-sidekiq (2.2.0)
rspec (~> 3.0, >= 3.0.0)
rspec-sidekiq (3.0.0)
rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0)
rspec-support (3.5.0)
rubocop (0.42.0)
parser (>= 2.3.1.1, < 3.0)
rubocop (0.48.1)
parser (>= 2.3.3.1, < 3.0)
powerpack (~> 0.1)
rainbow (>= 1.99.1, < 3.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
ruby-oembed (0.10.1)
ruby-oembed (0.12.0)
ruby-progressbar (1.8.1)
safe_yaml (1.0.4)
sass (3.4.22)
sass (3.4.23)
sass-rails (5.0.6)
railties (>= 4.0.0, < 6)
sass (~> 3.1)
sprockets (>= 2.8, < 4.0)
sprockets-rails (>= 2.0, < 4.0)
tilt (>= 1.1, < 3)
sidekiq (4.2.7)
sidekiq (4.2.10)
concurrent-ruby (~> 1.0)
connection_pool (~> 2.2, >= 2.2.0)
rack-protection (>= 1.5.0)
@ -385,20 +394,20 @@ GEM
sidekiq-skylight (0.2.0)
sidekiq (>= 3.3.0)
skylight (>= 0.5.2)
sidekiq-unique-jobs (4.0.18)
sidekiq (>= 2.6)
sidekiq-unique-jobs (5.0.0)
sidekiq (>= 4.0)
thor
simple-navigation (4.0.3)
simple-navigation (4.0.5)
activesupport (>= 2.3.2)
simple_form (3.2.1)
simple_form (3.4.0)
actionpack (> 4, < 5.1)
activemodel (> 4, < 5.1)
simplecov (0.12.0)
simplecov (0.14.1)
docile (~> 1.1.0)
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.0)
skylight (1.1.0)
skylight (1.2.0)
activesupport (>= 3.0.0)
slop (3.6.0)
sprockets (3.7.1)
@ -408,43 +417,39 @@ GEM
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets (>= 3.0.0)
sshkit (1.11.5)
sshkit (1.13.1)
net-scp (>= 1.1.2)
net-ssh (>= 2.8.0)
statsd-instrument (2.1.2)
temple (0.7.7)
term-ansicolor (1.4.0)
tins (~> 1.0)
terminal-table (1.7.0)
unicode-display_width (~> 1.1)
temple (0.8.0)
terminal-table (1.7.3)
unicode-display_width (~> 1.1.1)
thor (0.19.4)
thread (0.2.2)
thread_safe (0.3.6)
tilt (2.0.6)
tins (1.12.0)
tilt (2.0.7)
twitter-text (1.14.5)
unf (~> 0.1.0)
tzinfo (1.2.2)
tzinfo (1.2.3)
thread_safe (~> 0.1)
tzinfo-data (1.2017.2)
tzinfo (>= 1.0.0)
uglifier (3.0.1)
uglifier (3.2.0)
execjs (>= 0.3.0, < 3)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.2)
unicode-display_width (1.1.0)
unicode-display_width (1.1.3)
uniform_notifier (1.10.0)
warden (1.2.6)
warden (1.2.7)
rack (>= 1.0)
webmock (2.1.0)
webmock (2.3.2)
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff
websocket-driver (0.6.5)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.2)
will_paginate (3.1.0)
PLATFORMS
ruby
@ -483,6 +488,7 @@ DEPENDENCIES
httplog
i18n-tasks (~> 0.9.6)
jquery-rails
kaminari
letter_opener
letter_opener_web
link_header
@ -502,6 +508,7 @@ DEPENDENCIES
rack-cors
rack-timeout
rails (~> 5.0.2)
rails-controller-testing
rails-settings-cached
rails_12factor
react-rails
@ -525,10 +532,9 @@ DEPENDENCIES
tzinfo-data
uglifier (>= 1.3.0)
webmock
will_paginate
RUBY VERSION
ruby 2.3.1p112
ruby 2.4.1p111
BUNDLED WITH
1.14.5
1.14.6

View File

@ -25,11 +25,11 @@ If you would like, you can [support the development of this project on Patreon][
## Resources
- [List of Mastodon instances](docs/Using-Mastodon/List-of-Mastodon-instances.md)
- [List of Mastodon instances](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md)
- [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com)
- [API overview](docs/Using-the-API/API.md)
- [Frequently Asked Questions](docs/Using-Mastodon/FAQ.md)
- [List of apps](docs/Using-Mastodon/Apps.md)
- [API overview](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md)
- [Frequently Asked Questions](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md)
- [List of apps](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md)
## Features
@ -67,7 +67,7 @@ Consult the example configuration file, `.env.production.sample` for the full li
[![](https://images.microbadger.com/badges/version/gargron/mastodon.svg)](https://microbadger.com/images/gargron/mastodon "Get your own version badge on microbadger.com") [![](https://images.microbadger.com/badges/image/gargron/mastodon.svg)](https://microbadger.com/images/gargron/mastodon "Get your own image badge on microbadger.com")
The project now includes a `Dockerfile` and a `docker-compose.yml`. You need to turn `.env.production.sample` into `.env.production` with all the variables set before you can:
The project now includes a `Dockerfile` and a `docker-compose.yml` file (which requires at least docker-compose version `1.10.0`). You need to turn `.env.production.sample` into `.env.production` with all the variables set before you can:
docker-compose build
@ -117,25 +117,25 @@ Which will re-create the updated containers, leaving databases and data as is. D
## Deployment without Docker
Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](docs/Running-Mastodon/Production-guide.md) for examples, configuration and instructions.
Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Production-guide.md) for examples, configuration and instructions.
## Deployment on Scalingo
[![Deploy on Scalingo](https://cdn.scalingo.com/deploy/button.svg)](https://my.scalingo.com/deploy?source=https://github.com/tootsuite/mastodon#master)
[You can view a guide for deployment on Scalingo here.](docs/Running-Mastodon/Scalingo-guide.md)
[You can view a guide for deployment on Scalingo here.](https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Scalingo-guide.md)
## Deployment on Heroku (experimental)
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)
Mastodon can theoretically run indefinitely on a free [Heroku](https://heroku.com) app. [You can view a guide for deployment on Heroku here.](docs/Running-Mastodon/Heroku-guide.md)
Mastodon can theoretically run indefinitely on a free [Heroku](https://heroku.com) app. [You can view a guide for deployment on Heroku here.](https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Heroku-guide.md)
## Development with Vagrant
A quick way to get a development environment up and running is with Vagrant. You will need recent versions of [Vagrant](https://www.vagrantup.com/) and [VirtualBox](https://www.virtualbox.org/) installed.
[You can find the guide for setting up a Vagrant development environment here.](docs/Running-Mastodon/Vagrant-guide.md)
[You can find the guide for setting up a Vagrant development environment here.](https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Vagrant-guide.md)
## Contributing

8
Vagrantfile vendored
View File

@ -46,12 +46,12 @@ git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
export PATH="$HOME/.rbenv/bin::$PATH"
eval "$(rbenv init -)"
echo "Compiling Ruby 2.3.1: warning, this takes a while!!!"
rbenv install 2.3.1
rbenv global 2.3.1
cd /vagrant
echo "Compiling Ruby $(cat .ruby-version): warning, this takes a while!!!"
rbenv install $(cat .ruby-version)
rbenv global $(cat .ruby-version)
# Configure database
sudo -u postgres createuser -U postgres vagrant -s
sudo -u postgres createdb -U postgres mastodon_development

View File

@ -79,6 +79,18 @@
"SMTP_FROM_ADDRESS": {
"description": "Address to send emails from",
"required": false
},
"SMTP_AUTH_METHOD": {
"description": "Authentication method to use with SMTP server. Default is 'plain'.",
"required": false
},
"SMTP_OPENSSL_VERIFY_MODE": {
"description": "SMTP server certificate verification mode. Defaults is 'peer'.",
"required": false
},
"SMTP_ENABLE_STARTTLS_AUTO": {
"description": "Enable STARTTLS if SMTP server supports it? Default is true.",
"required": false
}
},
"buildpacks": [

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 258 KiB

View File

@ -50,6 +50,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
};
};
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
export function refreshNotifications() {
return (dispatch, getState) => {
dispatch(refreshNotificationsRequest());
@ -61,6 +63,8 @@ export function refreshNotifications() {
params.since_id = ids.first().get('id');
}
params.exclude_types = excludeTypesFromSettings(getState());
api(getState).get('/api/v1/notifications', { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
@ -105,11 +109,11 @@ export function expandNotifications() {
dispatch(expandNotificationsRequest());
api(getState).get(url, {
params: {
limit: 5
}
}).then(response => {
const params = {};
params.exclude_types = excludeTypesFromSettings(getState());
api(getState).get(url, params).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));

View File

@ -1,5 +1,5 @@
import axios from 'axios';
import LinkHeader from 'http-link-header';
import LinkHeader from './link_header';
export const getLinks = response => {
const value = response.headers.link;

View File

@ -65,7 +65,7 @@ const Account = React.createClass({
<div className='account'>
<div style={{ display: 'flex' }}>
<Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<div style={{ float: 'left', marginLeft: '12px', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={36} /></div>
<div style={{ float: 'left', marginLeft: '12px', marginRight: '10px' }}><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={36} /></div>
<DisplayName account={account} />
</Permalink>

View File

@ -1,103 +1,18 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
// From: http://stackoverflow.com/a/18320662
const resample = (canvas, width, height, resize_canvas) => {
let width_source = canvas.width;
let height_source = canvas.height;
width = Math.round(width);
height = Math.round(height);
let ratio_w = width_source / width;
let ratio_h = height_source / height;
let ratio_w_half = Math.ceil(ratio_w / 2);
let ratio_h_half = Math.ceil(ratio_h / 2);
let ctx = canvas.getContext("2d");
let img = ctx.getImageData(0, 0, width_source, height_source);
let img2 = ctx.createImageData(width, height);
let data = img.data;
let data2 = img2.data;
for (let j = 0; j < height; j++) {
for (let i = 0; i < width; i++) {
let x2 = (i + j * width) * 4;
let weight = 0;
let weights = 0;
let weights_alpha = 0;
let gx_r = 0;
let gx_g = 0;
let gx_b = 0;
let gx_a = 0;
let center_y = (j + 0.5) * ratio_h;
let yy_start = Math.floor(j * ratio_h);
let yy_stop = Math.ceil((j + 1) * ratio_h);
for (let yy = yy_start; yy < yy_stop; yy++) {
let dy = Math.abs(center_y - (yy + 0.5)) / ratio_h_half;
let center_x = (i + 0.5) * ratio_w;
let w0 = dy * dy; //pre-calc part of w
let xx_start = Math.floor(i * ratio_w);
let xx_stop = Math.ceil((i + 1) * ratio_w);
for (let xx = xx_start; xx < xx_stop; xx++) {
let dx = Math.abs(center_x - (xx + 0.5)) / ratio_w_half;
let w = Math.sqrt(w0 + dx * dx);
if (w >= 1) {
// pixel too far
continue;
}
// hermite filter
weight = 2 * w * w * w - 3 * w * w + 1;
let pos_x = 4 * (xx + yy * width_source);
// alpha
gx_a += weight * data[pos_x + 3];
weights_alpha += weight;
// colors
if (data[pos_x + 3] < 255)
weight = weight * data[pos_x + 3] / 250;
gx_r += weight * data[pos_x];
gx_g += weight * data[pos_x + 1];
gx_b += weight * data[pos_x + 2];
weights += weight;
}
}
data2[x2] = gx_r / weights;
data2[x2 + 1] = gx_g / weights;
data2[x2 + 2] = gx_b / weights;
data2[x2 + 3] = gx_a / weights_alpha;
}
}
// clear and resize canvas
if (resize_canvas === true) {
canvas.width = width;
canvas.height = height;
} else {
ctx.clearRect(0, 0, width_source, height_source);
}
// draw
ctx.putImageData(img2, 0, 0);
};
const Avatar = React.createClass({
propTypes: {
src: React.PropTypes.string.isRequired,
staticSrc: React.PropTypes.string,
size: React.PropTypes.number.isRequired,
style: React.PropTypes.object,
animated: React.PropTypes.bool
animate: React.PropTypes.bool
},
getDefaultProps () {
return {
animated: true
animate: false
};
},
@ -117,38 +32,30 @@ const Avatar = React.createClass({
this.setState({ hovering: false });
},
handleLoad () {
this.canvas.width = this.image.naturalWidth;
this.canvas.height = this.image.naturalHeight;
this.canvas.getContext('2d').drawImage(this.image, 0, 0);
resample(this.canvas, this.props.size * window.devicePixelRatio, this.props.size * window.devicePixelRatio, true);
},
setImageRef (c) {
this.image = c;
},
setCanvasRef (c) {
this.canvas = c;
},
render () {
const { src, size, staticSrc, animate } = this.props;
const { hovering } = this.state;
if (this.props.animated) {
return (
<div style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px` }}>
<img src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ borderRadius: '4px' }} />
</div>
);
const style = {
...this.props.style,
width: `${size}px`,
height: `${size}px`,
backgroundSize: `${size}px ${size}px`
};
if (hovering || animate) {
style.backgroundImage = `url(${src})`;
} else {
style.backgroundImage = `url(${staticSrc})`;
}
return (
<div onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px`, position: 'relative' }}>
<img ref={this.setImageRef} onLoad={this.handleLoad} src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ position: 'absolute', top: '0', left: '0', opacity: hovering ? '1' : '0', borderRadius: '4px' }} />
<canvas ref={this.setCanvasRef} style={{ borderRadius: '4px', width: this.props.size, height: this.props.size, opacity: hovering ? '0' : '1' }} />
</div>
<div
className='avatar'
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
style={style}
/>
);
}

View File

@ -31,7 +31,7 @@ const IconButton = React.createClass({
e.preventDefault();
if (!this.props.disabled) {
this.props.onClick();
this.props.onClick(e);
}
},

View File

@ -27,6 +27,7 @@ const Status = React.createClass({
onOpenMedia: React.PropTypes.func,
onBlock: React.PropTypes.func,
me: React.PropTypes.number,
boostModal: React.PropTypes.bool,
muted: React.PropTypes.bool
},
@ -90,7 +91,7 @@ const Status = React.createClass({
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px' }}>
<div className='status__avatar' style={{ position: 'absolute', left: '10px', top: '10px', width: '48px', height: '48px' }}>
<Avatar src={status.getIn(['account', 'avatar'])} size={48} />
<Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />
</div>
<DisplayName account={status.get('account')} />

View File

@ -46,8 +46,8 @@ const StatusActionBar = React.createClass({
this.props.onFavourite(this.props.status);
},
handleReblogClick () {
this.props.onReblog(this.props.status);
handleReblogClick (e) {
this.props.onReblog(this.props.status, e);
},
handleDeleteClick () {

View File

@ -36,6 +36,7 @@ const StatusContent = React.createClass({
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
link.setAttribute('title', mention.get('acct'));
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
} else if (media) {
@ -91,7 +92,7 @@ const StatusContent = React.createClass({
const { status } = this.props;
const { hidden } = this.state;
const content = { __html: emojify(status.get('content')) };
const content = { __html: emojify(status.get('content')).replace(/\n/g, '') };
const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
const directionStyle = { direction: 'ltr' };
@ -125,7 +126,7 @@ const StatusContent = React.createClass({
<div style={{ display: hidden ? 'none' : 'block', ...directionStyle }} dangerouslySetInnerHTML={content} />
</div>
);
} else {
} else if (this.props.onClick) {
return (
<div
className='status__content'
@ -135,6 +136,14 @@ const StatusContent = React.createClass({
dangerouslySetInnerHTML={content}
/>
);
} else {
return (
<div
className='status__content'
style={{ ...directionStyle }}
dangerouslySetInnerHTML={content}
/>
);
}
},

View File

@ -48,6 +48,9 @@ import hu from 'react-intl/locale-data/hu';
import uk from 'react-intl/locale-data/uk';
import fi from 'react-intl/locale-data/fi';
import eo from 'react-intl/locale-data/eo';
import ru from 'react-intl/locale-data/ru';
import ja from 'react-intl/locale-data/ja';
import getMessagesForLocale from '../locales';
import { hydrateStore } from '../actions/store';
import createStream from '../stream';
@ -60,7 +63,9 @@ const browserHistory = useRouterHistory(createBrowserHistory)({
basename: '/web'
});
addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk, ...fi, ...eo]);
addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk, ...fi, ...eo, ...ru, ...ja]);
const Mastodon = React.createClass({

View File

@ -26,7 +26,8 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => ({
status: getStatus(state, props.id),
me: state.getIn(['meta', 'me'])
me: state.getIn(['meta', 'me']),
boostModal: state.getIn(['meta', 'boost_modal'])
});
return mapStateToProps;
@ -38,11 +39,19 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(replyCompose(status, router));
},
onReblog (status) {
onModalReblog (status) {
dispatch(reblog(status));
},
onReblog (status, e) {
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
dispatch(reblog(status));
if (e.altKey || !this.boostModal) {
this.onModalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
}
}
},

View File

@ -4,7 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
const AutosuggestAccount = ({ account }) => (
<div style={{ overflow: 'hidden' }} className='autosuggest-account'>
<div style={{ float: 'left', marginRight: '5px' }}><Avatar src={account.get('avatar')} size={18} /></div>
<div style={{ float: 'left', marginRight: '5px' }}><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={18} /></div>
<DisplayName account={account} />
</div>
);

View File

@ -83,11 +83,23 @@ const ComposeForm = React.createClass({
this.props.onChangeSpoilerText(e.target.value);
},
componentWillReceiveProps (nextProps) {
// If this is the update where we've finished uploading,
// save the last caret position so we can restore it below!
if (!nextProps.is_uploading && this.props.is_uploading) {
this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart;
}
},
componentDidUpdate (prevProps) {
if (this.props.focusDate !== prevProps.focusDate) {
// If replying to zero or one users, places the cursor at the end of the textbox.
// If replying to more than one user, selects any usernames past the first;
// this provides a convenient shortcut to drop everyone else from the conversation.
// This statement does several things:
// - If we're beginning a reply, and,
// - 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;
// this provides a convenient shortcut to drop everyone else from the conversation.
// - If we've just finished uploading an image, and have a saved caret position,
// restores the cursor to that position after the text changes!
if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) {
let selectionEnd, selectionStart;
if (this.props.preselectDate !== prevProps.preselectDate) {
@ -118,7 +130,7 @@ const ComposeForm = React.createClass({
render () {
const { intl, needsPrivacyWarning, mentionedDomains, onPaste } = this.props;
const disabled = this.props.is_submitting || this.props.is_uploading;
const disabled = this.props.is_submitting;
let publishText = '';
let privacyWarning = '';

View File

@ -46,8 +46,8 @@ const EmojiPickerDropdown = React.createClass({
<img draggable="false" className="emojione" alt="🙂" src="/emoji/1f602.svg" />
</DropdownTrigger>
<DropdownContent className='dropdown__left'>
<EmojiPicker emojione={settings} onChange={this.handleChange} />
<DropdownContent className='dropdown__left light'>
<EmojiPicker emojione={settings} onChange={this.handleChange} search={true} />
</DropdownContent>
</Dropdown>
);

View File

@ -17,7 +17,7 @@ const NavigationBar = React.createClass({
render () {
return (
<div className='navigation-bar'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Permalink>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} animate size={40} /></Permalink>
<div style={{ flex: '1 1 auto', marginLeft: '8px' }}>
<strong style={{ fontWeight: '500', display: 'block' }}>{this.props.account.get('acct')}</strong>

View File

@ -50,7 +50,7 @@ const ReplyIndicator = React.createClass({
<div style={{ float: 'right', lineHeight: '24px' }}><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}>
<div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={status.getIn(['account', 'avatar'])} /></div>
<div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} /></div>
<DisplayName account={status.get('account')} />
</a>
</div>

View File

@ -33,7 +33,7 @@ const AccountAuthorize = ({ intl, account, onAuthorize, onReject }) => {
<div>
<div style={outerStyle}>
<Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}>
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={48} /></div>
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={48} /></div>
<DisplayName account={account} />
</Permalink>

View File

@ -4,16 +4,6 @@ const messages = defineMessages({
clear: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }
});
const iconStyle = {
fontSize: '16px',
padding: '15px',
position: 'absolute',
right: '48px',
top: '0',
cursor: 'pointer',
zIndex: '2'
};
const ClearColumnButton = React.createClass({
propTypes: {
@ -25,7 +15,7 @@ const ClearColumnButton = React.createClass({
const { intl } = this.props;
return (
<div title={intl.formatMessage(messages.clear)} className='column-icon' tabIndex='0' style={iconStyle} onClick={this.props.onClick}>
<div title={intl.formatMessage(messages.clear)} className='column-icon column-icon-clear' tabIndex='0' onClick={this.props.onClick}>
<i className='fa fa-eraser' />
</div>
);

View File

@ -21,7 +21,7 @@ const Notification = React.createClass({
renderFollow (account, link) {
return (
<div className='notification'>
<div className='notification notification-follow'>
<div className='notification__message'>
<div style={{ position: 'absolute', 'left': '-26px'}}>
<i className='fa fa-fw fa-user-plus' />
@ -41,7 +41,7 @@ const Notification = React.createClass({
renderFavourite (notification, link) {
return (
<div className='notification'>
<div className='notification notification-favourite'>
<div className='notification__message'>
<div style={{ position: 'absolute', 'left': '-26px'}}>
<i className='fa fa-fw fa-star' style={{ color: '#ca8f04' }} />
@ -57,7 +57,7 @@ const Notification = React.createClass({
renderReblog (notification, link) {
return (
<div className='notification'>
<div className='notification notification-reblog'>
<div className='notification__message'>
<div style={{ position: 'absolute', 'left': '-26px'}}>
<i className='fa fa-fw fa-retweet' />
@ -76,17 +76,17 @@ const Notification = React.createClass({
const account = notification.get('account');
const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
const link = <Permalink className='notification__display-name' style={linkStyle} href={account.get('url')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
const link = <Permalink className='notification__display-name' style={linkStyle} href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
switch(notification.get('type')) {
case 'follow':
return this.renderFollow(account, link);
case 'mention':
return this.renderMention(notification);
case 'favourite':
return this.renderFavourite(notification, link);
case 'reblog':
return this.renderReblog(notification, link);
case 'follow':
return this.renderFollow(account, link);
case 'mention':
return this.renderMention(notification);
case 'favourite':
return this.renderFavourite(notification, link);
case 'reblog':
return this.renderReblog(notification, link);
}
}

View File

@ -37,8 +37,8 @@ const ActionBar = React.createClass({
this.props.onReply(this.props.status);
},
handleReblogClick () {
this.props.onReblog(this.props.status);
handleReblogClick (e) {
this.props.onReblog(this.props.status, e);
},
handleFavouriteClick () {

View File

@ -54,7 +54,7 @@ const DetailedStatus = React.createClass({
return (
<div style={{ padding: '14px 10px' }} className='detailed-status'>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}>
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={status.getIn(['account', 'avatar'])} size={48} /></div>
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /></div>
<DisplayName account={status.get('account')} />
</a>

View File

@ -38,7 +38,8 @@ const makeMapStateToProps = () => {
status: getStatus(state, Number(props.params.statusId)),
ancestorsIds: state.getIn(['timelines', 'ancestors', Number(props.params.statusId)]),
descendantsIds: state.getIn(['timelines', 'descendants', Number(props.params.statusId)]),
me: state.getIn(['meta', 'me'])
me: state.getIn(['meta', 'me']),
boostModal: state.getIn(['meta', 'boost_modal'])
});
return mapStateToProps;
@ -55,7 +56,8 @@ const Status = React.createClass({
status: ImmutablePropTypes.map,
ancestorsIds: ImmutablePropTypes.list,
descendantsIds: ImmutablePropTypes.list,
me: React.PropTypes.number
me: React.PropTypes.number,
boostModal: React.PropTypes.bool
},
mixins: [PureRenderMixin],
@ -82,11 +84,19 @@ const Status = React.createClass({
this.props.dispatch(replyCompose(status, this.context.router));
},
handleReblogClick (status) {
handleModalReblog (status) {
this.props.dispatch(reblog(status));
},
handleReblogClick (status, e) {
if (status.get('reblogged')) {
this.props.dispatch(unreblog(status));
} else {
this.props.dispatch(reblog(status));
if (e.altKey || !this.props.boostModal) {
this.handleModalReblog(status);
} else {
this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog }));
}
}
},

View File

@ -0,0 +1,77 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from '../../../components/icon_button';
import Button from '../../../components/button';
import StatusContent from '../../../components/status_content';
import Avatar from '../../../components/avatar';
import RelativeTimestamp from '../../../components/relative_timestamp';
import DisplayName from '../../../components/display_name';
const messages = defineMessages({
reblog: { id: 'status.reblog', defaultMessage: 'Boost' }
});
const BoostModal = React.createClass({
contextTypes: {
router: React.PropTypes.object
},
propTypes: {
status: ImmutablePropTypes.map.isRequired,
onReblog: React.PropTypes.func.isRequired,
onClose: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
handleReblog() {
this.props.onReblog(this.props.status);
this.props.onClose();
},
handleAccountClick (e) {
if (e.button === 0) {
e.preventDefault();
this.props.onClose();
this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
}
},
render () {
const { status, intl, onClose } = this.props;
return (
<div className='modal-root__modal boost-modal'>
<div className='boost-modal__container'>
<div className='status light'>
<div style={{ fontSize: '15px' }}>
<div style={{ float: 'right', fontSize: '14px' }}>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
</div>
<a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px' }}>
<div className='status__avatar' style={{ position: 'absolute', left: '10px', top: '10px', width: '48px', height: '48px' }}>
<Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />
</div>
<DisplayName account={status.get('account')} />
</a>
</div>
<StatusContent status={status} />
</div>
</div>
<div className='boost-modal__action-bar'>
<div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Alt + <i className='fa fa-retweet' /></span> }} /></div>
<Button text={intl.formatMessage(messages.reblog)} onClick={this.handleReblog} />
</div>
</div>
);
}
});
export default injectIntl(BoostModal);

View File

@ -41,8 +41,11 @@ const Column = React.createClass({
mixins: [PureRenderMixin],
handleHeaderClick () {
let node = ReactDOM.findDOMNode(this);
this._interruptScrollAnimation = scrollTop(node.querySelector('.scrollable'));
const scrollable = ReactDOM.findDOMNode(this).querySelector('.scrollable');
if (!scrollable) {
return;
}
this._interruptScrollAnimation = scrollTop(scrollable);
},
handleWheel () {

View File

@ -1,9 +1,11 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import MediaModal from './media_modal';
import BoostModal from './boost_modal';
import { TransitionMotion, spring } from 'react-motion';
const MODAL_COMPONENTS = {
'MEDIA': MediaModal
'MEDIA': MediaModal,
'BOOST': BoostModal
};
const ModalRoot = React.createClass({

View File

@ -0,0 +1,33 @@
import Link from 'http-link-header';
import querystring from 'querystring';
Link.parseAttrs = (link, parts) => {
let match = null
let attr = ''
let value = ''
let attrs = ''
let uriAttrs = /<(.*)>;\s*(.*)/gi.exec(parts)
if(uriAttrs) {
attrs = uriAttrs[2]
link = Link.parseParams(link, uriAttrs[1])
}
while(match = Link.attrPattern.exec(attrs)) {
attr = match[1].toLowerCase()
value = match[4] || match[3] || match[2]
if( /\*$/.test(attr)) {
Link.setAttr(link, attr, Link.parseExtendedValue(value))
} else if(/%/.test(value)) {
Link.setAttr(link, attr, querystring.decode(value))
} else {
Link.setAttr(link, attr, value)
}
}
return link
};
export default Link;

View File

@ -12,10 +12,12 @@ const fr = {
"status.sensitive_toggle": "Cliquer pour dévoiler",
"status.show_more": "Déplier",
"status.show_less": "Replier",
"status.open": "Déplier ce status",
"status.open": "Déplier ce statut",
"status.report": "Signaler @{name}",
"status.load_more": "Charger plus",
"status.media_hidden": "Média caché",
"video_player.toggle_sound": "Mettre/Couper le son",
"video_player.toggle_visible": "Afficher/Cacher la vidéo",
"account.mention": "Mentionner",
"account.edit_profile": "Modifier le profil",
"account.unblock": "Débloquer",
@ -42,16 +44,25 @@ const fr = {
"column.notifications": "Notifications",
"column.blocks": "Utilisateurs bloqués",
"column.favourites": "Favoris",
"column.follow_requests": "Demandes de suivi",
"empty_column.notifications": "Vous navez pas encore de notification. Interagissez avec dautres utilisateurs⋅trices pour débuter la conversation.",
"empty_column.public": "Il n'y a rien ici ! Écrivez quelque chose publiquement, ou bien suivez manuellement des utilisateurs d'autres instances pour remplir le fil public.",
"empty_column.home": "Vous ne suivez encore personne. Visitez {public} ou bien utilisez la recherche pour vous connecter à d'autres utilisateurs.",
"empty_column.home.public_timeline": "le fil public",
"empty_column.community": "Le fil public local est vide. Écrivez-donc quelque chose pour le remplir !",
"empty_column.hashtag": "Il n'y a encore aucun contenu relatif à ce hashtag",
"tabs_bar.compose": "Composer",
"tabs_bar.home": "Accueil",
"tabs_bar.mentions": "Mentions",
"tabs_bar.public": "Fil public global",
"tabs_bar.notifications": "Notifications",
"tabs_bar.local_timeline": "Fil public local",
"tabs_bar.federated_timeline": "Fil public global",
"compose_form.placeholder": "Quavez-vous en tête ?",
"compose_form.publish": "Pouet",
"compose_form.sensitive": "Marquer le média comme délicat",
"compose_form.spoiler": "Masquer le texte derrière un avertissement",
"compose_form.spoiler_placeholder": "Avertissement",
"compose_form.private": "Rendre privé",
"compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ? Les statuts privés ne fonctionnent que sur les instances de Mastodons. Si {domains} {domainsCount, plural, one {n'est pas une instance de Mastodon} other {ne sont pas des instances de Mastodon}}, il n'y aura aucune indication que votre statut est privé, et il pourrait être partagé ou rendu visible d'une autre manière à d'autres personnes imprévues",
"compose_form.unlisted": "Ne pas afficher dans les fils publics",
@ -64,23 +75,31 @@ const fr = {
"navigation_bar.favourites": "Favoris",
"navigation_bar.info": "Plus d'informations",
"navigation_bar.logout": "Déconnexion",
"navigation_bar.follow_requests": "Demandes de suivi",
"reply_indicator.cancel": "Annuler",
"search.placeholder": "Chercher",
"search.placeholder": "Rechercher",
"search.account": "Compte",
"search.hashtag": "Mot-clé",
"search_results.total": "{count} {count, plural, one {résultat} other {résultats}}",
"search.status_by": "Statuts de {name}",
"upload_button.label": "Joindre un média",
"upload_form.undo": "Annuler",
"upload_progress.label": "Envoi en cours…",
"upload_area.title": "Glissez et déposez pour envoyer",
"notification.follow": "{name} vous suit.",
"notification.favourite": "{name} a ajouté à ses favoris :",
"notification.reblog": "{name} a partagé votre statut :",
"notification.mention": "{name} vous a mentionné⋅e :",
"notifications.column_settings.alert": "Notifications locales",
"notifications.column_settings.show": "Afficher dans la colonne",
"notifications.column_settings.sound": "Émettre un son",
"notifications.column_settings.follow": "Nouveaux abonnés :",
"notifications.column_settings.favourite": "Favoris :",
"notifications.column_settings.mention": "Mentions :",
"notifications.column_settings.reblog": "Partages :",
"notifications.clear": "Nettoyer",
"notifications.clear_confirmation": "Voulez-vous vraiment nettoyer toutes vos notifications ?",
"notifications.settings": "Paramètres de la colonne",
"privacy.public.short": "Public",
"privacy.public.long": "Afficher dans les fils publics",
"privacy.unlisted.short": "Non-listé",
@ -90,6 +109,20 @@ const fr = {
"privacy.direct.short": "Direct",
"privacy.direct.long": "Nafficher que pour les personnes mentionné⋅e⋅s",
"privacy.change": "Ajuster la confidentialité du message",
"media_gallery.toggle_visible": "Modifier la visibilité",
"missing_indicator.label": "Non trouvé",
"follow_request.authorize": "Autoriser",
"follow_request.reject": "Rejeter",
"home.settings": "Paramètres de la colonne",
"home.column_settings.basic": "Basique",
"home.column_settings.show_reblogs": "Afficher les partages",
"home.column_settings.show_replies": "Afficher les réponses",
"home.column_settings.advanced": "Avancé",
"home.column_settings.filter_regex": "Filtrer avec une expression rationnelle",
"report.heading": "Nouveau signalement",
"report.placeholder": "Commentaires additionnels",
"report.submit": "Envoyer",
"report.target": "Signalement"
};
export default fr;

View File

@ -7,6 +7,9 @@ import pt from './pt';
import uk from './uk';
import fi from './fi';
import eo from './eo';
import ru from './ru';
import ja from './ja';
const locales = {
en,
@ -17,7 +20,10 @@ const locales = {
pt,
uk,
fi,
eo
eo,
ru,
ja
};
export default function getMessagesForLocale (locale) {

View File

@ -0,0 +1,72 @@
const ja = {
"column_back_button.label": "戻る",
"lightbox.close": "閉じる",
"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.show_less": "隠す",
"status.open": "Expand this status",
"status.report": "@{name}さんを報告",
"video_player.toggle_sound": "音切り替え",
"account.mention": "@{name}さんに返信",
"account.edit_profile": "プロフィール返信",
"account.unblock": "@{name}さんのブロックを解除",
"account.unfollow": "フォロー解除",
"account.block": "@{name}さんをブロック",
"account.follow": "フォロー",
"account.posts": "投稿",
"account.follows": "フォロー",
"account.followers": "フォロワー",
"account.follows_you": "フォロー中",
"account.requested": "承認待ち",
"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.public": "連邦タイムライン",
"column.notifications": "通知",
"tabs_bar.compose": "Compose",
"tabs_bar.home": "ホーム",
"tabs_bar.mentions": "返信",
"tabs_bar.public": "連邦タイムライン",
"tabs_bar.notifications": "通知",
"compose_form.placeholder": "今なにしてる?",
"compose_form.publish": "トゥート",
"compose_form.sensitive": "メディアを不適切なコンテンツとしてマークする",
"compose_form.spoiler": "テキストを隠す",
"compose_form.private": "非公開にする",
"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.unlisted": "公開タイムラインに表示しない",
"navigation_bar.edit_profile": "プロフィール編集",
"navigation_bar.preferences": "ユーザー設定",
"navigation_bar.community_timeline": "ローカルタイムライン",
"navigation_bar.public_timeline": "連邦タイムライン",
"navigation_bar.logout": "ログアウト",
"reply_indicator.cancel": "キャンセル",
"search.placeholder": "検索",
"search.account": "アカウント",
"search.hashtag": "ハッシュタグ",
"upload_button.label": "メディアを追加",
"upload_form.undo": "やり直す",
"notification.follow": "{name}さんにフォローされました",
"notification.favourite": "{name}さんがあなたのトゥートをいいねしました",
"notification.reblog": "{name}さんがあなたのトゥートをブーストしました",
"notification.mention": "{name}さんがあなたに返信しました",
"notifications.column_settings.alert": "デスクトップ通知",
"notifications.column_settings.show": "表示項目",
"notifications.column_settings.follow": "新しいフォロワー:",
"notifications.column_settings.favourite": "いいね:",
"notifications.column_settings.mention": "返信:",
"notifications.column_settings.reblog": "ブースト:",
};
export default ja;

View File

@ -0,0 +1,68 @@
const ru = {
"column_back_button.label": "Назад",
"lightbox.close": "Закрыть",
"loading_indicator.label": "Загрузка...",
"status.mention": "Упомянуть @{name}",
"status.delete": "Удалить",
"status.reply": "Ответить",
"status.reblog": "Продвинуть",
"status.favourite": "Нравится",
"status.reblogged_by": "{name} продвинул(а)",
"status.sensitive_warning": "Чувствительный контент",
"status.sensitive_toggle": "Нажмите для просмотра",
"video_player.toggle_sound": "Вкл./выкл. звук",
"account.mention": "Упомянуть @{name}",
"account.edit_profile": "Изменить профиль",
"account.unblock": "Разблокировать @{name}",
"account.unfollow": "Отписаться",
"account.block": "Блокировать @{name}",
"account.follow": "Подписаться",
"account.posts": "Посты",
"account.follows": "Подписки",
"account.followers": "Подписчики",
"account.follows_you": "Подписан(а) на Вас",
"account.requested": "Ожидает подтверждения",
"getting_started.heading": "Добро пожаловать",
"getting_started.about_addressing": "Вы можете подписаться на человека, зная имя пользователя и домен, на котором он находится, введя e-mail-подобный адрес в форму поиска.",
"getting_started.about_shortcuts": "Если пользователь находится на одном с Вами домене, можно использовать только имя. То же правило применимо к упоминанию пользователей в статусах.",
"getting_started.open_source_notice": "Mastodon - программа с открытым исходным кодом. Вы можете помочь проекту или сообщить о проблемах на GitHub по адресу {github}. {apps}.",
"column.home": "Главная",
"column.community": "Локальная лента",
"column.public": "Глобальная лента",
"column.notifications": "Уведомления",
"tabs_bar.compose": "Написать",
"tabs_bar.home": "Главная",
"tabs_bar.mentions": "Упоминания",
"tabs_bar.public": "Глобальная лента",
"tabs_bar.notifications": "Уведомления",
"compose_form.placeholder": "О чем Вы думаете?",
"compose_form.publish": "Протрубить",
"compose_form.sensitive": "Отметить как чувствительный контент",
"compose_form.spoiler": "Скрыть текст за предупреждением",
"compose_form.private": "Отметить как приватное",
"compose_form.privacy_disclaimer": "Ваш приватный статус будет доставлен упомянутым пользователям на доменах {domains}. Доверяете ли вы {domainsCount, plural, one {этому серверу} other {этим серверам}}? Приватность постов работает только на узлах Mastodon. Если {domains} {domainsCount, plural, one {не является узлом Mastodon} other {не являются узлами Mastodon}}, приватность поста не будет указана, и он может оказаться продвинут или иным образом показан не обозначенным Вами пользователям.",
"compose_form.unlisted": "Не отображать в публичных лентах",
"navigation_bar.edit_profile": "Изменить профиль",
"navigation_bar.preferences": "Опции",
"navigation_bar.community_timeline": "Локальная лента",
"navigation_bar.public_timeline": "Глобальная лента",
"navigation_bar.logout": "Выйти",
"reply_indicator.cancel": "Отмена",
"search.placeholder": "Поиск",
"search.account": "Аккаунт",
"search.hashtag": "Хэштег",
"upload_button.label": "Добавить медиаконтент",
"upload_form.undo": "Отменить",
"notification.follow": "{name} подписался(-лась) на Вас",
"notification.favourite": "{name} понравился Ваш статус",
"notification.reblog": "{name} продвинул(а) Ваш статус",
"notification.mention": "{name} упомянул(а) Вас",
"notifications.column_settings.alert": "Десктопные уведомления",
"notifications.column_settings.show": "Показывать в колонке",
"notifications.column_settings.follow": "Новые подписчики:",
"notifications.column_settings.favourite": "Нравится:",
"notifications.column_settings.mention": "Упоминания:",
"notifications.column_settings.reblog": "Продвижения:",
};
export default ru;

View File

@ -72,6 +72,7 @@
position: relative;
z-index: 2;
flex-direction: row;
background: rgba(0,0,0,0.5);
}
.details-counters {
@ -83,7 +84,7 @@
.counter {
width: 80px;
color: $color3;
padding: 0 10px;
padding: 5px 10px 0px;
margin-bottom: 10px;
border-right: 1px solid $color3;
cursor: default;
@ -148,7 +149,7 @@
order: 1;
}
@media screen and (max-width: 360px) {
@media screen and (max-width: 480px) {
.details {
display: block;
}
@ -173,7 +174,7 @@
text-align: center;
overflow: hidden;
a, .current, .next_page, .previous_page, .gap {
a, .current, .page, .gap {
font-size: 14px;
color: $color5;
font-weight: 500;
@ -193,12 +194,12 @@
cursor: default;
}
.previous_page, .next_page {
.prev, .next {
text-transform: uppercase;
color: $color2;
}
.previous_page {
.prev {
float: left;
padding-left: 0;
@ -208,7 +209,7 @@
}
}
.next_page {
.next {
float: right;
padding-right: 0;
@ -226,11 +227,11 @@
@media screen and (max-width: 360px) {
padding: 30px 20px;
a, .current, .next_page, .previous_page, .gap {
a, .current, .next, .prev, .gap {
display: none;
}
.next_page, .previous_page {
.next, .prev {
display: inline-block;
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,7 @@
@import 'variables';
.app-body{
-ms-overflow-style: -ms-autohiding-scrollbar;
-ms-overflow-style: -ms-autohiding-scrollbar;
}
.button {
@ -49,6 +49,22 @@
}
}
.column-icon-clear {
font-size: 16px;
padding: 15px;
position: absolute;
right: 48px;
top: 0;
cursor: pointer;
z-index: 2;
}
@media screen and (min-width: 1024px) {
.column-icon-clear {
top: 10px;
}
}
.icon-button {
display: inline-block;
padding: 0;
@ -149,6 +165,14 @@
}
}
.avatar {
border-radius: 4px;
background: transparent no-repeat;
background-position: 50%;
background-clip: padding-box;
position: relative;
}
.lightbox .icon-button {
color: $color1;
}
@ -325,6 +349,43 @@ a.status__content__spoiler-link {
.status__display-name {
color: lighten($color1, 26%);
}
&.light {
.status__relative-time {
color: $color3;
}
.status__display-name {
color: $color1;
}
.display-name {
strong {
color: $color1;
}
span {
color: $color3;
}
}
.status__content {
color: $color1;
a {
color: $color4;
}
a.status__content__spoiler-link {
color: $color5;
background: $color3;
&:hover {
background: lighten($color3, 8%);
}
}
}
}
}
.status-check-box {
@ -643,6 +704,12 @@ a.status__content__spoiler-link {
left: 8px;
}
&.light {
&:before {
border-color: transparent transparent $color5 transparent;
}
}
& > ul {
list-style: none;
background: $color2;
@ -660,7 +727,7 @@ a.status__content__spoiler-link {
}
& > .emoji-dialog {
left: -249px;
left: -210px;
}
}
@ -714,15 +781,7 @@ a.status__content__spoiler-link {
@media screen and (min-width: 360px) {
.columns-area {
margin: 0;
}
.column:first-child, .drawer:first-child {
margin-left: 0;
}
.column:last-child, .drawer:last-child {
margin-right: 0;
padding: 10px;
}
}
@ -730,9 +789,12 @@ a.status__content__spoiler-link {
width: 330px;
position: relative;
box-sizing: border-box;
background: $color1;
display: flex;
flex-direction: column;
> .scrollable {
background: $color1;
}
}
.ui {
@ -764,6 +826,58 @@ a.status__content__spoiler-link {
border-bottom: 2px solid transparent;
}
.column, .drawer {
flex: 1 1 100%;
overflow: hidden;
}
@media screen and (min-width: 360px) {
.tabs-bar {
margin: 10px;
margin-bottom: 0;
}
.search {
margin-bottom: 10px;
}
}
@media screen and (max-width: 1024px) {
.column, .drawer {
width: 100%;
padding: 0;
}
.columns-area {
flex-direction: column;
}
.search__input, .autosuggest-textarea__textarea {
font-size: 16px;
}
}
@media screen and (min-width: 1024px) {
.columns-area {
padding: 0;
}
.column, .drawer {
flex: 0 0 auto;
padding: 10px;
padding-left: 5px;
padding-right: 5px;
&:first-child {
padding-left: 10px;
}
&:last-child {
padding-right: 10px;
}
}
}
@media screen and (min-width: 2560px) {
.columns-area {
justify-content: center;
@ -823,38 +937,6 @@ a.status__content__spoiler-link {
}
}
.column, .drawer {
margin: 10px;
margin-left: 5px;
margin-right: 5px;
flex: 0 0 auto;
overflow: hidden;
}
.column:first-child, .drawer:first-child {
margin-left: 10px;
}
.column:last-child, .drawer:last-child {
margin-right: 10px;
}
@media screen and (max-width: 1024px) {
.column, .drawer {
width: 100%;
margin: 0;
flex: 1 1 100%;
}
.columns-area {
flex-direction: column;
}
.search__input, .autosuggest-textarea__textarea {
font-size: 16px;
}
}
.tabs-bar {
display: flex;
background: lighten($color1, 8%);
@ -865,17 +947,18 @@ a.status__content__spoiler-link {
.tabs-bar__link {
display: block;
flex: 1 1 auto;
padding: 10px 5px;
padding: 15px 10px;
color: $color5;
text-decoration: none;
text-align: center;
font-size:12px;
font-size: 14px;
font-weight: 500;
border-bottom: 2px solid lighten($color1, 8%);
transition: all 200ms linear;
.fa {
font-weight: 400;
font-size: 16px;
}
&.active {
@ -889,37 +972,13 @@ a.status__content__spoiler-link {
}
span {
margin-left: 5px;
display: none;
}
}
@media screen and (min-width: 360px) {
.columns-area {
margin: 10px;
}
.tabs-bar {
margin: 10px;
margin-bottom: 0;
}
.search {
margin-bottom: 10px;
}
}
@media screen and (min-width: 1024px) {
.columns-area {
margin: 0;
}
}
@media screen and (min-width: 600px) {
.tabs-bar__link {
.fa {
margin-right: 5px;
}
span {
display: inline;
}
@ -1199,7 +1258,7 @@ a.status__content__spoiler-link {
@import 'boost';
button i.fa-retweet {
button.icon-button i.fa-retweet {
height: 19px;
width: 22px;
background-position: 0 0;
@ -1211,7 +1270,7 @@ button i.fa-retweet {
}
}
button.active i.fa-retweet {
button.icon-button.active i.fa-retweet {
transition-duration: 0.9s;
background-position: 0 100%;
}
@ -1381,12 +1440,15 @@ button.active i.fa-retweet {
.empty-column-indicator {
color: lighten($color1, 20%);
background: $color1;
text-align: center;
padding: 20px;
padding-top: 100px;
font-size: 15px;
font-weight: 400;
cursor: default;
display: flex;
flex: 1 1 auto;
align-items: center;
a {
color: $color4;
@ -1412,22 +1474,23 @@ button.active i.fa-retweet {
}
.emoji-dialog {
width: 280px;
height: 220px;
background: $color2;
width: 245px;
height: 270px;
background: $color5;
box-sizing: border-box;
border-radius: 2px;
border-radius: 4px;
overflow: hidden;
position: relative;
box-shadow: 0 0 15px rgba($color8, 0.4);
box-shadow: 0 0 8px rgba($color8, 0.2);
.emojione {
margin: 0;
width: 100%;
height: auto;
}
.emoji-dialog-header {
padding: 0 10px;
background-color: $color3;
ul {
padding: 0;
@ -1438,18 +1501,29 @@ button.active i.fa-retweet {
li {
display: inline-block;
box-sizing: border-box;
height: 42px;
padding: 9px 5px;
padding: 10px 5px;
cursor: pointer;
border-bottom: 2px solid transparent;
.emoji {
width: 18px;
height: 18px;
}
img, svg {
width: 22px;
height: 22px;
width: 18px;
height: 18px;
filter: grayscale(100%);
}
&:hover {
img, svg {
filter: grayscale(0);
}
}
&.active {
background: lighten($color3, 6%);
border-bottom-color: $color4;
img, svg {
filter: grayscale(0);
@ -1473,7 +1547,7 @@ button.active i.fa-retweet {
.emoji-category-header {
box-sizing: border-box;
overflow-y: hidden;
padding: 8px 16px 0;
padding: 10px 8px 10px 16px;
display: table;
> * {
@ -1483,10 +1557,10 @@ button.active i.fa-retweet {
}
.emoji-category-title {
font-size: 14px;
font-family: sans-serif;
font-weight: normal;
color: $color1;
font-size: 12px;
text-transform: uppercase;
font-weight: 500;
color: darken($color2, 18%);
cursor: default;
}
@ -1526,7 +1600,7 @@ button.active i.fa-retweet {
width: 7px;
height: 7px;
border-radius: 10px;
border: 2px solid $color1;
border: 2px solid $color5;
top: 2px;
left: 2px;
}
@ -1534,14 +1608,20 @@ button.active i.fa-retweet {
}
.emoji-search-wrapper {
padding: 6px 16px;
padding: 10px;
border-bottom: 1px solid lighten($color2, 4%);
}
.emoji-search {
font-size: 12px;
padding: 6px 4px;
font-size: 14px;
font-weight: 400;
padding: 7px 9px;
font-family: inherit;
display: block;
width: 100%;
border: 1px solid #ddd;
background: rgba($color2, 0.3);
color: darken($color2, 18%);
border: 1px solid $color2;
border-radius: 4px;
}
@ -1554,11 +1634,21 @@ button.active i.fa-retweet {
}
.emoji-search-wrapper + .emoji-categories-wrapper {
top: 83px;
top: 93px;
}
.emoji-row .emoji:hover {
background: lighten($color2, 3%);
.emoji-row .emoji {
img, svg {
transition: transform 60ms ease-in-out;
}
&:hover {
background: lighten($color2, 3%);
img, svg {
transform: translateZ(0) scale(1.2);
}
}
}
.emoji {
@ -1915,3 +2005,41 @@ button.active i.fa-retweet {
max-height: 80vh;
}
}
.boost-modal {
background: lighten($color2, 8%);
color: $color1;
border-radius: 8px;
overflow: hidden;
max-width: 90vw;
width: 480px;
position: relative;
flex-direction: column;
}
.boost-modal__container {
padding: 10px;
.status {
user-select: text;
border-bottom: 0;
}
}
.boost-modal__action-bar {
display: flex;
background: $color2;
padding: 10px;
line-height: 36px;
& > div {
flex: 1 1 auto;
text-align: right;
color: lighten($color1, 33%);
padding-right: 10px;
}
.button {
flex: 0 0 auto;
}
}

View File

@ -35,11 +35,11 @@ class AccountsController < ApplicationController
end
def followers
@followers = @account.followers.order('follows.created_at desc').paginate(page: params[:page], per_page: 12)
@followers = @account.followers.order('follows.created_at desc').page(params[:page]).per(12)
end
def following
@following = @account.following.order('follows.created_at desc').paginate(page: params[:page], per_page: 12)
@following = @account.following.order('follows.created_at desc').page(params[:page]).per(12)
end
private
@ -53,7 +53,7 @@ class AccountsController < ApplicationController
end
def webfinger_account_url
webfinger_url(resource: "acct:#{@account.acct}@#{Rails.configuration.x.local_domain}")
webfinger_url(resource: @account.to_webfinger_s)
end
def check_account_suspension

View File

@ -1,51 +1,50 @@
# frozen_string_literal: true
class Admin::AccountsController < ApplicationController
before_action :require_admin!
before_action :set_account, except: :index
module Admin
class AccountsController < BaseController
before_action :set_account, except: :index
layout 'admin'
def index
@accounts = Account.alphabetic.page(params[:page])
def index
@accounts = Account.alphabetic.paginate(page: params[:page], per_page: 40)
@accounts = @accounts.local if params[:local].present?
@accounts = @accounts.remote if params[:remote].present?
@accounts = @accounts.where(domain: params[:by_domain]) if params[:by_domain].present?
@accounts = @accounts.silenced if params[:silenced].present?
@accounts = @accounts.recent if params[:recent].present?
@accounts = @accounts.suspended if params[:suspended].present?
end
@accounts = @accounts.local if params[:local].present?
@accounts = @accounts.remote if params[:remote].present?
@accounts = @accounts.where(domain: params[:by_domain]) if params[:by_domain].present?
@accounts = @accounts.silenced if params[:silenced].present?
@accounts = @accounts.recent if params[:recent].present?
@accounts = @accounts.suspended if params[:suspended].present?
end
def show; end
def show; end
def suspend
Admin::SuspensionWorker.perform_async(@account.id)
redirect_to admin_accounts_path
end
def suspend
Admin::SuspensionWorker.perform_async(@account.id)
redirect_to admin_accounts_path
end
def unsuspend
@account.update(suspended: false)
redirect_to admin_accounts_path
end
def unsuspend
@account.update(suspended: false)
redirect_to admin_accounts_path
end
def silence
@account.update(silenced: true)
redirect_to admin_accounts_path
end
def silence
@account.update(silenced: true)
redirect_to admin_accounts_path
end
def unsilence
@account.update(silenced: false)
redirect_to admin_accounts_path
end
def unsilence
@account.update(silenced: false)
redirect_to admin_accounts_path
end
private
private
def set_account
@account = Account.find(params[:id])
end
def set_account
@account = Account.find(params[:id])
end
def account_params
params.require(:account).permit(:silenced, :suspended)
def account_params
params.require(:account).permit(:silenced, :suspended)
end
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
module Admin
class BaseController < ApplicationController
before_action :require_admin!
layout 'admin'
end
end

View File

@ -1,32 +1,30 @@
# frozen_string_literal: true
class Admin::DomainBlocksController < ApplicationController
before_action :require_admin!
module Admin
class DomainBlocksController < BaseController
def index
@blocks = DomainBlock.page(params[:page])
end
layout 'admin'
def new
@domain_block = DomainBlock.new
end
def index
@blocks = DomainBlock.paginate(page: params[:page], per_page: 40)
end
def create
@domain_block = DomainBlock.new(resource_params)
def new
@domain_block = DomainBlock.new
end
if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id)
redirect_to admin_domain_blocks_path, notice: 'Domain block is now being processed'
else
render action: :new
end
end
def create
@domain_block = DomainBlock.new(resource_params)
private
if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id)
redirect_to admin_domain_blocks_path, notice: 'Domain block is now being processed'
else
render action: :new
def resource_params
params.require(:domain_block).permit(:domain, :severity)
end
end
private
def resource_params
params.require(:domain_block).permit(:domain, :severity)
end
end

View File

@ -1,11 +1,9 @@
# frozen_string_literal: true
class Admin::PubsubhubbubController < ApplicationController
before_action :require_admin!
layout 'admin'
def index
@subscriptions = Subscription.order('id desc').includes(:account).paginate(page: params[:page], per_page: 40)
module Admin
class PubsubhubbubController < BaseController
def index
@subscriptions = Subscription.order('id desc').includes(:account).page(params[:page])
end
end
end

View File

@ -1,45 +1,44 @@
# frozen_string_literal: true
class Admin::ReportsController < ApplicationController
before_action :require_admin!
before_action :set_report, except: [:index]
module Admin
class ReportsController < BaseController
before_action :set_report, except: [:index]
layout 'admin'
def index
@reports = Report.includes(:account, :target_account).order('id desc').page(params[:page])
@reports = params[:action_taken].present? ? @reports.resolved : @reports.unresolved
end
def index
@reports = Report.includes(:account, :target_account).order('id desc').paginate(page: params[:page], per_page: 40)
@reports = params[:action_taken].present? ? @reports.resolved : @reports.unresolved
end
def show
@statuses = Status.where(id: @report.status_ids)
end
def show
@statuses = Status.where(id: @report.status_ids)
end
def resolve
@report.update(action_taken: true, action_taken_by_account_id: current_account.id)
redirect_to admin_report_path(@report)
end
def resolve
@report.update(action_taken: true, action_taken_by_account_id: current_account.id)
redirect_to admin_report_path(@report)
end
def suspend
Admin::SuspensionWorker.perform_async(@report.target_account.id)
Report.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 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 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)
end
def remove
RemovalWorker.perform_async(params[:status_id])
redirect_to admin_report_path(@report)
end
private
private
def set_report
@report = Report.find(params[:id])
def set_report
@report = Report.find(params[:id])
end
end
end

View File

@ -1,35 +1,33 @@
# frozen_string_literal: true
class Admin::SettingsController < ApplicationController
before_action :require_admin!
layout 'admin'
def index
@settings = Setting.all_as_records
end
def update
@setting = Setting.where(var: params[:id]).first_or_initialize(var: params[:id])
value = settings_params[:value]
# Special cases
value = value == 'true' if @setting.var == 'open_registrations'
if @setting.value != value
@setting.value = value
@setting.save
module Admin
class SettingsController < BaseController
def index
@settings = Setting.all_as_records
end
respond_to do |format|
format.html { redirect_to admin_settings_path }
format.json { respond_with_bip(@setting) }
def update
@setting = Setting.where(var: params[:id]).first_or_initialize(var: params[:id])
value = settings_params[:value]
# Special cases
value = value == 'true' if @setting.var == 'open_registrations'
if @setting.value != value
@setting.value = value
@setting.save
end
respond_to do |format|
format.html { redirect_to admin_settings_path }
format.json { respond_with_bip(@setting) }
end
end
end
private
private
def settings_params
params.require(:setting).permit(:value)
def settings_params
params.require(:setting).permit(:value)
end
end
end

View File

@ -9,7 +9,7 @@ class Api::V1::NotificationsController < ApiController
DEFAULT_NOTIFICATIONS_LIMIT = 15
def index
@notifications = Notification.where(account: current_account).browserable.paginate_by_max_id(limit_param(DEFAULT_NOTIFICATIONS_LIMIT), params[:max_id], params[:since_id])
@notifications = Notification.where(account: current_account).browserable(exclude_types).paginate_by_max_id(limit_param(DEFAULT_NOTIFICATIONS_LIMIT), params[:max_id], params[:since_id])
@notifications = cache_collection(@notifications, Notification)
statuses = @notifications.select { |n| !n.target_status.nil? }.map(&:target_status)
@ -32,7 +32,13 @@ class Api::V1::NotificationsController < ApiController
private
def exclude_types
val = params.permit(exclude_types: [])[:exclude_types] || []
val = [val] unless val.is_a?(Enumerable)
val
end
def pagination_params(core_params)
params.permit(:limit).merge(core_params)
params.permit(:limit, exclude_types: []).merge(core_params)
end
end

View File

@ -7,6 +7,7 @@ class ApiController < ApplicationController
protect_from_forgery with: :null_session
skip_before_action :verify_authenticity_token
skip_before_action :store_current_location
before_action :set_rate_limit_headers

View File

@ -25,7 +25,7 @@ class RemoteFollowController < ApplicationController
session[:remote_follow] = @remote_follow.acct
redirect_to Addressable::Template.new(redirect_url_link.template).expand(uri: "#{@account.username}@#{Rails.configuration.x.local_domain}").to_s
redirect_to Addressable::Template.new(redirect_url_link.template).expand(uri: @account.to_webfinger_s).to_s
else
render :new
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Settings
module Exports
class BlockedAccountsController < ApplicationController
before_action :authenticate_user!
def index
export_data = Export.new(current_account.blocking).to_csv
respond_to do |format|
format.csv { send_data export_data, filename: 'blocking.csv' }
end
end
end
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Settings
module Exports
class FollowingAccountsController < ApplicationController
before_action :authenticate_user!
def index
export_data = Export.new(current_account.following).to_csv
respond_to do |format|
format.csv { send_data export_data, filename: 'following.csv' }
end
end
end
end
end

View File

@ -1,46 +1,13 @@
# frozen_string_literal: true
require 'csv'
class Settings::ExportsController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_account
def show
@total_storage = current_account.media_attachments.sum(:file_file_size)
@total_follows = current_account.following.count
@total_blocks = current_account.blocking.count
end
def download_following_list
@accounts = current_account.following
respond_to do |format|
format.csv { render text: accounts_list_to_csv(@accounts) }
end
end
def download_blocking_list
@accounts = current_account.blocking
respond_to do |format|
format.csv { render text: accounts_list_to_csv(@accounts) }
end
end
private
def set_account
@account = current_user.account
end
def accounts_list_to_csv(list)
CSV.generate do |csv|
list.each do |account|
csv << [(account.local? ? "#{account.username}@#{Rails.configuration.x.local_domain}" : account.acct)]
end
end
end
end

View File

@ -23,8 +23,9 @@ class Settings::PreferencesController < ApplicationController
}
current_user.settings['default_privacy'] = user_params[:setting_default_privacy]
current_user.settings['boost_modal'] = user_params[:setting_boost_modal] == '1'
if current_user.update(user_params.except(:notification_emails, :interactions, :setting_default_privacy))
if current_user.update(user_params.except(:notification_emails, :interactions, :setting_default_privacy, :setting_boost_modal))
redirect_to settings_preferences_path, notice: I18n.t('generic.changes_saved_msg')
else
render action: :show
@ -34,6 +35,6 @@ class Settings::PreferencesController < ApplicationController
private
def user_params
params.require(:user).permit(:locale, :setting_default_privacy, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention, :digest], interactions: [:must_be_follower, :must_be_following])
params.require(:user).permit(:locale, :setting_default_privacy, :setting_boost_modal, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention, :digest], interactions: [:must_be_follower, :must_be_following])
end
end

View File

@ -14,7 +14,7 @@ class XrdController < ApplicationController
def webfinger
@account = Account.find_local!(username_from_resource)
@canonical_account_uri = "acct:#{@account.username}@#{Rails.configuration.x.local_domain}"
@canonical_account_uri = @account.to_webfinger_s
@magic_key = pem_to_magic_key(@account.keypair.public_key)
respond_to do |format|

View File

@ -1,12 +0,0 @@
# frozen_string_literal: true
module AccountsHelper
def pagination_options
{
previous_label: safe_join([fa_icon('chevron-left'), t('pagination.prev')], ' '),
next_label: safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '),
inner_window: 1,
outer_window: 0,
}
end
end

View File

@ -1,285 +0,0 @@
# frozen_string_literal: true
module AtomBuilderHelper
def stream_updated_at
if @account.stream_entries.last
(@account.updated_at > @account.stream_entries.last.created_at ? @account.updated_at : @account.stream_entries.last.created_at)
else
@account.updated_at
end
end
def entry(xml, is_root = false, &block)
if is_root
root_tag(xml, :entry, &block)
else
xml.entry(&block)
end
end
def feed(xml, &block)
root_tag(xml, :feed, &block)
end
def unique_id(xml, date, id, type)
xml.id_ TagManager.instance.unique_tag(date, id, type)
end
def simple_id(xml, id)
xml.id_ id
end
def published_at(xml, date)
xml.published date.iso8601
end
def updated_at(xml, date)
xml.updated date.iso8601
end
def verb(xml, verb)
xml['activity'].send('verb', TagManager::VERBS[verb])
end
def content(xml, content, warning = nil)
xml.summary(warning) unless warning.blank?
xml.content({ type: 'html' }, content) unless content.blank?
end
def title(xml, title)
xml.title strip_tags(title || '').truncate(80)
end
def author(xml, &block)
xml.author(&block)
end
def category(xml, term)
xml.category(term: term)
end
def target(xml, &block)
xml['activity'].object(&block)
end
def object_type(xml, type)
xml['activity'].send('object-type', TagManager::TYPES[type])
end
def uri(xml, uri)
xml.uri uri
end
def name(xml, name)
xml.name name
end
def summary(xml, summary)
xml.summary(summary) unless summary.blank?
end
def subtitle(xml, subtitle)
xml.subtitle(subtitle) unless subtitle.blank?
end
def link_alternate(xml, url)
xml.link(rel: 'alternate', type: 'text/html', href: url)
end
def link_self(xml, url)
xml.link(rel: 'self', type: 'application/atom+xml', href: url)
end
def link_next(xml, url)
xml.link(rel: 'next', type: 'application/atom+xml', href: url)
end
def link_hub(xml, url)
xml.link(rel: 'hub', href: url)
end
def link_salmon(xml, url)
xml.link(rel: 'salmon', href: url)
end
def portable_contact(xml, account)
xml['poco'].preferredUsername account.username
xml['poco'].displayName(account.display_name) unless account.display_name.blank?
xml['poco'].note(Formatter.instance.simplified_format(account)) unless account.note.blank?
end
def in_reply_to(xml, uri, url)
xml['thr'].send('in-reply-to', ref: uri, href: url, type: 'text/html')
end
def link_mention(xml, account)
xml.link(:rel => 'mentioned', :href => TagManager.instance.uri_for(account), 'ostatus:object-type' => TagManager::TYPES[:person])
end
def link_enclosure(xml, media)
xml.link(rel: 'enclosure', href: full_asset_url(media.file.url(:original, false)), type: media.file_content_type, length: media.file_file_size)
end
def link_avatar(xml, account)
single_link_avatar(xml, account, :original, 120)
end
def link_header(xml, account)
xml.link('rel' => 'header', 'type' => account.header_content_type, 'media:width' => 700, 'media:height' => 335, 'href' => full_asset_url(account.header.url(:original)))
end
def logo(xml, url)
xml.logo url
end
def email(xml, email)
xml.email email
end
def conditionally_formatted(activity)
if activity.is_a?(Status)
Formatter.instance.format(activity.reblog? ? activity.reblog : activity)
elsif activity.nil?
nil
else
activity.content
end
end
def link_visibility(xml, item)
return unless item.respond_to?(:visibility) && item.public_visibility?
xml.link(:rel => 'mentioned', :href => TagManager::COLLECTIONS[:public], 'ostatus:object-type' => TagManager::TYPES[:collection])
end
def privacy_scope(xml, level)
xml['mastodon'].scope(level)
end
def include_author(xml, account)
simple_id xml, TagManager.instance.uri_for(account)
object_type xml, :person
uri xml, TagManager.instance.uri_for(account)
name xml, account.username
email xml, account.local? ? "#{account.acct}@#{Rails.configuration.x.local_domain}" : account.acct
summary xml, account.note
link_alternate xml, TagManager.instance.url_for(account)
link_avatar xml, account
link_header xml, account
portable_contact xml, account
privacy_scope xml, account.locked? ? :private : :public
end
def rich_content(xml, activity)
if activity.is_a?(Status)
content xml, conditionally_formatted(activity), activity.spoiler_text
else
content xml, conditionally_formatted(activity)
end
end
def include_target(xml, target)
simple_id xml, TagManager.instance.uri_for(target)
if target.object_type == :person
include_author xml, target
else
object_type xml, target.object_type
verb xml, target.verb
title xml, target.title
link_alternate xml, TagManager.instance.url_for(target)
end
# Statuses have content and author
return unless target.is_a?(Status)
rich_content xml, target
verb xml, target.verb
published_at xml, target.created_at
updated_at xml, target.updated_at
author(xml) do
include_author xml, target.account
end
if target.reply?
in_reply_to xml, TagManager.instance.uri_for(target.thread), TagManager.instance.url_for(target.thread)
end
link_visibility xml, target
target.mentions.each do |mention|
link_mention xml, mention.account
end
target.media_attachments.each do |media|
link_enclosure xml, media
end
target.tags.each do |tag|
category xml, tag.name
end
category(xml, 'nsfw') if target.sensitive?
privacy_scope(xml, target.visibility)
end
def include_entry(xml, stream_entry)
unique_id xml, stream_entry.created_at, stream_entry.activity_id, stream_entry.activity_type
published_at xml, stream_entry.created_at
updated_at xml, stream_entry.updated_at
title xml, stream_entry.title
rich_content xml, stream_entry.activity
verb xml, stream_entry.verb
link_self xml, account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom')
link_alternate xml, account_stream_entry_url(stream_entry.account, stream_entry)
object_type xml, stream_entry.object_type
# Comments need thread element
if stream_entry.threaded?
in_reply_to xml, TagManager.instance.uri_for(stream_entry.thread), TagManager.instance.url_for(stream_entry.thread)
end
if stream_entry.targeted?
target(xml) do
include_target(xml, stream_entry.target)
end
end
link_visibility xml, stream_entry.activity
stream_entry.mentions.each do |mentioned|
link_mention xml, mentioned
end
return unless stream_entry.activity.is_a?(Status)
stream_entry.activity.media_attachments.each do |media|
link_enclosure xml, media
end
stream_entry.activity.tags.each do |tag|
category xml, tag.name
end
category(xml, 'nsfw') if stream_entry.activity.sensitive?
privacy_scope(xml, stream_entry.activity.visibility)
end
private
def root_tag(xml, tag, &block)
xml.send(tag, {
'xmlns' => TagManager::XMLNS,
'xmlns:thr' => TagManager::THR_XMLNS,
'xmlns:activity' => TagManager::AS_XMLNS,
'xmlns:poco' => TagManager::POCO_XMLNS,
'xmlns:media' => TagManager::MEDIA_XMLNS,
'xmlns:ostatus' => TagManager::OS_XMLNS,
'xmlns:mastodon' => TagManager::MTDN_XMLNS,
}, &block)
end
def single_link_avatar(xml, account, size, px)
xml.link('rel' => 'avatar', 'type' => account.avatar_content_type, 'media:width' => px, 'media:height' => px, 'href' => full_asset_url(account.avatar.url(size)))
end
end

View File

@ -5,13 +5,16 @@ module SettingsHelper
en: 'English',
de: 'Deutsch',
es: 'Español',
eo: 'Esperanto',
pt: 'Português',
fr: 'Français',
hu: 'Magyar',
uk: 'Українська',
'zh-CN': '简体中文',
fi: 'Suomi',
eo: 'Esperanto',
ru: 'Русский',
ja: '日本語',
}.freeze
def human_locale(locale)

View File

@ -9,10 +9,6 @@ module StreamEntriesHelper
"@#{account.acct}#{@external_links && account.local? ? "@#{Rails.configuration.x.local_domain}" : ''}"
end
def avatar_for_status_url(status)
status.reblog? ? status.reblog.account.avatar.url(:original) : status.account.avatar.url(:original)
end
def entry_classes(status, is_predecessor, is_successor, include_threads)
classes = ['entry']
classes << 'entry-reblog u-repost-of h-cite' if status.reblog?
@ -22,18 +18,6 @@ module StreamEntriesHelper
classes.join(' ')
end
def relative_time(date)
date < 5.days.ago ? date.strftime('%d.%m.%Y') : "#{time_ago_in_words(date)} ago"
end
def reblogged_by_me_class(status)
user_signed_in? && @reblogged.key?(status.id) ? 'reblogged' : ''
end
def favourited_by_me_class(status)
user_signed_in? && @favourited.key?(status.id) ? 'favourited' : ''
end
def rtl?(text)
return false if text.empty?

View File

@ -20,7 +20,7 @@ class AtomSerializer
append_element(author, 'activity:object-type', TagManager::TYPES[:person])
append_element(author, 'uri', uri)
append_element(author, 'name', account.username)
append_element(author, 'email', account.local? ? "#{account.acct}@#{Rails.configuration.x.local_domain}" : account.acct)
append_element(author, 'email', account.local? ? account.local_username_and_domain : account.acct)
append_element(author, 'summary', account.note)
append_element(author, 'link', nil, rel: :alternate, type: 'text/html', href: TagManager.instance.url_for(account))
append_element(author, 'link', nil, rel: :avatar, type: account.avatar_content_type, 'media:width': 120, 'media:height': 120, href: full_asset_url(account.avatar.url(:original)))

View File

@ -66,7 +66,7 @@ class FeedManager
timeline_key = key(:home, into_account.id)
oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
from_account.statuses.select('id').where('id > ?', oldest_home_score).find_in_batches do |statuses|
from_account.statuses.select('id').where('id > ?', oldest_home_score).reorder(nil).find_in_batches do |statuses|
redis.pipelined do
statuses.each do |status|
redis.zrem(timeline_key, status.id)

View File

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

View File

@ -12,12 +12,12 @@ class Account < ApplicationRecord
validates :username, presence: true, uniqueness: { scope: :domain, case_sensitive: true }, unless: 'local?'
# Avatar upload
has_attached_file :avatar, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' }
has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-quality 80 -strip' }
validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES
validates_attachment_size :avatar, less_than: 2.megabytes
# Header upload
has_attached_file :header, styles: { original: '700x335#' }, convert_options: { all: '-quality 80 -strip' }
has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-quality 80 -strip' }
validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES
validates_attachment_size :header, less_than: 2.megabytes
@ -120,6 +120,14 @@ class Account < ApplicationRecord
local? ? username : "#{username}@#{domain}"
end
def local_username_and_domain
"#{username}@#{Rails.configuration.x.local_domain}"
end
def to_webfinger_s
"acct:#{local_username_and_domain}"
end
def subscribed?
!subscription_expires_at.blank?
end
@ -150,6 +158,22 @@ class Account < ApplicationRecord
save!
end
def avatar_original_url
avatar.url(:original)
end
def avatar_static_url
avatar_content_type == 'image/gif' ? avatar.url(:static) : avatar_original_url
end
def header_original_url
header.url(:original)
end
def header_static_url
header_content_type == 'image/gif' ? header.url(:static) : header_original_url
end
def avatar_remote_url=(url)
parsed_url = URI.parse(url)
@ -284,6 +308,18 @@ class Account < ApplicationRecord
def follow_mapping(query, field)
query.pluck(field).inject({}) { |mapping, id| mapping[id] = true; mapping }
end
def avatar_styles(file)
styles = { original: '120x120#' }
styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
styles
end
def header_styles(file)
styles = { original: '700x335#' }
styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
styles
end
end
before_create do

18
app/models/export.rb Normal file
View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'csv'
class Export
attr_reader :accounts
def initialize(accounts)
@accounts = accounts
end
def to_csv
CSV.generate do |csv|
accounts.each do |account|
csv << [(account.local? ? account.local_username_and_domain : account.acct)]
end
end
end
end

View File

@ -16,10 +16,17 @@ class Notification < ApplicationRecord
validates :account_id, uniqueness: { scope: [:activity_type, :activity_id] }
TYPE_CLASS_MAP = {
mention: 'Mention',
reblog: 'Status',
follow: 'Follow',
follow_request: 'FollowRequest',
favourite: 'Favourite',
}.freeze
STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, :tags, mentions: :account, reblog: [:stream_entry, :account, :media_attachments, :tags, mentions: :account]].freeze
scope :cache_ids, -> { select(:id, :updated_at, :activity_type, :activity_id) }
scope :browserable, -> { where.not(activity_type: ['FollowRequest']) }
cache_associated :from_account, status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account
@ -28,12 +35,7 @@ class Notification < ApplicationRecord
end
def type
case activity_type
when 'Status'
:reblog
else
activity_type.underscore.to_sym
end
@type ||= TYPE_CLASS_MAP.invert[activity_type].to_sym
end
def target_status
@ -50,6 +52,11 @@ class Notification < ApplicationRecord
end
class << self
def browserable(types = [])
types.concat([:follow_request])
where.not(activity_type: activity_types_from_types(types))
end
def reload_stale_associations!(cached_items)
account_ids = cached_items.map(&:from_account_id).uniq
accounts = Account.where(id: account_ids).map { |a| [a.id, a] }.to_h
@ -58,6 +65,12 @@ class Notification < ApplicationRecord
item.from_account = accounts[item.from_account_id]
end
end
private
def activity_types_from_types(types)
types.map { |type| TYPE_CLASS_MAP[type.to_sym] }.compact
end
end
after_initialize :set_from_account

View File

@ -26,4 +26,8 @@ class User < ApplicationRecord
def setting_default_privacy
settings.default_privacy || (account.locked? ? 'private' : 'public')
end
def setting_boost_modal
settings.boost_modal
end
end

View File

@ -5,5 +5,4 @@ class BaseService
include ActionView::Helpers::SanitizeHelper
include RoutingHelper
include AtomBuilderHelper
end

View File

@ -35,7 +35,7 @@
.info
= link_to t('auth.login'), new_user_session_path, class: 'webapp-btn'
·
= link_to t('about.other_instances'), 'https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/List-of-Mastodon-instances.md'
= link_to t('about.other_instances'), 'https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md'
·
= link_to t('about.about_this'), about_more_path
@ -79,8 +79,8 @@
.info
= link_to t('about.terms'), terms_path
·
= link_to t('about.apps'), 'https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md'
= link_to t('about.apps'), 'https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md'
·
= link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon'
·
= link_to t('about.other_instances'), 'https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/List-of-Mastodon-instances.md'
= link_to t('about.other_instances'), 'https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md'

View File

@ -9,4 +9,4 @@
- else
= render partial: 'grid_card', collection: @followers, as: :account, cached: true
= will_paginate @followers, pagination_options
= paginate @followers

View File

@ -9,4 +9,4 @@
- else
= render partial: 'grid_card', collection: @following, as: :account, cached: true
= will_paginate @following, pagination_options
= paginate @following

View File

@ -31,4 +31,4 @@
.pagination
- if @statuses.size == 20
= link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), short_account_url(@account, max_id: @statuses.last.id), class: 'next_page', rel: 'next'
= link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), short_account_url(@account, max_id: @statuses.last.id), class: 'next', rel: 'next'

View File

@ -46,4 +46,4 @@
= table_link_to 'globe', 'Public', TagManager.instance.url_for(account)
= table_link_to 'pencil', 'Edit', admin_account_path(account.id)
= will_paginate @accounts, pagination_options
= paginate @accounts

View File

@ -13,5 +13,5 @@
%samp= block.domain
%td= block.severity
= will_paginate @blocks, pagination_options
= paginate @blocks
= link_to 'Add new', new_admin_domain_block_path, class: 'button'

View File

@ -26,4 +26,4 @@
- else
= l subscription.last_successful_delivery_at
= will_paginate @subscriptions, pagination_options
= paginate @subscriptions

View File

@ -29,4 +29,4 @@
%td= truncate(report.comment, length: 30, separator: ' ')
%td= table_link_to 'circle', 'View', admin_report_path(report)
= will_paginate @reports, pagination_options
= paginate @reports

View File

@ -4,8 +4,9 @@ attributes :id, :username, :acct, :display_name, :locked, :created_at
node(:note) { |account| Formatter.instance.simplified_format(account) }
node(:url) { |account| TagManager.instance.url_for(account) }
node(:avatar) { |account| full_asset_url(account.avatar.url(:original)) }
node(:header) { |account| full_asset_url(account.header.url(:original)) }
node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : account.followers_count }
node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : account.following_count }
node(:statuses_count) { |account| defined?(@statuses_counts_map) ? (@statuses_counts_map[account.id] || 0) : account.statuses_count }
node(:avatar) { |account| full_asset_url(account.avatar_original_url) }
node(:avatar_static) { |account| full_asset_url(account.avatar_static_url) }
node(:header) { |account| full_asset_url(account.header_original_url) }
node(:header_static) { |account| full_asset_url(account.header_static_url) }
attributes :followers_count, :following_count, :statuses_count

View File

@ -5,6 +5,7 @@ node(:meta) do
access_token: @token,
locale: I18n.locale,
me: current_account.id,
boost_modal: current_account.user.setting_boost_modal,
}
end

View File

@ -0,0 +1,9 @@
-# Link to the "Next" page
-# available local variables
-# url: url to the next page
-# current_page: a page object for the currently displayed page
-# total_pages: total number of pages
-# per_page: number of items to fetch per page
-# remote: data-remote
%span.next
= link_to_unless current_page.last?, safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), url, rel: 'next', remote: remote

View File

@ -0,0 +1,16 @@
-# The container tag
-# available local variables
-# current_page: a page object for the currently displayed page
-# total_pages: total number of pages
-# per_page: number of items to fetch per page
-# remote: data-remote
-# paginator: the paginator that renders the pagination tags inside
= paginator.render do
%nav.pagination
= prev_page_tag unless current_page.first?
- each_page do |page|
- if page.display_tag?
= page_tag page
- elsif !page.was_truncated?
= gap_tag
= next_page_tag unless current_page.last?

View File

@ -0,0 +1,9 @@
-# Link to the "Previous" page
-# available local variables
-# url: url to the previous page
-# current_page: a page object for the currently displayed page
-# total_pages: total number of pages
-# per_page: number of items to fetch per page
-# remote: data-remote
%span.prev
= link_to_unless current_page.first?, safe_join([fa_icon('chevron-left'), t('pagination.prev')], ' '), url, rel: 'prev', remote: remote

View File

@ -10,8 +10,8 @@
%tr
%th= t('exports.follows')
%td= @total_follows
%td= table_link_to 'download', t('exports.csv'), follows_settings_export_path(format: :csv)
%td= table_link_to 'download', t('exports.csv'), settings_exports_follows_path(format: :csv)
%tr
%th= t('exports.blocks')
%td= @total_blocks
%td= table_link_to 'download', t('exports.csv'), blocks_settings_export_path(format: :csv)
%td= table_link_to 'download', t('exports.csv'), settings_exports_blocks_path(format: :csv)

View File

@ -22,5 +22,8 @@
= ff.input :must_be_follower, as: :boolean, wrapper: :with_label
= ff.input :must_be_following, as: :boolean, wrapper: :with_label
.fields-group
= f.input :setting_boost_modal, as: :boolean, wrapper: :with_label
.actions
= f.button :button, t('generic.save_changes'), type: :submit

View File

@ -1,2 +1,5 @@
.landing-strip
= t('landing_strip_html', name: display_name(account), domain: Rails.configuration.x.local_domain, sign_up_path: new_user_registration_path)
= t('landing_strip_html',
name: content_tag(:span, display_name(account), class: :emojify),
domain: Rails.configuration.x.local_domain,
sign_up_path: new_user_registration_path)

View File

@ -13,7 +13,7 @@
= fa_icon('retweet fw')
%span
= link_to TagManager.instance.url_for(status.account), class: 'status__display-name muted' do
%strong= display_name(status.account)
%strong.emojify= display_name(status.account)
= t('stream_entries.reblogged')
= render partial: centered ? 'stream_entries/detailed_status' : 'stream_entries/simple_status', locals: { status: status.proper }

View File

@ -15,4 +15,4 @@
- if @statuses.size == 20
.pagination
= link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), tag_url(@tag, max_id: @statuses.last.id), class: 'next_page', rel: 'next'
= link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), tag_url(@tag, max_id: @statuses.last.id), class: 'next', rel: 'next'

View File

@ -0,0 +1,5 @@
<p>ようこそ<%= @resource.email %>さん</p>
<p>以下のリンクをクリックしてMastodonアカウントのメールアドレスを確認してください</p>
<p><%= link_to 'メールアドレスの確認', confirmation_url(@resource, confirmation_token: @token) %></p>

View File

@ -0,0 +1,5 @@
ようこそ<%= @resource.email %>さん
以下のリンクをクリックしてMastodonアカウントのメールアドレスを確認してください
<%= confirmation_url(@resource, confirmation_token: @token) %>

View File

@ -0,0 +1,3 @@
<p>こんにちは<%= @resource.email %>さん</p>
<p>Mastodonアカウントのパスワードが変更されました。</p>

View File

@ -0,0 +1,3 @@
こんにちは<%= @resource.email %>さん
Mastodonアカウントのパスワードが変更されました。

View File

@ -0,0 +1,8 @@
<p>こんにちは<%= @resource.email %>さん</p>
<p>Mastodonアカウントのパスワードの変更がリクエストされました。以下のリンクをクリックして操作を完了できます。</p>
<p><%= link_to 'パスワードを変更', edit_password_url(@resource, reset_password_token: @token) %></p>
<p>このメールに見に覚えのない場合は無視してください。</p>
<p>上記のリンクにアクセスし、変更をしない限りパスワードは変更されません。</p>

View File

@ -0,0 +1,8 @@
こんにちは<%= @resource.email %>さん
Mastodonアカウントのパスワードの変更がリクエストされました。以下のリンクをクリックして操作を完了できます。
<%= edit_password_url(@resource, reset_password_token: @token) %>
このメールに見に覚えのない場合は無視してください。
上記のリンクにアクセスし、変更をしない限りパスワードは変更されません。

View File

@ -4,32 +4,41 @@ require 'csv'
class ImportWorker
include Sidekiq::Worker
sidekiq_options queue: 'pull', retry: false
def perform(import_id)
import = Import.find(import_id)
attr_reader :import
case import.type
def perform(import_id)
@import = Import.find(import_id)
case @import.type
when 'blocking'
process_blocks(import)
process_blocks
when 'following'
process_follows(import)
process_follows
end
import.destroy
@import.destroy
end
private
def process_blocks(import)
from_account = import.account
def from_account
@import.account
end
CSV.foreach(import.data.path) do |row|
next if row.size != 1
def import_contents
Paperclip.io_adapters.for(@import.data).read
end
def import_rows
CSV.new(import_contents).reject(&:blank?)
end
def process_blocks
import_rows.each do |row|
begin
target_account = FollowRemoteAccountService.new.call(row[0])
target_account = FollowRemoteAccountService.new.call(row.first)
next if target_account.nil?
BlockService.new.call(from_account, target_account)
rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError
@ -38,14 +47,10 @@ class ImportWorker
end
end
def process_follows(import)
from_account = import.account
CSV.foreach(import.data.path) do |row|
next if row.size != 1
def process_follows
import_rows.each do |row|
begin
FollowService.new.call(from_account, row[0])
FollowService.new.call(from_account, row.first)
rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound, Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError
next
end

View File

@ -24,7 +24,9 @@ module Mastodon
# The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
# config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
config.i18n.available_locales = [:en, :de, :es, :pt, :fr, :hu, :uk, 'zh-CN', :fi, :eo]
config.i18n.available_locales = [:en, :de, :es, :pt, :fr, :hu, :uk, 'zh-CN', :fi, :eo, :ru, :ja]
config.i18n.default_locale = :en
# config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb')

View File

@ -1,11 +1,11 @@
lock '3.7.2'
lock '3.8.0'
set :application, 'mastodon'
set :repo_url, 'https://github.com/tootsuite/mastodon.git'
set :branch, 'master'
set :branch, 'skylight'
set :rbenv_type, :user
set :rbenv_ruby, File.read('.ruby-version').strip
set :migration_role, :app
append :linked_files, '.env.production'
append :linked_dirs, 'vendor/bundle', 'node_modules', 'public/system'
append :linked_dirs, 'vendor/bundle', 'node_modules', 'public/system', 'tmp/cache'

View File

@ -40,7 +40,7 @@ Rails.application.configure do
# By default, use the lowest log level to ensure availability of diagnostic information
# when problems arise.
config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'debug').to_sym
config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'info').to_sym
# Prepend all log lines with the following tags.
config.log_tags = [:request_id]
@ -64,7 +64,7 @@ Rails.application.configure do
password: ENV.fetch('REDIS_PASSWORD') { false },
db: 0,
namespace: 'cache',
expires_in: 20.minutes,
expires_in: 10.minutes,
}
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
@ -94,12 +94,14 @@ Rails.application.configure do
# E-mails
config.action_mailer.smtp_settings = {
:port => ENV['SMTP_PORT'],
:address => ENV['SMTP_SERVER'],
:user_name => ENV['SMTP_LOGIN'],
:password => ENV['SMTP_PASSWORD'],
:domain => ENV['SMTP_DOMAIN'] || config.x.local_domain,
:authentication => :plain,
:port => ENV['SMTP_PORT'],
:address => ENV['SMTP_SERVER'],
:user_name => ENV['SMTP_LOGIN'],
:password => ENV['SMTP_PASSWORD'],
:domain => ENV['SMTP_DOMAIN'] || config.x.local_domain,
:authentication => ENV['SMTP_AUTH_METHOD'] || :plain,
:openssl_verify_mode => ENV['SMTP_OPENSSL_VERIFY_MODE'],
:enable_starttls_auto => ENV['SMTP_ENABLE_STARTTLS_AUTO'] || true,
}
config.action_mailer.delivery_method = :smtp

View File

@ -33,7 +33,7 @@ search:
ignore_unused:
- 'activerecord.attributes.*'
- '{devise,will_paginate,doorkeeper}.*'
- '{devise,pagination,doorkeeper}.*'
- '{datetime,time}.*'
- 'simple_form.{yes,no}'
- 'simple_form.{placeholders,hints,labels}.*'

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