diff --git a/.babelrc b/.babelrc index de922f389..190b5038c 100644 --- a/.babelrc +++ b/.babelrc @@ -4,6 +4,7 @@ [ "env", { + "exclude": ["transform-async-to-generator", "transform-regenerator"], "loose": true, "modules": false, "targets": { diff --git a/.env.nanobox b/.env.nanobox index 48204a6bf..0d14f8a00 100644 --- a/.env.nanobox +++ b/.env.nanobox @@ -13,11 +13,29 @@ DB_PORT=5432 DATABASE_URL=postgresql://$DATA_DB_USER:$DATA_DB_PASS@$DATA_DB_HOST/gonano +# Optional ElasticSearch configuration +# ES_ENABLED=true +# ES_HOST=localhost +# ES_PORT=9200 + +# Optimizations +LD_PRELOAD=/data/lib/libjemalloc.so + +# ImageMagick optimizations +MAGICK_TEMPORARY_PATH=/app/tmp +MAGICK_MEMORY_LIMIT=128MiB +MAGICK_MAP_LIMIT=64MiB +MAGICK_TIME_LIMIT=15 +MAGICK_AREA_LIMIT=16MP +MAGICK_WIDTH_LIMIT=8KP +MAGICK_HEIGHT_LIMIT=8KP + # Federation -# Note: Changing LOCAL_DOMAIN or LOCAL_HTTPS at a later time will cause unwanted side effects. +# Note: Changing LOCAL_DOMAIN at a later time will cause unwanted side effects, including breaking all existing federation. # LOCAL_DOMAIN should *NOT* contain the protocol part of the domain e.g https://example.com. LOCAL_DOMAIN=${APP_NAME}.nanoapp.io -LOCAL_HTTPS=false + +# Changing LOCAL_HTTPS in production is no longer supported. (Mastodon will always serve https:// links) # Use this only if you need to run mastodon on a different domain than the one used for federation. # You can read more about this option on https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Serving_a_different_domain.md @@ -31,7 +49,6 @@ LOCAL_HTTPS=false # Application secrets # Generate each with the `rake secret` task (`nanobox run bundle exec rake secret`) -PAPERCLIP_SECRET=$PAPERCLIP_SECRET SECRET_KEY_BASE=$SECRET_KEY_BASE OTP_SECRET=$OTP_SECRET @@ -131,9 +148,79 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io # Cluster number setting for streaming API server. # If you comment out following line, cluster number will be `numOfCpuCores - 1`. -STREAMING_CLUSTER_NUM=1 +# STREAMING_CLUSTER_NUM=1 # Docker mastodon user # If you use Docker, you may want to assign UID/GID manually. # UID=1000 # GID=1000 + +# LDAP authentication (optional) +# LDAP_ENABLED=true +# LDAP_HOST=localhost +# LDAP_PORT=389 +# LDAP_METHOD=simple_tls +# LDAP_BASE= +# LDAP_BIND_DN= +# LDAP_PASSWORD= +# LDAP_UID=cn + +# PAM authentication (optional) +# PAM authentication uses for the email generation the "email" pam variable +# and optional as fallback PAM_DEFAULT_SUFFIX +# The pam environment variable "email" is provided by: +# https://github.com/devkral/pam_email_extractor +# PAM_ENABLED=true +# Fallback Suffix for email address generation (nil by default) +# PAM_DEFAULT_SUFFIX=pam +# Name of the pam service (pam "auth" section is evaluated) +# PAM_DEFAULT_SERVICE=rpam +# Name of the pam service used for checking if an user can register (pam "account" section is evaluated) (nil (disabled) by default) +# PAM_CONTROLLED_SERVICE=rpam + +# Global OAuth settings (optional) : +# If you have only one strategy, you may want to enable this +# OAUTH_REDIRECT_AT_SIGN_IN=true + +# Optional CAS authentication (cf. omniauth-cas) : +# CAS_ENABLED=true +# CAS_URL=https://sso.myserver.com/ +# CAS_HOST=sso.myserver.com/ +# CAS_PORT=443 +# CAS_SSL=true +# CAS_VALIDATE_URL= +# CAS_CALLBACK_URL= +# CAS_LOGOUT_URL= +# CAS_LOGIN_URL= +# CAS_UID_FIELD='user' +# CAS_CA_PATH= +# CAS_DISABLE_SSL_VERIFICATION=false +# CAS_UID_KEY='user' +# CAS_NAME_KEY='name' +# CAS_EMAIL_KEY='email' +# CAS_NICKNAME_KEY='nickname' +# CAS_FIRST_NAME_KEY='firstname' +# CAS_LAST_NAME_KEY='lastname' +# CAS_LOCATION_KEY='location' +# CAS_IMAGE_KEY='image' +# CAS_PHONE_KEY='phone' + +# Optional SAML authentication (cf. omniauth-saml) +# SAML_ENABLED=true +# SAML_ACS_URL= +# SAML_ISSUER=http://localhost:3000/auth/auth/saml/callback +# SAML_IDP_SSO_TARGET_URL=https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO +# SAML_IDP_CERT= +# SAML_IDP_CERT_FINGERPRINT= +# SAML_NAME_IDENTIFIER_FORMAT= +# SAML_CERT= +# SAML_PRIVATE_KEY= +# SAML_SECURITY_WANT_ASSERTION_SIGNED=true +# SAML_SECURITY_WANT_ASSERTION_ENCRYPTED=true +# SAML_SECURITY_ASSUME_EMAIL_IS_VERIFIED=true +# SAML_ATTRIBUTES_STATEMENTS_UID="urn:oid:0.9.2342.19200300.100.1.1" +# SAML_ATTRIBUTES_STATEMENTS_EMAIL="urn:oid:1.3.6.1.4.1.5923.1.1.1.6" +# SAML_ATTRIBUTES_STATEMENTS_FULL_NAME="urn:oid:2.5.4.42" +# SAML_UID_ATTRIBUTE="urn:oid:0.9.2342.19200300.100.1.1" +# SAML_ATTRIBUTES_STATEMENTS_VERIFIED= +# SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL= diff --git a/.env.production.sample b/.env.production.sample index 3f0edd72f..1e5ed9f3d 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -9,11 +9,15 @@ DB_USER=postgres DB_NAME=postgres DB_PASS= DB_PORT=5432 +# Optional ElasticSearch configuration +# ES_ENABLED=true +# ES_HOST=es +# ES_PORT=9200 # Federation # Note: Changing LOCAL_DOMAIN at a later time will cause unwanted side effects, including breaking all existing federation. # LOCAL_DOMAIN should *NOT* contain the protocol part of the domain e.g https://example.com. -LOCAL_DOMAIN=example.com +LOCAL_DOMAIN=example.com # Changing LOCAL_HTTPS in production is no longer supported. (Mastodon will always serve https:// links) @@ -29,7 +33,6 @@ LOCAL_DOMAIN=example.com # Application secrets # Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose) -PAPERCLIP_SECRET= SECRET_KEY_BASE= OTP_SECRET= @@ -58,7 +61,7 @@ VAPID_PUBLIC_KEY= # E-mail configuration # Note: Mailgun and SparkPost (https://sparkpo.st/smtp) each have good free tiers # If you want to use an SMTP server without authentication (e.g local Postfix relay) -# then set SMTP_AUTH_METHOD and SMTP_OPENSSL_VERIFY_MODE to 'none' and +# then set SMTP_AUTH_METHOD and SMTP_OPENSSL_VERIFY_MODE to 'none' and # *comment* SMTP_LOGIN and SMTP_PASSWORD (leaving them blank is not enough). SMTP_SERVER=smtp.mailgun.org SMTP_PORT=587 @@ -135,3 +138,75 @@ STREAMING_CLUSTER_NUM=1 # If you use Docker, you may want to assign UID/GID manually. # UID=1000 # GID=1000 + +# LDAP authentication (optional) +# LDAP_ENABLED=true +# LDAP_HOST=localhost +# LDAP_PORT=389 +# LDAP_METHOD=simple_tls +# LDAP_BASE= +# LDAP_BIND_DN= +# LDAP_PASSWORD= +# LDAP_UID=cn + +# PAM authentication (optional) +# PAM authentication uses for the email generation the "email" pam variable +# and optional as fallback PAM_DEFAULT_SUFFIX +# The pam environment variable "email" is provided by: +# https://github.com/devkral/pam_email_extractor +# PAM_ENABLED=true +# Fallback email domain for email address generation (LOCAL_DOMAIN by default) +# PAM_EMAIL_DOMAIN=example.com +# Name of the pam service (pam "auth" section is evaluated) +# PAM_DEFAULT_SERVICE=rpam +# Name of the pam service used for checking if an user can register (pam "account" section is evaluated) (nil (disabled) by default) +# PAM_CONTROLLED_SERVICE=rpam + +# Global OAuth settings (optional) : +# If you have only one strategy, you may want to enable this +# OAUTH_REDIRECT_AT_SIGN_IN=true + +# Optional CAS authentication (cf. omniauth-cas) : +# CAS_ENABLED=true +# CAS_URL=https://sso.myserver.com/ +# CAS_HOST=sso.myserver.com/ +# CAS_PORT=443 +# CAS_SSL=true +# CAS_VALIDATE_URL= +# CAS_CALLBACK_URL= +# CAS_LOGOUT_URL= +# CAS_LOGIN_URL= +# CAS_UID_FIELD='user' +# CAS_CA_PATH= +# CAS_DISABLE_SSL_VERIFICATION=false +# CAS_UID_KEY='user' +# CAS_NAME_KEY='name' +# CAS_EMAIL_KEY='email' +# CAS_NICKNAME_KEY='nickname' +# CAS_FIRST_NAME_KEY='firstname' +# CAS_LAST_NAME_KEY='lastname' +# CAS_LOCATION_KEY='location' +# CAS_IMAGE_KEY='image' +# CAS_PHONE_KEY='phone' + +# Optional SAML authentication (cf. omniauth-saml) +# SAML_ENABLED=true +# SAML_ACS_URL= +# SAML_ISSUER=http://localhost:3000/auth/auth/saml/callback +# SAML_IDP_SSO_TARGET_URL=https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO +# SAML_IDP_CERT= +# SAML_IDP_CERT_FINGERPRINT= +# SAML_NAME_IDENTIFIER_FORMAT= +# SAML_CERT= +# SAML_PRIVATE_KEY= +# SAML_SECURITY_WANT_ASSERTION_SIGNED=true +# SAML_SECURITY_WANT_ASSERTION_ENCRYPTED=true +# SAML_SECURITY_ASSUME_EMAIL_IS_VERIFIED=true +# SAML_ATTRIBUTES_STATEMENTS_UID="urn:oid:0.9.2342.19200300.100.1.1" +# SAML_ATTRIBUTES_STATEMENTS_EMAIL="urn:oid:1.3.6.1.4.1.5923.1.1.1.6" +# SAML_ATTRIBUTES_STATEMENTS_FULL_NAME="urn:oid:2.16.840.1.113730.3.1.241" +# SAML_ATTRIBUTES_STATEMENTS_FIRST_NAME="urn:oid:2.5.4.42" +# SAML_ATTRIBUTES_STATEMENTS_LAST_NAME="urn:oid:2.5.4.4" +# SAML_UID_ATTRIBUTE="urn:oid:0.9.2342.19200300.100.1.1" +# SAML_ATTRIBUTES_STATEMENTS_VERIFIED= +# SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL= diff --git a/.env.test b/.env.test index e25c040ac..b57f52e30 100644 --- a/.env.test +++ b/.env.test @@ -1,4 +1,3 @@ # Federation LOCAL_DOMAIN=cb6e6126.ngrok.io LOCAL_HTTPS=true -OTP_SECRET=100c7faeef00caa29242f6b04156742bf76065771fd4117990c4282b8748ff3d99f8fdae97c982ab5bd2e6756a159121377cce4421f4a8ecd2d67bd7749a3fb4 diff --git a/.travis.yml b/.travis.yml index 35fc49dde..576659aaf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,15 +3,15 @@ cache: bundler: true yarn: true directories: - - node_modules - - public/assets - - public/packs-test - - tmp/cache/babel-loader + - node_modules + - public/assets + - public/packs-test + - tmp/cache/babel-loader dist: trusty sudo: false branches: only: - - master + - master notifications: email: false @@ -23,21 +23,20 @@ env: - RAILS_ENV=test - NOKOGIRI_USE_SYSTEM_LIBRARIES=true - PARALLEL_TEST_PROCESSORS=2 - - "PATH=$HOME:$PATH" addons: postgresql: 9.4 apt: sources: - - trusty-media - - sourceline: deb https://dl.yarnpkg.com/debian/ stable main - key_url: https://dl.yarnpkg.com/debian/pubkey.gpg + - trusty-media + - sourceline: deb https://dl.yarnpkg.com/debian/ stable main + key_url: https://dl.yarnpkg.com/debian/pubkey.gpg packages: - - ffmpeg - - libicu-dev - - libprotobuf-dev - - protobuf-compiler - - yarn + - ffmpeg + - libicu-dev + - libprotobuf-dev + - protobuf-compiler + - yarn rvm: - 2.4.2 @@ -53,9 +52,8 @@ install: before_script: - ./bin/rails parallel:create parallel:load_schema parallel:prepare assets:precompile - - ln -s /usr/bin/x86_64-linux-gnu-g++-6 "$HOME/g++" script: - travis_retry bundle exec parallel_test spec/ --group-by filesize --type rspec - - yarn test + - yarn run test:jest - bundle exec i18n-tasks check-normalized && bundle exec i18n-tasks unused diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 000000000..c4bbb6014 --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,450 @@ +Mastodon is available on [GitHub](https://github.com/tootsuite/mastodon) +and provided thanks to the work of the following contributors: + +* [Gargron](https://github.com/Gargron) +* [ykzts](https://github.com/ykzts) +* [mjankowski](https://github.com/mjankowski) +* [akihikodaki](https://github.com/akihikodaki) +* [unarist](https://github.com/unarist) +* [yiskah](https://github.com/yiskah) +* [m4sk1n](https://github.com/m4sk1n) +* [nolanlawson](https://github.com/nolanlawson) +* [sorin-davidoi](https://github.com/sorin-davidoi) +* [abcang](https://github.com/abcang) +* [ThibG](https://github.com/ThibG) +* [lynlynlynx](https://github.com/lynlynlynx) +* [alpaca-tc](https://github.com/alpaca-tc) +* [nclm](https://github.com/nclm) +* [ineffyble](https://github.com/ineffyble) +* [jeroenpraat](https://github.com/jeroenpraat) +* [blackle](https://github.com/blackle) +* [Quent-in](https://github.com/Quent-in) +* [JantsoP](https://github.com/JantsoP) +* [nullkal](https://github.com/nullkal) +* [yookoala](https://github.com/yookoala) +* [ysksn](https://github.com/ysksn) +* [ashfurrow](https://github.com/ashfurrow) +* [eramdam](https://github.com/eramdam) +* [mayaeh](https://github.com/mayaeh) +* [zunda](https://github.com/zunda) +* [ticky](https://github.com/ticky) +* [masarakki](https://github.com/masarakki) +* [Wonderfall](https://github.com/Wonderfall) +* [matteoaquila](https://github.com/matteoaquila) +* [rkarabut](https://github.com/rkarabut) +* [stephenburgess8](https://github.com/stephenburgess8) +* [Kjwon15](https://github.com/Kjwon15) +* [Artoria2e5](https://github.com/Artoria2e5) +* [yukimochi](https://github.com/yukimochi) +* [marrus-sh](https://github.com/marrus-sh) +* [krainboltgreene](https://github.com/krainboltgreene) +* [renatolond](https://github.com/renatolond) +* [BoFFire](https://github.com/BoFFire) +* [clworld](https://github.com/clworld) +* [danhunsaker](https://github.com/danhunsaker) +* [patf](https://github.com/patf) +* [Quenty31](https://github.com/Quenty31) +* [MitarashiDango](https://github.com/MitarashiDango) +* [Aldarone](https://github.com/Aldarone) +* [JeanGauthier](https://github.com/JeanGauthier) +* [kschaper](https://github.com/kschaper) +* [takayamaki](https://github.com/takayamaki) +* [adbelle](https://github.com/adbelle) +* [evanminto](https://github.com/evanminto) +* [mabkenar](https://github.com/mabkenar) +* [MightyPork](https://github.com/MightyPork) +* [beatrix-bitrot](https://github.com/beatrix-bitrot) +* [yhirano55](https://github.com/yhirano55) +* [camponez](https://github.com/camponez) +* [aschmitz](https://github.com/aschmitz) +* [fpiesche](https://github.com/fpiesche) +* [gandaro](https://github.com/gandaro) +* [johnsudaar](https://github.com/johnsudaar) +* [trebmuh](https://github.com/trebmuh) +* [Sylvhem](https://github.com/Sylvhem) +* [lindwurm](https://github.com/lindwurm) +* [voidsatisfaction](https://github.com/voidsatisfaction) +* [neetshin](https://github.com/neetshin) +* [valentin2105](https://github.com/valentin2105) +* [hikari-no-yume](https://github.com/hikari-no-yume) +* [Angristan](https://github.com/Angristan) +* [seefood](https://github.com/seefood) +* [jackjennings](https://github.com/jackjennings) +* [hcmiya](https://github.com/hcmiya) +* [nightpool](https://github.com/nightpool) +* [salvadorpla](https://github.com/salvadorpla) +* [expenses](https://github.com/expenses) +* [walf443](https://github.com/walf443) +* [JoelQ](https://github.com/JoelQ) +* [mistydemeo](https://github.com/mistydemeo) +* [dunn](https://github.com/dunn) +* [xqus](https://github.com/xqus) +* [pfm-eyesightjp](https://github.com/pfm-eyesightjp) +* [fakenine](https://github.com/fakenine) +* [tsuwatch](https://github.com/tsuwatch) +* [victorhck](https://github.com/victorhck) +* [puckipedia](https://github.com/puckipedia) +* [contraexemplo](https://github.com/contraexemplo) +* [kazu9su](https://github.com/kazu9su) +* [Komic](https://github.com/Komic) +* [diomed](https://github.com/diomed) +* [rainyday](https://github.com/rainyday) +* [kadiix](https://github.com/kadiix) +* [kodacs](https://github.com/kodacs) +* [ProgVal](https://github.com/ProgVal) +* [sterdev](https://github.com/sterdev) +* [TheKinrar](https://github.com/TheKinrar) +* [AA4ch1](https://github.com/AA4ch1) +* [alexgleason](https://github.com/alexgleason) +* [cpytel](https://github.com/cpytel) +* [northerner](https://github.com/northerner) +* [hnrysmth](https://github.com/hnrysmth) +* [hugogameiro](https://github.com/hugogameiro) +* [JohnD28](https://github.com/JohnD28) +* [znz](https://github.com/znz) +* [Naouak](https://github.com/Naouak) +* [rtucker](https://github.com/rtucker) +* [reneklacan](https://github.com/reneklacan) +* [KScl](https://github.com/KScl) +* [SerCom-KC](https://github.com/SerCom-KC) +* [tcitworld](https://github.com/tcitworld) +* [geta6](https://github.com/geta6) +* [goofy-bz](https://github.com/goofy-bz) +* [happycoloredbanana](https://github.com/happycoloredbanana) +* [leopku](https://github.com/leopku) +* [SansPseudoFix](https://github.com/SansPseudoFix) +* [tomfhowe](https://github.com/tomfhowe) +* [noraworld](https://github.com/noraworld) +* [fvh-P](https://github.com/fvh-P) +* [178inaba](https://github.com/178inaba) +* [devkral](https://github.com/devkral) +* [alyssais](https://github.com/alyssais) +* [kodnaplakal](https://github.com/kodnaplakal) +* [stalker314314](https://github.com/stalker314314) +* [huertanix](https://github.com/huertanix) +* [genesixx](https://github.com/genesixx) +* [fhemberger](https://github.com/fhemberger) +* [halkeye](https://github.com/halkeye) +* [treby](https://github.com/treby) +* [d6rkaiz](https://github.com/d6rkaiz) +* [jpdevries](https://github.com/jpdevries) +* [rndm-stranger](https://github.com/rndm-stranger) +* [saper](https://github.com/saper) +* [nevillepark](https://github.com/nevillepark) +* [ornithocoder](https://github.com/ornithocoder) +* [pierreozoux](https://github.com/pierreozoux) +* [ramlmn](https://github.com/ramlmn) +* [harukasan](https://github.com/harukasan) +* [stamak](https://github.com/stamak) +* [Eychics](https://github.com/Eychics) +* [thor-the-norseman](https://github.com/thor-the-norseman) +* [0x70b1a5](https://github.com/0x70b1a5) +* [gled-rs](https://github.com/gled-rs) +* [R0ckweb](https://github.com/R0ckweb) +* [esetomo](https://github.com/esetomo) +* [foxiehkins](https://github.com/foxiehkins) +* [sdukhovni](https://github.com/sdukhovni) +* [unsmell](https://github.com/unsmell) +* [chriswmartin](https://github.com/chriswmartin) +* [vahnj](https://github.com/vahnj) +* [ikuradon](https://github.com/ikuradon) +* [AndreLewin](https://github.com/AndreLewin) +* [redtachyons](https://github.com/redtachyons) +* [thurloat](https://github.com/thurloat) +* [aaribaud](https://github.com/aaribaud) +* [estuans](https://github.com/estuans) +* [dissolve](https://github.com/dissolve) +* [PurpleBooth](https://github.com/PurpleBooth) +* [bradurani](https://github.com/bradurani) +* [wavebeem](https://github.com/wavebeem) +* [bruwalfas](https://github.com/bruwalfas) +* [foxsan48](https://github.com/foxsan48) +* [wchristian](https://github.com/wchristian) +* [muffinista](https://github.com/muffinista) +* [cdutson](https://github.com/cdutson) +* [farlistener](https://github.com/farlistener) +* [DavidLibeau](https://github.com/DavidLibeau) +* [SirCmpwn](https://github.com/SirCmpwn) +* [MasterGroosha](https://github.com/MasterGroosha) +* [Fjoerfoks](https://github.com/Fjoerfoks) +* [fmauNeko](https://github.com/fmauNeko) +* [gloaec](https://github.com/gloaec) +* [greysteil](https://github.com/greysteil) +* [unstabler](https://github.com/unstabler) +* [potato4d](https://github.com/potato4d) +* [h-izumi](https://github.com/h-izumi) +* [ErikXXon](https://github.com/ErikXXon) +* [ian-kelling](https://github.com/ian-kelling) +* [foozmeat](https://github.com/foozmeat) +* [jasonrhodes](https://github.com/jasonrhodes) +* [asm](https://github.com/asm) +* [jviide](https://github.com/jviide) +* [crakaC](https://github.com/crakaC) +* [tkbky](https://github.com/tkbky) +* [Kazhnuz](https://github.com/Kazhnuz) +* [alimony](https://github.com/alimony) +* [mig5](https://github.com/mig5) +* [ndarville](https://github.com/ndarville) +* [Abzol](https://github.com/Abzol) +* [xPaw](https://github.com/xPaw) +* [raymestalez](https://github.com/raymestalez) +* [sim6](https://github.com/sim6) +* [ekiru](https://github.com/ekiru) +* [Technowix](https://github.com/Technowix) +* [ThomasLeister](https://github.com/ThomasLeister) +* [mcat-ee](https://github.com/mcat-ee) +* [tototoshi](https://github.com/tototoshi) +* [VirtuBox](https://github.com/VirtuBox) +* [kaniini](https://github.com/kaniini) +* [vayan](https://github.com/vayan) +* [yannicka](https://github.com/yannicka) +* [ikasoumen](https://github.com/ikasoumen) +* [zacanger](https://github.com/zacanger) +* [amazedkoumei](https://github.com/amazedkoumei) +* [anon5r](https://github.com/anon5r) +* [codl](https://github.com/codl) +* [barzamin](https://github.com/barzamin) +* [fhalna](https://github.com/fhalna) +* [haoyayoi](https://github.com/haoyayoi) +* [ik11235](https://github.com/ik11235) +* [kawax](https://github.com/kawax) +* [007lva](https://github.com/007lva) +* [matsurai25](https://github.com/matsurai25) +* [mecab](https://github.com/mecab) +* [nicobz25](https://github.com/nicobz25) +* [oliverkeeble](https://github.com/oliverkeeble) +* [pinfort](https://github.com/pinfort) +* [rbaumert](https://github.com/rbaumert) +* [usagi-f](https://github.com/usagi-f) +* [vidarlee](https://github.com/vidarlee) +* [vjackson725](https://github.com/vjackson725) +* [wxcafe](https://github.com/wxcafe) +* [rinsuki](https://github.com/rinsuki) +* [cygnan](https://github.com/cygnan) +* [Awea](https://github.com/Awea) +* [halcy](https://github.com/halcy) +* [bounshi](https://github.com/bounshi) +* [8398a7](https://github.com/8398a7) +* [857b](https://github.com/857b) +* [unascribed](https://github.com/unascribed) +* [Aguay-val](https://github.com/Aguay-val) +* [knu](https://github.com/knu) +* [alxrcs](https://github.com/alxrcs) +* [console-cowboy](https://github.com/console-cowboy) +* [pointlessone](https://github.com/pointlessone) +* [a2](https://github.com/a2) +* [0xa](https://github.com/0xa) +* [virtualpain](https://github.com/virtualpain) +* [sapphirus](https://github.com/sapphirus) +* [amandavisconti](https://github.com/amandavisconti) +* [ameliavoncat](https://github.com/ameliavoncat) +* [ilpianista](https://github.com/ilpianista) +* [andydrop](https://github.com/andydrop) +* [schas002](https://github.com/schas002) +* [jumbosushi](https://github.com/jumbosushi) +* [ayumin](https://github.com/ayumin) +* [BaptisteGelez](https://github.com/BaptisteGelez) +* [bzg](https://github.com/bzg) +* [benediktg](https://github.com/benediktg) +* [blakebarnett](https://github.com/blakebarnett) +* [bradj](https://github.com/bradj) +* [brycied00d](https://github.com/brycied00d) +* [carlosjs23](https://github.com/carlosjs23) +* [cgxxx](https://github.com/cgxxx) +* [chrisheninger](https://github.com/chrisheninger) +* [chris-martin](https://github.com/chris-martin) +* [DoubleMalt](https://github.com/DoubleMalt) +* [Moosh-be](https://github.com/Moosh-be) +* [Motoma](https://github.com/Motoma) +* [chriswk](https://github.com/chriswk) +* [csu](https://github.com/csu) +* [kklleemm](https://github.com/kklleemm) +* [monsterpit-daggertooth](https://github.com/monsterpit-daggertooth) +* [watilde](https://github.com/watilde) +* [daprice](https://github.com/daprice) +* [dar5hak](https://github.com/dar5hak) +* [kant](https://github.com/kant) +* [singingwolfboy](https://github.com/singingwolfboy) +* [davidcelis](https://github.com/davidcelis) +* [yipdw](https://github.com/yipdw) +* [debanshuk](https://github.com/debanshuk) +* [dblandin](https://github.com/dblandin) +* [aranaur](https://github.com/aranaur) +* [d3vgru](https://github.com/d3vgru) +* [Elizafox](https://github.com/Elizafox) +* [ericblade](https://github.com/ericblade) +* [mikoim](https://github.com/mikoim) +* [siuying](https://github.com/siuying) +* [hattori6789](https://github.com/hattori6789) +* [algernon](https://github.com/algernon) +* [Fastbyte01](https://github.com/Fastbyte01) +* [myfreeweb](https://github.com/myfreeweb) +* [gfaivre](https://github.com/gfaivre) +* [Fiaxhs](https://github.com/Fiaxhs) +* [reedcourty](https://github.com/reedcourty) +* [anneau](https://github.com/anneau) +* [HellPie](https://github.com/HellPie) +* [Habu-Kagumba](https://github.com/Habu-Kagumba) +* [hinaloe](https://github.com/hinaloe) +* [suzukaze](https://github.com/suzukaze) +* [Hiromi-Kai](https://github.com/Hiromi-Kai) +* [musashino205](https://github.com/musashino205) +* [iwaim](https://github.com/iwaim) +* [valrus](https://github.com/valrus) +* [IMcD23](https://github.com/IMcD23) +* [yi0713](https://github.com/yi0713) +* [immae](https://github.com/immae) +* [iblech](https://github.com/iblech) +* [jack-michaud](https://github.com/jack-michaud) +* [Floppy](https://github.com/Floppy) +* [loomchild](https://github.com/loomchild) +* [docjkl](https://github.com/docjkl) +* [TrollDecker](https://github.com/TrollDecker) +* [jmontane](https://github.com/jmontane) +* [jonathanklee](https://github.com/jonathanklee) +* [jguerder](https://github.com/jguerder) +* [Jehops](https://github.com/Jehops) +* [joshuap](https://github.com/joshuap) +* [Tiwy57](https://github.com/Tiwy57) +* [xuv](https://github.com/xuv) +* [Jnsll](https://github.com/Jnsll) +* [j0k3r](https://github.com/j0k3r) +* [KEINOS](https://github.com/KEINOS) +* [futoase](https://github.com/futoase) +* [abjectio](https://github.com/abjectio) +* [mkody](https://github.com/mkody) +* [connyduck](https://github.com/connyduck) +* [k0ta0uchi](https://github.com/k0ta0uchi) +* [KrzysiekJ](https://github.com/KrzysiekJ) +* [leowzukw](https://github.com/leowzukw) +* [lmorchard](https://github.com/lmorchard) +* [cacheflow](https://github.com/cacheflow) +* [ldidry](https://github.com/ldidry) +* [jemus42](https://github.com/jemus42) +* [lfuelling](https://github.com/lfuelling) +* [Grabacr07](https://github.com/Grabacr07) +* [mistermantas](https://github.com/mistermantas) +* [wirehack7](https://github.com/wirehack7) +* [marvinkopf](https://github.com/marvinkopf) +* [otsune](https://github.com/otsune) +* [m-blc](https://github.com/m-blc) +* [matt-auckland](https://github.com/matt-auckland) +* [mattjmattj](https://github.com/mattjmattj) +* [mtparet](https://github.com/mtparet) +* [maximeborges](https://github.com/maximeborges) +* [minacle](https://github.com/minacle) +* [michaeljdeeb](https://github.com/michaeljdeeb) +* [Themimitoof](https://github.com/Themimitoof) +* [cyweo](https://github.com/cyweo) +* [M1dgard](https://github.com/M1dgard) +* [mike-burns](https://github.com/mike-burns) +* [verymilan](https://github.com/verymilan) +* [milmazz](https://github.com/milmazz) +* [Mnkai](https://github.com/Mnkai) +* [mitchhentges](https://github.com/mitchhentges) +* [moritzheiber](https://github.com/moritzheiber) +* [mouse-reeve](https://github.com/mouse-reeve) +* [lae](https://github.com/lae) +* [Nanamachi](https://github.com/Nanamachi) +* [ngerakines](https://github.com/ngerakines) +* [vonneudeck](https://github.com/vonneudeck) +* [Ninetailed](https://github.com/Ninetailed) +* [k24](https://github.com/k24) +* [noiob](https://github.com/noiob) +* [kwaio](https://github.com/kwaio) +* [norayr](https://github.com/norayr) +* [joyeusenoelle](https://github.com/joyeusenoelle) +* [OlivierNicole](https://github.com/OlivierNicole) +* [Otakan951](https://github.com/Otakan951) +* [fahy](https://github.com/fahy) +* [Pangoraw](https://github.com/Pangoraw) +* [pwoolcoc](https://github.com/pwoolcoc) +* [peterkeen](https://github.com/peterkeen) +* [petzah](https://github.com/petzah) +* [ignisf](https://github.com/ignisf) +* [rfwatson](https://github.com/rfwatson) +* [rfreebern](https://github.com/rfreebern) +* [sylph01](https://github.com/sylph01) +* [staticsafe](https://github.com/staticsafe) +* [snwh](https://github.com/snwh) +* [skoji](https://github.com/skoji) +* [ScienJus](https://github.com/ScienJus) +* [larkinscott](https://github.com/larkinscott) +* [imolein](https://github.com/imolein) +* [blinry](https://github.com/blinry) +* [Noiwex](https://github.com/Noiwex) +* [yuki764](https://github.com/yuki764) +* [shnjp](https://github.com/shnjp) +* [ernix](https://github.com/ernix) +* [rosylilly](https://github.com/rosylilly) +* [shouko](https://github.com/shouko) +* [sossii](https://github.com/sossii) +* [StefOfficiel](https://github.com/StefOfficiel) +* [svetlik](https://github.com/svetlik) +* [dereckson](https://github.com/dereckson) +* [theboss](https://github.com/theboss) +* [takp](https://github.com/takp) +* [tkusano](https://github.com/tkusano) +* [TheInventrix](https://github.com/TheInventrix) +* [shug0](https://github.com/shug0) +* [Fortyseven](https://github.com/Fortyseven) +* [tobypinder](https://github.com/tobypinder) +* [tomosm](https://github.com/tomosm) +* [TomoyaShibata](https://github.com/TomoyaShibata) +* [TrashMacNugget](https://github.com/TrashMacNugget) +* [treyssatvincent](https://github.com/treyssatvincent) +* [optikfluffel](https://github.com/optikfluffel) +* [vmincev](https://github.com/vmincev) +* [waldyrious](https://github.com/waldyrious) +* [tahnok](https://github.com/tahnok) +* [YDrogen](https://github.com/YDrogen) +* [YOSHIOKAEiichiro](https://github.com/YOSHIOKAEiichiro) +* [S-YOU](https://github.com/S-YOU) +* [YaQ00](https://github.com/YaQ00) +* [yanakend](https://github.com/yanakend) +* [orzFly](https://github.com/orzFly) +* [chansuke](https://github.com/chansuke) +* [yuntan](https://github.com/yuntan) +* [LogicalDash](https://github.com/LogicalDash) +* [ZiiX](https://github.com/ZiiX) +* [benklop](https://github.com/benklop) +* [caasi](https://github.com/caasi) +* [caesarologia](https://github.com/caesarologia) +* [chrolis](https://github.com/chrolis) +* [cormojs](https://github.com/cormojs) +* [cpsdqs](https://github.com/cpsdqs) +* [d0p1s4m4](https://github.com/d0p1s4m4) +* [evilny0](https://github.com/evilny0) +* [febrezo](https://github.com/febrezo) +* [fsubal](https://github.com/fsubal) +* [dikky1218](https://github.com/dikky1218) +* [gentarok](https://github.com/gentarok) +* [hakoai](https://github.com/hakoai) +* [chaosbunker](https://github.com/chaosbunker) +* [isati](https://github.com/isati) +* [jkap](https://github.com/jkap) +* [jirayudech](https://github.com/jirayudech) +* [jukper](https://github.com/jukper) +* [karlyeurl](https://github.com/karlyeurl) +* [kedamaDQ](https://github.com/kedamaDQ) +* [kuro5hin](https://github.com/kuro5hin) +* [maxypy](https://github.com/maxypy) +* [marcus-herrmann](https://github.com/marcus-herrmann) +* [mshrtkch](https://github.com/mshrtkch) +* [muan](https://github.com/muan) +* [rch850](https://github.com/rch850) +* [roikale](https://github.com/roikale) +* [rysiekpl](https://github.com/rysiekpl) +* [saturday06](https://github.com/saturday06) +* [scriptjunkie](https://github.com/scriptjunkie) +* [seekr](https://github.com/seekr) +* [syui](https://github.com/syui) +* [tackeyy](https://github.com/tackeyy) +* [tmyt](https://github.com/tmyt) +* [utam0k](https://github.com/utam0k) +* [vpzomtrrfrt](https://github.com/vpzomtrrfrt) +* [walfie](https://github.com/walfie) +* [y-temp4](https://github.com/y-temp4) +* [ymmtmdk](https://github.com/ymmtmdk) + +This document is provided for informational purposes only. Since it is only updated once per release, the version you are looking at may be currently out of date. To see the full list of contributors, consider looking at the [git history](https://github.com/tootsuite/mastodon/graphs/contributors) instead. diff --git a/Dockerfile b/Dockerfile index 765df58c2..0801f5747 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,12 @@ -FROM ruby:2.5.0-alpine3.7 +FROM ruby:2.4.3-alpine3.6 LABEL maintainer="https://github.com/tootsuite/mastodon" \ - description="A GNU Social-compatible microblogging server" + description="Your self-hosted, globally interconnected microblogging community" -ENV UID=991 GID=991 \ - RAILS_SERVE_STATIC_FILES=true \ +ARG UID=991 +ARG GID=991 + +ENV RAILS_SERVE_STATIC_FILES=true \ RAILS_ENV=production NODE_ENV=production ARG YARN_VERSION=1.3.2 @@ -38,7 +40,6 @@ RUN apk -U upgrade \ nodejs \ nodejs-npm \ protobuf \ - su-exec \ tini \ tzdata \ && update-ca-certificates \ @@ -68,12 +69,16 @@ RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-in && yarn --pure-lockfile \ && yarn cache clean +RUN addgroup -g ${GID} mastodon && adduser -h /mastodon -s /bin/sh -D -G mastodon -u ${UID} mastodon \ + && mkdir -p /mastodon/public/system /mastodon/public/assets /mastodon/public/packs \ + && chown -R mastodon:mastodon /mastodon/public + COPY . /mastodon -COPY docker_entrypoint.sh /usr/local/bin/run - -RUN chmod +x /usr/local/bin/run +RUN chown -R mastodon:mastodon /mastodon VOLUME /mastodon/public/system /mastodon/public/assets /mastodon/public/packs -ENTRYPOINT ["/usr/local/bin/run"] +USER mastodon + +ENTRYPOINT ["/sbin/tini", "--"] diff --git a/Gemfile b/Gemfile index eaa1d29de..8bc28b893 100644 --- a/Gemfile +++ b/Gemfile @@ -7,7 +7,6 @@ gem 'pkg-config', '~> 1.2' gem 'puma', '~> 3.10' gem 'rails', '~> 5.1.4' -gem 'uglifier', '~> 3.2' gem 'hamlit-rails', '~> 0.2' gem 'pg', '~> 0.20' @@ -20,6 +19,7 @@ gem 'fog-local', '~> 0.4', require: false gem 'fog-openstack', '~> 0.1', require: false gem 'paperclip', '~> 5.1' gem 'paperclip-av-transcoder', '~> 0.6' +gem 'streamio-ffmpeg', '~> 3.0' gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.5' @@ -27,11 +27,22 @@ gem 'bootsnap' gem 'browser' gem 'charlock_holmes', '~> 0.7.5' gem 'iso-639' +gem 'chewy', '~> 5.0' gem 'cld3', '~> 3.2.0' gem 'devise', '~> 4.4' gem 'devise-two-factor', '~> 3.0' + +group :pam_authentication, optional: true do + gem 'devise_pam_authenticatable2', '~> 9.0' +end +gem 'net-ldap', '~> 0.10' +gem 'omniauth-cas', '~> 1.1' +gem 'omniauth-saml', '~> 1.10' +gem 'omniauth', '~> 1.2' + gem 'doorkeeper', '~> 4.2' gem 'fast_blank', '~> 1.0' +gem 'fastimage' gem 'goldfinger', '~> 2.1' gem 'hiredis', '~> 0.6' gem 'redis-namespace', '~> 1.5' @@ -60,7 +71,7 @@ gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'rqrcode', '~> 0.10' gem 'ruby-oembed', '~> 0.12', require: 'oembed' gem 'ruby-progressbar', '~> 1.4' -gem 'sanitize', '~> 4.4' +gem 'sanitize', '~> 4.6.4' gem 'sidekiq', '~> 5.0' gem 'sidekiq-scheduler', '~> 2.1' gem 'sidekiq-unique-jobs', '~> 5.0' @@ -69,6 +80,8 @@ gem 'simple-navigation', '~> 4.0' gem 'simple_form', '~> 3.4' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' gem 'strong_migrations' +gem 'tty-command' +gem 'tty-prompt' gem 'twitter-text', '~> 1.14' gem 'tzinfo-data', '~> 1.2017' gem 'webpacker', '~> 3.0' @@ -85,6 +98,10 @@ group :development, :test do gem 'rspec-rails', '~> 3.7' end +group :production, :test do + gem 'private_address_check', '~> 0.4.1' +end + group :test do gem 'capybara', '~> 2.15' gem 'climate_control', '~> 0.2' @@ -105,6 +122,7 @@ group :development do gem 'bullet', '~> 5.5' gem 'letter_opener', '~> 1.4' gem 'letter_opener_web', '~> 1.3' + gem 'memory_profiler' gem 'rubocop', require: false gem 'brakeman', '~> 4.0', require: false gem 'bundler-audit', '~> 0.6', require: false diff --git a/Gemfile.lock b/Gemfile.lock index b116318a7..7360ce7f6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -109,6 +109,10 @@ GEM case_transform (0.2) activesupport charlock_holmes (0.7.5) + chewy (5.0.0) + activesupport (>= 4.0) + elasticsearch (>= 2.0.0) + elasticsearch-dsl chunky_png (1.3.8) cld3 (3.2.2) ffi (>= 1.1.0, < 1.10.0) @@ -137,6 +141,9 @@ GEM devise (~> 4.0) railties (< 5.2) rotp (~> 2.0) + devise_pam_authenticatable2 (9.0.0) + devise (>= 4.0.0) + rpam2 (~> 3.0) diff-lcs (1.3) docile (1.1.5) domain_name (0.5.20170404) @@ -151,16 +158,28 @@ GEM json thread thread_safe + elasticsearch (6.0.1) + elasticsearch-api (= 6.0.1) + elasticsearch-transport (= 6.0.1) + elasticsearch-api (6.0.1) + multi_json + elasticsearch-dsl (0.1.5) + elasticsearch-transport (6.0.1) + faraday + multi_json encryptor (3.0.0) + equatable (0.5.0) erubi (1.7.0) et-orbi (1.0.8) tzinfo excon (0.59.0) - execjs (2.7.0) fabrication (2.18.0) faker (1.8.4) i18n (~> 0.5) + faraday (0.14.0) + multipart-post (>= 1.2, < 3) fast_blank (1.0.0) + fastimage (2.1.1) ffi (1.9.18) fog-core (1.45.0) builder @@ -198,8 +217,10 @@ GEM hamster (3.0.0) concurrent-ruby (~> 1.0) hashdiff (0.3.7) + hashie (3.5.7) highline (1.7.10) hiredis (0.6.1) + hitimes (1.2.6) hkdf (0.3.0) htmlentities (4.3.4) http (3.0.0) @@ -215,7 +236,7 @@ GEM httplog (0.99.7) colorize rack - i18n (0.9.1) + i18n (0.9.3) concurrent-ruby (~> 1.0) i18n-tasks (0.9.19) activesupport (>= 4.0.2) @@ -267,13 +288,14 @@ GEM activesupport (>= 4, < 5.2) railties (>= 4, < 5.2) request_store (~> 1.0) - loofah (2.1.1) + loofah (2.2.1) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.0) mini_mime (>= 0.1.1) mario-redis-lock (1.2.0) redis (~> 3, >= 3.0.5) + memory_profiler (0.9.10) method_source (0.9.0) microformats (4.0.7) json @@ -284,16 +306,19 @@ GEM mimemagic (0.3.2) mini_mime (1.0.0) mini_portile2 (2.3.0) - minitest (5.10.3) + minitest (5.11.3) msgpack (1.1.0) multi_json (1.12.2) + multipart-post (2.0.0) + necromancer (0.4.0) + net-ldap (0.16.1) net-scp (1.2.1) net-ssh (>= 2.6.5) net-ssh (4.2.0) nio4r (2.1.0) - nokogiri (1.8.1) + nokogiri (1.8.2) mini_portile2 (~> 2.3.0) - nokogumbo (1.4.13) + nokogumbo (1.5.0) nokogiri nsa (0.2.4) activesupport (>= 4.2, < 6) @@ -301,13 +326,23 @@ GEM sidekiq (>= 3.5.0) statsd-ruby (~> 1.2.0) oj (3.3.10) + omniauth (1.8.1) + hashie (>= 3.4.6, < 3.6.0) + rack (>= 1.6.2, < 3) + omniauth-cas (1.1.1) + addressable (~> 2.3) + nokogiri (~> 1.5) + omniauth (~> 1.2) + omniauth-saml (1.10.0) + omniauth (~> 1.3, >= 1.3.2) + ruby-saml (~> 1.7) orm_adapter (0.5.0) ostatus2 (2.0.3) addressable (~> 2.5) http (~> 3.0) nokogiri (~> 1.8) ox (2.8.2) - paperclip (5.1.0) + paperclip (5.2.1) activemodel (>= 4.2.0) activesupport (>= 4.2.0) cocaine (~> 0.5.5) @@ -321,6 +356,9 @@ GEM parallel parser (2.4.0.2) ast (~> 2.3) + pastel (0.7.2) + equatable (~> 0.5.0) + tty-color (~> 0.4.0) pg (0.21.0) pghero (1.7.0) activerecord @@ -333,6 +371,7 @@ GEM premailer-rails (1.10.1) actionmailer (>= 3, < 6) premailer (~> 1.7, >= 1.7.9) + private_address_check (0.4.1) pry (0.11.3) coderay (~> 1.1.0) method_source (~> 0.9.0) @@ -420,6 +459,7 @@ GEM actionpack (>= 4.2.0, < 5.3) railties (>= 4.2.0, < 5.3) rotp (2.1.2) + rpam2 (3.1.0) rqrcode (0.10.1) chunky_png (~> 1.0) rspec-core (3.7.0) @@ -451,13 +491,15 @@ GEM unicode-display_width (~> 1.0, >= 1.0.1) ruby-oembed (0.12.0) ruby-progressbar (1.9.0) + ruby-saml (1.7.2) + nokogiri (>= 1.5.10) rufus-scheduler (3.4.2) et-orbi (~> 1.0) safe_yaml (1.0.4) - sanitize (4.5.0) + sanitize (4.6.4) crass (~> 1.0.2) nokogiri (>= 1.4.4) - nokogumbo (~> 1.4.1) + nokogumbo (~> 1.4) sass (3.5.3) sass-listen (~> 4.0.0) sass-listen (4.0.0) @@ -503,6 +545,8 @@ GEM net-scp (>= 1.1.2) net-ssh (>= 2.8.0) statsd-ruby (1.2.1) + streamio-ffmpeg (3.0.2) + multi_json (~> 1.8) strong_migrations (0.1.9) activerecord (>= 3.2.0) temple (0.8.0) @@ -512,14 +556,29 @@ GEM thread (0.2.2) thread_safe (0.3.6) tilt (2.0.8) + timers (4.1.2) + hitimes + tty-color (0.4.2) + tty-command (0.7.0) + pastel (~> 0.7.0) + tty-cursor (0.5.0) + tty-prompt (0.15.0) + necromancer (~> 0.4.0) + pastel (~> 0.7.0) + timers (~> 4.0) + tty-cursor (~> 0.5.0) + tty-reader (~> 0.2.0) + tty-reader (0.2.0) + tty-cursor (~> 0.5.0) + tty-screen (~> 0.6.4) + wisper (~> 2.0.0) + tty-screen (0.6.4) twitter-text (1.14.7) unf (~> 0.1.0) tzinfo (1.2.4) thread_safe (~> 0.1) tzinfo-data (1.2017.3) tzinfo (>= 1.0.0) - uglifier (3.2.0) - execjs (>= 0.3.0, < 3) unf (0.1.4) unf_ext unf_ext (0.0.7.4) @@ -541,6 +600,7 @@ GEM websocket-driver (0.6.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.3) + wisper (2.0.0) xpath (2.1.0) nokogiri (~> 1.3) @@ -566,15 +626,18 @@ DEPENDENCIES capistrano-yarn (~> 2.0) capybara (~> 2.15) charlock_holmes (~> 0.7.5) + chewy (~> 5.0) cld3 (~> 3.2.0) climate_control (~> 0.2) devise (~> 4.4) devise-two-factor (~> 3.0) + devise_pam_authenticatable2 (~> 9.0) doorkeeper (~> 4.2) dotenv-rails (~> 2.2) fabrication (~> 2.18) faker (~> 1.7) fast_blank (~> 1.0) + fastimage fog-core (~> 1.45) fog-local (~> 0.4) fog-openstack (~> 0.1) @@ -596,11 +659,16 @@ DEPENDENCIES link_header (~> 0.0) lograge (~> 0.7) mario-redis-lock (~> 1.2) + memory_profiler microformats (~> 4.0) mime-types (~> 3.1) + net-ldap (~> 0.10) nokogiri (~> 1.8) nsa (~> 0.2) oj (~> 3.3) + omniauth (~> 1.2) + omniauth-cas (~> 1.1) + omniauth-saml (~> 1.10) ostatus2 (~> 2.0) ox (~> 2.8) paperclip (~> 5.1) @@ -610,6 +678,7 @@ DEPENDENCIES pghero (~> 1.7) pkg-config (~> 1.2) premailer-rails + private_address_check (~> 0.4.1) pry-rails (~> 0.3) puma (~> 3.10) pundit (~> 1.1) @@ -630,7 +699,7 @@ DEPENDENCIES rubocop ruby-oembed (~> 0.12) ruby-progressbar (~> 1.4) - sanitize (~> 4.4) + sanitize (~> 4.6.4) scss_lint (~> 0.55) sidekiq (~> 5.0) sidekiq-bulk (~> 0.1.1) @@ -640,10 +709,12 @@ DEPENDENCIES simple_form (~> 3.4) simplecov (~> 0.14) sprockets-rails (~> 3.2) + streamio-ffmpeg (~> 3.0) strong_migrations + tty-command + tty-prompt twitter-text (~> 1.14) tzinfo-data (~> 1.2017) - uglifier (~> 3.2) webmock (~> 3.0) webpacker (~> 3.0) webpush diff --git a/Procfile.dev b/Procfile.dev index e75a491c7..e589bbf63 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,4 +1,4 @@ -web: PORT=3000 bundle exec puma -C config/puma.rb -sidekiq: PORT=3000 bundle exec sidekiq -stream: PORT=4000 yarn run start +web: env PORT=3000 bundle exec puma -C config/puma.rb +sidekiq: env PORT=3000 bundle exec sidekiq +stream: env PORT=4000 yarn run start webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0 diff --git a/README.md b/README.md index 5cf91d52c..7b85b165b 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,10 @@ Click on the screenshot below to watch a demo of the UI: **Ruby on Rails** is used for the back-end, while **React.js** and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided. -If you would like, you can [support the development of this project on Patreon][patreon]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd` +If you would like, you can [support the development of this project on Patreon][patreon] or [Liberapay][liberapay]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd` [patreon]: https://www.patreon.com/user?u=619786 +[liberapay]: https://liberapay.com/Mastodon/ --- @@ -78,6 +79,16 @@ You can open issues for bugs you've found or features you think are missing. You **IRC channel**: #mastodon on irc.freenode.net +## License + +Copyright (C) 2016-2018 Eugen Rochko & other Mastodon contributors (see AUTHORS.md) + +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + --- ## Extra credits diff --git a/Vagrantfile b/Vagrantfile index 0c21bed68..ddcdf3510 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -39,6 +39,7 @@ sudo apt-get install \ libidn11-dev \ libprotobuf-dev \ libreadline-dev \ + libpam0g-dev \ -y # Install rvm @@ -48,7 +49,7 @@ curl -sSL https://raw.githubusercontent.com/rvm/rvm/stable/binscripts/rvm-instal source /home/vagrant/.rvm/scripts/rvm # Install Ruby -rvm install ruby-$RUBY_VERSION +rvm reinstall ruby-$RUBY_VERSION --disable-binary # Configure database sudo -u postgres createuser -U postgres vagrant -s @@ -79,7 +80,7 @@ VAGRANTFILE_API_VERSION = "2" Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| - config.vm.box = "ubuntu/trusty64" + config.vm.box = "ubuntu/xenial64" config.vm.provider :virtualbox do |vb| vb.name = "mastodon" diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb new file mode 100644 index 000000000..8bf5b4af7 --- /dev/null +++ b/app/chewy/statuses_index.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class StatusesIndex < Chewy::Index + settings index: { refresh_interval: '15m' }, analysis: { + filter: { + english_stop: { + type: 'stop', + stopwords: '_english_', + }, + english_stemmer: { + type: 'stemmer', + language: 'english', + }, + english_possessive_stemmer: { + type: 'stemmer', + language: 'possessive_english', + }, + }, + analyzer: { + content: { + tokenizer: 'uax_url_email', + filter: %w( + english_possessive_stemmer + lowercase + asciifolding + cjk_width + english_stop + english_stemmer + ), + }, + }, + } + + define_type ::Status.without_reblogs do + crutch :mentions do |collection| + data = ::Mention.where(status_id: collection.map(&:id)).pluck(:status_id, :account_id) + data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } + end + + crutch :favourites do |collection| + data = ::Favourite.where(status_id: collection.map(&:id)).pluck(:status_id, :account_id) + data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } + end + + crutch :reblogs do |collection| + data = ::Status.where(reblog_of_id: collection.map(&:id)).pluck(:reblog_of_id, :account_id) + data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } + end + + root date_detection: false do + field :account_id, type: 'long' + + field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].join("\n\n") } do + field :stemmed, type: 'text', analyzer: 'content' + end + + field :searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) } + field :created_at, type: 'date' + end + end +end diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 47690e81e..4ffdfb685 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -31,7 +31,7 @@ class AboutController < ApplicationController def initial_state_params { - settings: {}, + settings: { known_fediverse: Setting.show_known_fediverse_at_about_page }, token: current_session&.token, } end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 69fd20e27..7bf35825f 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class AccountsController < ApplicationController + PAGE_SIZE = 20 + include AccountControllerConcern before_action :set_cache_headers @@ -16,13 +18,16 @@ class AccountsController < ApplicationController end @pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses? - @statuses = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id]) + @statuses = filtered_status_page(params) @statuses = cache_collection(@statuses, Status) - @next_url = next_url unless @statuses.empty? + unless @statuses.empty? + @older_url = older_url if @statuses.last.id > filtered_statuses.last.id + @newer_url = newer_url if @statuses.first.id < filtered_statuses.first.id + end end format.atom do - @entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id]) + @entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(PAGE_SIZE, params[:max_id], params[:since_id]) render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? })) end @@ -69,13 +74,22 @@ class AccountsController < ApplicationController @account = Account.find_local!(params[:username]) end - def next_url + def older_url + ::Rails.logger.info("older: max_id #{@statuses.last.id}, url #{pagination_url(max_id: @statuses.last.id)}") + pagination_url(max_id: @statuses.last.id) + end + + def newer_url + pagination_url(min_id: @statuses.first.id) + end + + def pagination_url(max_id: nil, min_id: nil) if media_requested? - short_account_media_url(@account, max_id: @statuses.last.id) + short_account_media_url(@account, max_id: max_id, min_id: min_id) elsif replies_requested? - short_account_with_replies_url(@account, max_id: @statuses.last.id) + short_account_with_replies_url(@account, max_id: max_id, min_id: min_id) else - short_account_url(@account, max_id: @statuses.last.id) + short_account_url(@account, max_id: max_id, min_id: min_id) end end @@ -86,4 +100,12 @@ class AccountsController < ApplicationController def replies_requested? request.path.ends_with?('/with_replies') end + + def filtered_status_page(params) + if params[:min_id].present? + filtered_statuses.paginate_by_min_id(PAGE_SIZE, params[:min_id]).reverse + else + filtered_statuses.paginate_by_max_id(PAGE_SIZE, params[:max_id], params[:since_id]).to_a + end + end end diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb new file mode 100644 index 000000000..081914016 --- /dev/null +++ b/app/controllers/activitypub/collections_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class ActivityPub::CollectionsController < Api::BaseController + include SignatureVerification + + before_action :set_account + before_action :set_size + before_action :set_statuses + + def show + render json: collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json', + skip_activities: true + end + + private + + def set_account + @account = Account.find_local!(params[:account_username]) + end + + def set_statuses + @statuses = scope_for_collection.paginate_by_max_id(20, params[:max_id], params[:since_id]) + @statuses = cache_collection(@statuses, Status) + end + + def set_size + case params[:id] + when 'featured' + @account.pinned_statuses.count + else + raise ActiveRecord::NotFound + end + end + + def scope_for_collection + case params[:id] + when 'featured' + @account.statuses.permitted_for(@account, signed_request_account).tap do |scope| + scope.merge!(@account.pinned_statuses) + end + else + raise ActiveRecord::NotFound + end + end + + def collection_presenter + ActivityPub::CollectionPresenter.new( + id: account_collection_url(@account, params[:id]), + type: :ordered, + size: @size, + items: @statuses + ) + end +end diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index 7d0bc74d3..af51e32d5 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -11,7 +11,7 @@ class ActivityPub::InboxesController < Api::BaseController process_payload head 202 else - [signature_verification_failure_reason, 401] + render plain: signature_verification_failure_reason, status: 401 end end diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index 9f97ff622..9ed700c1e 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -1,13 +1,15 @@ # frozen_string_literal: true class ActivityPub::OutboxesController < Api::BaseController + include SignatureVerification + before_action :set_account def show - @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) + @statuses = @account.statuses.permitted_for(@account, signed_request_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses, Status) - render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end private diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index 487282dc3..ce3208209 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -16,9 +16,11 @@ module Admin show_staff_badge bootstrap_timeline_accounts thumbnail + hero min_invite_role activity_api_enabled peers_api_enabled + show_known_fediverse_at_about_page ).freeze BOOLEAN_SETTINGS = %w( @@ -28,10 +30,12 @@ module Admin show_staff_badge activity_api_enabled peers_api_enabled + show_known_fediverse_at_about_page ).freeze UPLOAD_SETTINGS = %w( thumbnail + hero ).freeze def edit diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 52e68ab35..7b5168b31 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -51,6 +51,10 @@ class Api::BaseController < ApplicationController [params[:limit].to_i.abs, default_limit * 2].min end + def truthy_param?(key) + ActiveModel::Type::Boolean.new.cast(params[key]) + end + def current_resource_owner @current_user ||= User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token end diff --git a/app/controllers/api/salmon_controller.rb b/app/controllers/api/salmon_controller.rb index 143e9d3cd..ac5f3268d 100644 --- a/app/controllers/api/salmon_controller.rb +++ b/app/controllers/api/salmon_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::SalmonController < Api::BaseController + include SignatureVerification + before_action :set_account respond_to :txt @@ -9,7 +11,7 @@ class Api::SalmonController < Api::BaseController process_salmon head 202 elsif payload.present? - [signature_verification_failure_reason, 401] + render plain: signature_verification_failure_reason, status: 401 else head 400 end diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index da534d960..68af22529 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -20,6 +20,6 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController private def account_params - params.permit(:display_name, :note, :avatar, :header) + params.permit(:display_name, :note, :avatar, :header, :locked) end end diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb index 91a942d75..70236d1a8 100644 --- a/app/controllers/api/v1/accounts/relationships_controller.rb +++ b/app/controllers/api/v1/accounts/relationships_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController accounts = Account.where(id: account_ids).select('id') # .where doesn't guarantee that our results are in the same order # we requested them, so return the "right" order to the requestor. - @accounts = accounts.index_by(&:id).values_at(*account_ids) + @accounts = accounts.index_by(&:id).values_at(*account_ids).compact render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships end @@ -21,6 +21,6 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController end def account_ids - @_account_ids ||= Array(params[:id]).map(&:to_i) + Array(params[:id]).map(&:to_i) end end diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb index 11e647c3c..7649da433 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -22,8 +22,4 @@ class Api::V1::Accounts::SearchController < Api::BaseController following: truthy_param?(:following) ) end - - def truthy_param?(key) - params[key] == 'true' - end end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 095f6937b..1e1511a7b 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -28,9 +28,9 @@ class Api::V1::Accounts::StatusesController < Api::BaseController def account_statuses default_statuses.tap do |statuses| - statuses.merge!(only_media_scope) if params[:only_media] - statuses.merge!(pinned_scope) if params[:pinned] - statuses.merge!(no_replies_scope) if params[:exclude_replies] + statuses.merge!(only_media_scope) if truthy_param?(:only_media) + statuses.merge!(pinned_scope) if truthy_param?(:pinned) + statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies) end end @@ -51,7 +51,13 @@ class Api::V1::Accounts::StatusesController < Api::BaseController end def account_media_status_ids - @account.media_attachments.attached.reorder(nil).select(:status_id).distinct + # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids. + # Also, Avoid getting slow by not narrowing down by `statuses.account_id`. + # When narrowing down by `statuses.account_id`, `index_statuses_20180106` will be used + # and the table will be joined by `Merge Semi Join`, so the query will be slow. + Status.joins(:media_attachments).merge(@account.media_attachments).permitted_for(@account, current_account) + .paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) + .reorder(id: :desc).distinct(:id).pluck(:id) end def pinned_scope diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 4e73e9e8b..d64325944 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -13,9 +13,9 @@ class Api::V1::AccountsController < Api::BaseController end def follow - FollowService.new.call(current_user.account, @account.acct, reblogs: params[:reblogs]) + FollowService.new.call(current_user.account, @account.acct, reblogs: truthy_param?(:reblogs)) - options = @account.locked? ? {} : { following_map: { @account.id => { reblogs: params[:reblogs] } }, requested_map: { @account.id => false } } + options = @account.locked? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } } render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) end @@ -26,7 +26,7 @@ class Api::V1::AccountsController < Api::BaseController end def mute - MuteService.new.call(current_user.account, @account, notifications: params[:notifications]) + MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications)) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index 9f330f0df..d4e6337e7 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -27,7 +27,7 @@ class Api::V1::MediaController < Api::BaseController private def media_params - params.permit(:file, :description) + params.permit(:file, :description, :focus) end def file_type_error diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 22828217d..f5095e073 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -13,14 +13,14 @@ class Api::V1::ReportsController < Api::BaseController end def create - @report = current_account.reports.create!( - target_account: reported_account, + @report = ReportService.new.call( + current_account, + reported_account, status_ids: reported_status_ids, - comment: report_params[:comment] + comment: report_params[:comment], + forward: report_params[:forward] ) - User.staff.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later } - render json: @report, serializer: REST::ReportSerializer end @@ -39,6 +39,6 @@ class Api::V1::ReportsController < Api::BaseController end def report_params - params.permit(:account_id, :comment, status_ids: []) + params.permit(:account_id, :comment, :forward, status_ids: []) end end diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb index 997eed6e2..05754d0f2 100644 --- a/app/controllers/api/v1/search_controller.rb +++ b/app/controllers/api/v1/search_controller.rb @@ -33,12 +33,8 @@ class Api::V1::SearchController < Api::BaseController SearchService.new.call( params[:q], RESULTS_LIMIT, - resolving_search?, + truthy_param?(:resolve), current_account ) end - - def resolving_search? - params[:resolve] == 'true' - end end diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb index 3de1009b8..bba6a6f48 100644 --- a/app/controllers/api/v1/statuses/pins_controller.rb +++ b/app/controllers/api/v1/statuses/pins_controller.rb @@ -11,12 +11,18 @@ class Api::V1::Statuses::PinsController < Api::BaseController def create StatusPin.create!(account: current_account, status: @status) + distribute_add_activity! render json: @status, serializer: REST::StatusSerializer end def destroy pin = StatusPin.find_by(account: current_account, status: @status) - pin&.destroy! + + if pin + pin.destroy! + distribute_remove_activity! + end + render json: @status, serializer: REST::StatusSerializer end @@ -25,4 +31,24 @@ class Api::V1::Statuses::PinsController < Api::BaseController def set_status @status = Status.find(params[:status_id]) end + + def distribute_add_activity! + json = ActiveModelSerializers::SerializableResource.new( + @status, + serializer: ActivityPub::AddSerializer, + adapter: ActivityPub::Adapter + ).as_json + + ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account) + end + + def distribute_remove_activity! + json = ActiveModelSerializers::SerializableResource.new( + @status, + serializer: ActivityPub::RemoveSerializer, + adapter: ActivityPub::Adapter + ).as_json + + ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account) + end end diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index 49887778e..d7d70b94d 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -21,15 +21,23 @@ class Api::V1::Timelines::PublicController < Api::BaseController end def public_statuses - public_timeline_statuses.paginate_by_max_id( + statuses = public_timeline_statuses.paginate_by_max_id( limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id] ) + + if truthy_param?(:only_media) + # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids. + status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id) + statuses.where(id: status_ids) + else + statuses + end end def public_timeline_statuses - Status.as_public_timeline(current_account, params[:local]) + Status.as_public_timeline(current_account, truthy_param?(:local)) end def insert_pagination_headers @@ -37,7 +45,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController end def pagination_params(core_params) - params.permit(:local, :limit).merge(core_params) + params.permit(:local, :limit, :only_media).merge(core_params) end def next_path diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 08db04a39..eb32611ad 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -29,16 +29,24 @@ class Api::V1::Timelines::TagController < Api::BaseController if @tag.nil? [] else - tag_timeline_statuses.paginate_by_max_id( + statuses = tag_timeline_statuses.paginate_by_max_id( limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id] ) + + if truthy_param?(:only_media) + # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids. + status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id) + statuses.where(id: status_ids) + else + statuses + end end end def tag_timeline_statuses - Status.as_tag_timeline(@tag, current_account, params[:local]) + Status.as_tag_timeline(@tag, current_account, truthy_param?(:local)) end def insert_pagination_headers @@ -46,7 +54,7 @@ class Api::V1::Timelines::TagController < Api::BaseController end def pagination_params(core_params) - params.permit(:local, :limit).merge(core_params) + params.permit(:local, :limit, :only_media).merge(core_params) end def next_path diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e1aae0b67..6e5042617 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -14,6 +14,7 @@ class ApplicationController < ActionController::Base helper_method :current_session helper_method :current_theme helper_method :single_user_mode? + helper_method :use_seamless_external_login? rescue_from ActionController::RoutingError, with: :not_found rescue_from ActiveRecord::RecordNotFound, with: :not_found @@ -34,7 +35,7 @@ class ApplicationController < ActionController::Base end def store_current_location - store_location_for(:user, request.url) + store_location_for(:user, request.url) unless request.format == :json end def require_admin! @@ -75,6 +76,10 @@ class ApplicationController < ActionController::Base @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.exists? end + def use_seamless_external_login? + Devise.pam_authentication || Devise.ldap_authentication + end + def current_account @current_account ||= current_user.try(:account) end diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb index 2fdb281f4..a240425cd 100644 --- a/app/controllers/auth/confirmations_controller.rb +++ b/app/controllers/auth/confirmations_controller.rb @@ -2,4 +2,28 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController layout 'auth' + + before_action :set_user, only: [:finish_signup] + + # GET/PATCH /users/:id/finish_signup + def finish_signup + return unless request.patch? && params[:user] + if @user.update(user_params) + @user.skip_reconfirmation! + sign_in(@user, bypass: true) + redirect_to root_path, notice: I18n.t('devise.confirmations.send_instructions') + else + @show_errors = true + end + end + + private + + def set_user + @user = current_user + end + + def user_params + params.require(:user).permit(:email) + end end diff --git a/app/controllers/auth/omniauth_callbacks_controller.rb b/app/controllers/auth/omniauth_callbacks_controller.rb new file mode 100644 index 000000000..bbf63bed3 --- /dev/null +++ b/app/controllers/auth/omniauth_callbacks_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController + skip_before_action :verify_authenticity_token + + def self.provides_callback_for(provider) + provider_id = provider.to_s.chomp '_oauth2' + + define_method provider do + @user = User.find_for_oauth(request.env['omniauth.auth'], current_user) + + if @user.persisted? + sign_in_and_redirect @user, event: :authentication + set_flash_message(:notice, :success, kind: provider_id.capitalize) if is_navigational_format? + else + session["devise.#{provider}_data"] = request.env['omniauth.auth'] + redirect_to new_user_registration_url + end + end + end + + Devise.omniauth_configs.each_key do |provider| + provides_callback_for provider + end + + def after_sign_in_path_for(resource) + if resource.email_verified? + root_path + else + finish_signup_path + end + end +end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index b8ff4e54f..417e2b63b 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -14,6 +14,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController protected + def update_resource(resource, params) + params[:password] = nil if Devise.pam_authentication && resource.encrypted_password.blank? + super + end + def build_resource(hash = nil) super(hash) diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index a5acb6c36..c1ebe760c 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -10,6 +10,14 @@ class Auth::SessionsController < Devise::SessionsController prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] before_action :set_instance_presenter, only: [:new] + def new + Devise.omniauth_configs.each do |provider, config| + return redirect_to(omniauth_authorize_path(resource_name, provider)) if config.strategy.redirect_at_sign_in + end + + super + end + def create super do |resource| remember_me(resource) @@ -28,7 +36,11 @@ class Auth::SessionsController < Devise::SessionsController if session[:otp_user_id] User.find(session[:otp_user_id]) elsif user_params[:email] - User.find_for_authentication(email: user_params[:email]) + if use_seamless_external_login? && Devise.check_at_sign && user_params[:email].index('@').nil? + User.joins(:account).find_by(accounts: { username: user_params[:email] }) + else + User.find_for_authentication(email: user_params[:email]) + end end end @@ -46,6 +58,14 @@ class Auth::SessionsController < Devise::SessionsController end end + def after_sign_out_path_for(_resource_or_scope) + Devise.omniauth_configs.each_value do |config| + return root_path if config.strategy.redirect_at_sign_in + end + + super + end + def two_factor_enabled? find_user.try(:otp_required_for_login?) end diff --git a/app/controllers/concerns/localized.rb b/app/controllers/concerns/localized.rb index a9ea60f7d..abd85ea27 100644 --- a/app/controllers/concerns/localized.rb +++ b/app/controllers/concerns/localized.rb @@ -17,11 +17,11 @@ module Localized end def default_locale - request_locale || env_locale || I18n.default_locale - end - - def env_locale - ENV['DEFAULT_LOCALE'] + if ENV['DEFAULT_LOCALE'].present? + I18n.default_locale + else + request_locale || I18n.default_locale + end end def request_locale @@ -29,12 +29,10 @@ module Localized end def preferred_locale - http_accept_language.preferred_language_from([env_locale]) || - http_accept_language.preferred_language_from(I18n.available_locales) + http_accept_language.preferred_language_from(I18n.available_locales) end def compatible_locale - http_accept_language.compatible_language_from([env_locale]) || - http_accept_language.compatible_language_from(I18n.available_locales) + http_accept_language.compatible_language_from(I18n.available_locales) end end diff --git a/app/controllers/concerns/signature_authentication.rb b/app/controllers/concerns/signature_authentication.rb new file mode 100644 index 000000000..beec93223 --- /dev/null +++ b/app/controllers/concerns/signature_authentication.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module SignatureAuthentication + extend ActiveSupport::Concern + + include SignatureVerification + + def current_account + super || signed_request_account + end +end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 399e79665..2d2315034 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -7,7 +7,9 @@ class FollowerAccountsController < ApplicationController @follows = Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account) respond_to do |format| - format.html + format.html do + @relationships = AccountRelationshipsPresenter.new(@follows.map(&:account_id), current_user.account_id) if user_signed_in? + end format.json do render json: collection_presenter, diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 1e73d4bd4..169f9057d 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -7,7 +7,9 @@ class FollowingAccountsController < ApplicationController @follows = Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account) respond_to do |format| - format.html + format.html do + @relationships = AccountRelationshipsPresenter.new(@follows.map(&:target_account_id), current_user.account_id) if user_signed_in? + end format.json do render json: collection_presenter, diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 21dde20ce..b1f8f1ad9 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -34,7 +34,8 @@ class HomeController < ApplicationController end end - redirect_to(default_redirect_path) + matches = request.path.match(%r{\A/web/timelines/tag/(?.+)\z}) + redirect_to(matches ? tag_path(CGI.unescape(matches[:tag])) : default_redirect_path) end def set_initial_state_json diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index f652f5ace..88c7232dd 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -3,20 +3,26 @@ class MediaController < ApplicationController include Authorization - before_action :verify_permitted_status + before_action :set_media_attachment + before_action :verify_permitted_status! def show - redirect_to media_attachment.file.url(:original) + redirect_to @media_attachment.file.url(:original) + end + + def player + @body_classes = 'player' + raise ActiveRecord::RecordNotFound unless @media_attachment.video? || @media_attachment.gifv? end private - def media_attachment - MediaAttachment.attached.find_by!(shortcode: params[:id]) + def set_media_attachment + @media_attachment = MediaAttachment.attached.find_by!(shortcode: params[:id] || params[:medium_id]) end - def verify_permitted_status - authorize media_attachment.status, :show? + def verify_permitted_status! + authorize @media_attachment.status, :show? rescue Mastodon::NotPermittedError # Reraise in order to get a 404 instead of a 403 error code raise ActiveRecord::RecordNotFound diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb index ae62f00c1..869e11d3b 100644 --- a/app/controllers/settings/exports_controller.rb +++ b/app/controllers/settings/exports_controller.rb @@ -1,11 +1,23 @@ # frozen_string_literal: true class Settings::ExportsController < ApplicationController + include Authorization + layout 'admin' before_action :authenticate_user! def show - @export = Export.new(current_account) + @export = Export.new(current_account) + @backups = current_user.backups + end + + def create + authorize :backup, :create? + + backup = current_user.backups.create! + BackupWorker.perform_async(backup.id) + + redirect_to settings_export_path end end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 069026715..839763138 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -39,6 +39,7 @@ class Settings::PreferencesController < ApplicationController :setting_boost_modal, :setting_delete_modal, :setting_auto_play_gif, + :setting_display_sensitive_media, :setting_reduce_motion, :setting_system_font_ui, :setting_noindex, diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 367ea34e7..45226c8d2 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class StatusesController < ApplicationController + include SignatureAuthentication include Authorization layout 'public' diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index cc579dbc8..f81856cc6 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -10,6 +10,7 @@ class StreamEntriesController < ApplicationController before_action :set_stream_entry before_action :set_link_headers before_action :check_account_suspension + before_action :set_cache_headers def show respond_to do |format| @@ -19,6 +20,10 @@ class StreamEntriesController < ApplicationController end format.atom do + unless @stream_entry.hidden? + skip_session! + expires_in 3.minutes, public: true + end render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.entry(@stream_entry, true)) end end diff --git a/app/helpers/instance_helper.rb b/app/helpers/instance_helper.rb index 22a19c52b..dd0b25f3e 100644 --- a/app/helpers/instance_helper.rb +++ b/app/helpers/instance_helper.rb @@ -2,7 +2,7 @@ module InstanceHelper def site_title - Setting.site_title.presence || site_hostname + Setting.site_title end def site_hostname diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index 445114985..3992432db 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -8,6 +8,56 @@ module StreamEntriesHelper account.display_name.presence || account.username end + def account_description(account) + prepend_str = [ + [ + number_to_human(account.statuses_count, strip_insignificant_zeros: true), + t('accounts.posts'), + ].join(' '), + + [ + number_to_human(account.following_count, strip_insignificant_zeros: true), + t('accounts.following'), + ].join(' '), + + [ + number_to_human(account.followers_count, strip_insignificant_zeros: true), + t('accounts.followers'), + ].join(' '), + ].join(', ') + + [prepend_str, account.note].join(' · ') + end + + def media_summary(status) + attachments = { image: 0, video: 0 } + + status.media_attachments.each do |media| + if media.video? + attachments[:video] += 1 + else + attachments[:image] += 1 + end + end + + text = attachments.to_a.reject { |_, value| value.zero? }.map { |key, value| t("statuses.attached.#{key}", count: value) }.join(' · ') + + return if text.blank? + + t('statuses.attached.description', attached: text) + end + + def status_text_summary(status) + return if status.spoiler_text.blank? + t('statuses.content_warning', warning: status.spoiler_text) + end + + def status_description(status) + components = [[media_summary(status), status_text_summary(status)].reject(&:blank?).join(' · ')] + components << status.text if status.spoiler_text.blank? + components.reject(&:blank?).join("\n\n") + end + def stream_link_target embedded_view? ? '_blank' : nil end diff --git a/app/javascript/images/icon_file_download.svg b/app/javascript/images/icon_file_download.svg new file mode 100644 index 000000000..53e97e4f8 --- /dev/null +++ b/app/javascript/images/icon_file_download.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/javascript/images/mailer/icon_file_download.png b/app/javascript/images/mailer/icon_file_download.png new file mode 100644 index 000000000..8a6a8673b Binary files /dev/null and b/app/javascript/images/mailer/icon_file_download.png differ diff --git a/app/javascript/images/reticle.png b/app/javascript/images/reticle.png new file mode 100644 index 000000000..998994f5c Binary files /dev/null and b/app/javascript/images/reticle.png differ diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 8a35049b3..1371f22b2 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -1,6 +1,8 @@ import api from '../api'; +import { CancelToken } from 'axios'; import { throttle } from 'lodash'; import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; +import { tagHistory } from '../settings'; import { useEmoji } from './emojis'; import { @@ -10,6 +12,8 @@ import { refreshPublicTimeline, } from './timelines'; +let cancelFetchComposeSuggestionsAccounts; + export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; @@ -27,6 +31,9 @@ export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; +export const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE'; + +export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE'; export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; @@ -92,8 +99,9 @@ export function mentionCompose(account, router) { export function submitCompose() { return function (dispatch, getState) { const status = getState().getIn(['compose', 'text'], ''); + const media = getState().getIn(['compose', 'media_attachments']); - if (!status || !status.length) { + if ((!status || !status.length) && media.size === 0) { return; } @@ -102,7 +110,7 @@ export function submitCompose() { api(getState).post('/api/v1/statuses', { status, in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), - media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')), + media_ids: media.map(item => item.get('id')), sensitive: getState().getIn(['compose', 'sensitive']), spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), visibility: getState().getIn(['compose', 'privacy']), @@ -111,6 +119,7 @@ export function submitCompose() { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), }, }).then(function (response) { + dispatch(insertIntoTagHistory(response.data.tags)); dispatch(submitComposeSuccess({ ...response.data })); // To make the app more responsive, immediately get the status into the columns @@ -178,11 +187,11 @@ export function uploadCompose(files) { }; }; -export function changeUploadCompose(id, description) { +export function changeUploadCompose(id, params) { return (dispatch, getState) => { dispatch(changeUploadComposeRequest()); - api(getState).put(`/api/v1/media/${id}`, { description }).then(response => { + api(getState).put(`/api/v1/media/${id}`, params).then(response => { dispatch(changeUploadComposeSuccess(response.data)); }).catch(error => { dispatch(changeUploadComposeFail(id, error)); @@ -251,13 +260,22 @@ export function undoUploadCompose(media_id) { }; export function clearComposeSuggestions() { + if (cancelFetchComposeSuggestionsAccounts) { + cancelFetchComposeSuggestionsAccounts(); + } return { type: COMPOSE_SUGGESTIONS_CLEAR, }; }; const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { + if (cancelFetchComposeSuggestionsAccounts) { + cancelFetchComposeSuggestionsAccounts(); + } api(getState).get('/api/v1/accounts/search', { + cancelToken: new CancelToken(cancel => { + cancelFetchComposeSuggestionsAccounts = cancel; + }), params: { q: token.slice(1), resolve: false, @@ -273,12 +291,22 @@ const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { dispatch(readyComposeSuggestionsEmojis(token, results)); }; +const fetchComposeSuggestionsTags = (dispatch, getState, token) => { + dispatch(updateSuggestionTags(token)); +}; + export function fetchComposeSuggestions(token) { return (dispatch, getState) => { - if (token[0] === ':') { + switch (token[0]) { + case ':': fetchComposeSuggestionsEmojis(dispatch, getState, token); - } else { + break; + case '#': + fetchComposeSuggestionsTags(dispatch, getState, token); + break; + default: fetchComposeSuggestionsAccounts(dispatch, getState, token); + break; } }; }; @@ -308,6 +336,9 @@ export function selectComposeSuggestion(position, token, suggestion) { startPosition = position - 1; dispatch(useEmoji(suggestion)); + } else if (suggestion[0] === '#') { + completion = suggestion; + startPosition = position - 1; } else { completion = getState().getIn(['accounts', suggestion, 'acct']); startPosition = position; @@ -322,6 +353,48 @@ export function selectComposeSuggestion(position, token, suggestion) { }; }; +export function updateSuggestionTags(token) { + return { + type: COMPOSE_SUGGESTION_TAGS_UPDATE, + token, + }; +} + +export function updateTagHistory(tags) { + return { + type: COMPOSE_TAG_HISTORY_UPDATE, + tags, + }; +} + +export function hydrateCompose() { + return (dispatch, getState) => { + const me = getState().getIn(['meta', 'me']); + const history = tagHistory.get(me); + + if (history !== null) { + dispatch(updateTagHistory(history)); + } + }; +} + +function insertIntoTagHistory(tags) { + return (dispatch, getState) => { + const state = getState(); + const oldHistory = state.getIn(['compose', 'tagHistory']); + const me = state.getIn(['meta', 'me']); + const names = tags.map(({ name }) => name); + const intersectedOldHistory = oldHistory.filter(name => !names.includes(name)); + + names.push(...intersectedOldHistory.toJS()); + + const newHistory = names.slice(0, 1000); + + tagHistory.set(me, newHistory); + dispatch(updateTagHistory(newHistory)); + }; +} + export function mountCompose() { return { type: COMPOSE_MOUNT, diff --git a/app/javascript/mastodon/actions/dropdown_menu.js b/app/javascript/mastodon/actions/dropdown_menu.js new file mode 100644 index 000000000..217ba4e74 --- /dev/null +++ b/app/javascript/mastodon/actions/dropdown_menu.js @@ -0,0 +1,10 @@ +export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN'; +export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE'; + +export function openDropdownMenu(id, placement) { + return { type: DROPDOWN_MENU_OPEN, id, placement }; +} + +export function closeDropdownMenu(id) { + return { type: DROPDOWN_MENU_CLOSE, id }; +} diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 7b5f4bd9c..10e66910a 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -62,6 +62,7 @@ export function reblogRequest(status) { return { type: REBLOG_REQUEST, status: status, + skipLoading: true, }; }; @@ -70,6 +71,7 @@ export function reblogSuccess(status, response) { type: REBLOG_SUCCESS, status: status, response: response, + skipLoading: true, }; }; @@ -78,6 +80,7 @@ export function reblogFail(status, error) { type: REBLOG_FAIL, status: status, error: error, + skipLoading: true, }; }; @@ -85,6 +88,7 @@ export function unreblogRequest(status) { return { type: UNREBLOG_REQUEST, status: status, + skipLoading: true, }; }; @@ -93,6 +97,7 @@ export function unreblogSuccess(status, response) { type: UNREBLOG_SUCCESS, status: status, response: response, + skipLoading: true, }; }; @@ -101,6 +106,7 @@ export function unreblogFail(status, error) { type: UNREBLOG_FAIL, status: status, error: error, + skipLoading: true, }; }; @@ -132,6 +138,7 @@ export function favouriteRequest(status) { return { type: FAVOURITE_REQUEST, status: status, + skipLoading: true, }; }; @@ -140,6 +147,7 @@ export function favouriteSuccess(status, response) { type: FAVOURITE_SUCCESS, status: status, response: response, + skipLoading: true, }; }; @@ -148,6 +156,7 @@ export function favouriteFail(status, error) { type: FAVOURITE_FAIL, status: status, error: error, + skipLoading: true, }; }; @@ -155,6 +164,7 @@ export function unfavouriteRequest(status) { return { type: UNFAVOURITE_REQUEST, status: status, + skipLoading: true, }; }; @@ -163,6 +173,7 @@ export function unfavouriteSuccess(status, response) { type: UNFAVOURITE_SUCCESS, status: status, response: response, + skipLoading: true, }; }; @@ -171,6 +182,7 @@ export function unfavouriteFail(status, error) { type: UNFAVOURITE_FAIL, status: status, error: error, + skipLoading: true, }; }; @@ -258,6 +270,7 @@ export function pinRequest(status) { return { type: PIN_REQUEST, status, + skipLoading: true, }; }; @@ -266,6 +279,7 @@ export function pinSuccess(status, response) { type: PIN_SUCCESS, status, response, + skipLoading: true, }; }; @@ -274,6 +288,7 @@ export function pinFail(status, error) { type: PIN_FAIL, status, error, + skipLoading: true, }; }; @@ -293,6 +308,7 @@ export function unpinRequest(status) { return { type: UNPIN_REQUEST, status, + skipLoading: true, }; }; @@ -301,6 +317,7 @@ export function unpinSuccess(status, response) { type: UNPIN_SUCCESS, status, response, + skipLoading: true, }; }; @@ -309,5 +326,6 @@ export function unpinFail(status, error) { type: UNPIN_FAIL, status, error, + skipLoading: true, }; }; diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 502690045..cf9242d0f 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -24,7 +24,7 @@ defineMessages({ const fetchRelatedRelationships = (dispatch, notifications) => { const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); - if (accountIds > 0) { + if (accountIds.length > 0) { dispatch(fetchRelationships(accountIds)); } }; diff --git a/app/javascript/mastodon/actions/reports.js b/app/javascript/mastodon/actions/reports.js index b19a07285..afa0c3412 100644 --- a/app/javascript/mastodon/actions/reports.js +++ b/app/javascript/mastodon/actions/reports.js @@ -10,6 +10,7 @@ export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL'; export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE'; +export const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE'; export function initReport(account, status) { return dispatch => { @@ -45,6 +46,7 @@ export function submitReport() { account_id: getState().getIn(['reports', 'new', 'account_id']), status_ids: getState().getIn(['reports', 'new', 'status_ids']), comment: getState().getIn(['reports', 'new', 'comment']), + forward: getState().getIn(['reports', 'new', 'forward']), }).then(response => { dispatch(closeModal()); dispatch(submitReportSuccess(response.data)); @@ -78,3 +80,10 @@ export function changeReportComment(comment) { comment, }; }; + +export function changeReportForward(forward) { + return { + type: REPORT_FORWARD_CHANGE, + forward, + }; +}; diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js index 78c6109f7..73cb106ec 100644 --- a/app/javascript/mastodon/actions/search.js +++ b/app/javascript/mastodon/actions/search.js @@ -1,4 +1,5 @@ import api from '../api'; +import { fetchRelationships } from './accounts'; export const SEARCH_CHANGE = 'SEARCH_CHANGE'; export const SEARCH_CLEAR = 'SEARCH_CLEAR'; @@ -38,6 +39,7 @@ export function submitSearch() { }, }).then(response => { dispatch(fetchSearchSuccess(response.data)); + dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); }).catch(error => { dispatch(fetchSearchFail(error)); }); diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 2204e0b14..073f09883 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -23,6 +23,9 @@ export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST'; export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; +export const STATUS_REVEAL = 'STATUS_REVEAL'; +export const STATUS_HIDE = 'STATUS_HIDE'; + export function fetchStatusRequest(id, skipLoading) { return { type: STATUS_FETCH_REQUEST, @@ -215,3 +218,25 @@ export function unmuteStatusFail(id, error) { error, }; }; + +export function hideStatus(ids) { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: STATUS_HIDE, + ids, + }; +}; + +export function revealStatus(ids) { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: STATUS_REVEAL, + ids, + }; +}; diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js index a1db0fdd5..2dd94a998 100644 --- a/app/javascript/mastodon/actions/store.js +++ b/app/javascript/mastodon/actions/store.js @@ -1,4 +1,5 @@ import { Iterable, fromJS } from 'immutable'; +import { hydrateCompose } from './compose'; export const STORE_HYDRATE = 'STORE_HYDRATE'; export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; @@ -8,10 +9,14 @@ const convertState = rawState => Iterable.isIndexed(v) ? v.toList() : v.toMap()); export function hydrateStore(rawState) { - const state = convertState(rawState); + return dispatch => { + const state = convertState(rawState); - return { - type: STORE_HYDRATE, - state, + dispatch({ + type: STORE_HYDRATE, + state, + }); + + dispatch(hydrateCompose()); }; }; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index df6a36379..f0ab16a2d 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -117,13 +117,14 @@ export function refreshTimeline(timelineId, path, params = {}) { }; }; -export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home'); -export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public'); -export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true }); -export const refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); -export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); -export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); -export const refreshListTimeline = id => refreshTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`); +export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home'); +export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public'); +export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true }); +export const refreshAccountTimeline = (accountId, withReplies) => refreshTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies }); +export const refreshAccountFeaturedTimeline = accountId => refreshTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); +export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); +export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); +export const refreshListTimeline = id => refreshTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`); export function refreshTimelineFail(timeline, error, skipLoading) { return { @@ -161,7 +162,7 @@ export function expandTimeline(timelineId, path, params = {}) { export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home'); export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public'); export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true }); -export const expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); +export const expandAccountTimeline = (accountId, withReplies) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies }); export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); export const expandListTimeline = id => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`); diff --git a/app/javascript/mastodon/base_polyfills.js b/app/javascript/mastodon/base_polyfills.js index 7856b26f9..8fbb17785 100644 --- a/app/javascript/mastodon/base_polyfills.js +++ b/app/javascript/mastodon/base_polyfills.js @@ -3,6 +3,7 @@ import 'intl/locale-data/jsonp/en'; import 'es6-symbol/implement'; import includes from 'array-includes'; import assign from 'object-assign'; +import values from 'object.values'; import isNaN from 'is-nan'; if (!Array.prototype.includes) { @@ -13,6 +14,10 @@ if (!Object.assign) { Object.assign = assign; } +if (!Object.values) { + values.shim(); +} + if (!Number.isNaN) { Number.isNaN = isNaN; } diff --git a/app/javascript/mastodon/components/attachment_list.js b/app/javascript/mastodon/components/attachment_list.js index 9f2d46ddd..8e5bb0e0b 100644 --- a/app/javascript/mastodon/components/attachment_list.js +++ b/app/javascript/mastodon/components/attachment_list.js @@ -1,5 +1,6 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; import ImmutablePureComponent from 'react-immutable-pure-component'; const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; @@ -8,10 +9,29 @@ export default class AttachmentList extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.list.isRequired, + compact: PropTypes.bool, }; render () { - const { media } = this.props; + const { media, compact } = this.props; + + if (compact) { + return ( +
+
    + {media.map(attachment => { + const displayUrl = attachment.get('remote_url') || attachment.get('url'); + + return ( +
  • + {filename(displayUrl)} +
  • + ); + })} +
+
+ ); + } return (
@@ -20,11 +40,15 @@ export default class AttachmentList extends ImmutablePureComponent {
); diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index 6a16e2fc7..34904194f 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -20,7 +20,7 @@ const textAtCursorMatchesToken = (str, caretPosition) => { word = str.slice(left, right + caretPosition); } - if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) { + if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) { return [null, null]; } @@ -170,6 +170,9 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { if (typeof suggestion === 'object') { inner = ; key = suggestion.id; + } else if (suggestion[0] === '#') { + inner = suggestion; + key = suggestion; } else { inner = ; key = suggestion; diff --git a/app/javascript/mastodon/components/column_back_button_slim.js b/app/javascript/mastodon/components/column_back_button_slim.js index 3b4f46d99..964c100be 100644 --- a/app/javascript/mastodon/components/column_back_button_slim.js +++ b/app/javascript/mastodon/components/column_back_button_slim.js @@ -1,17 +1,8 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; -import PropTypes from 'prop-types'; +import ColumnBackButton from './column_back_button'; -export default class ColumnBackButtonSlim extends React.PureComponent { - - static contextTypes = { - router: PropTypes.object, - }; - - handleClick = () => { - if (window.history && window.history.length === 1) this.context.router.history.push('/'); - else this.context.router.history.goBack(); - } +export default class ColumnBackButtonSlim extends ColumnBackButton { render () { return ( diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index c300db89b..56453aeac 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -19,10 +19,11 @@ export default class ColumnHeader extends React.PureComponent { static propTypes = { intl: PropTypes.object.isRequired, - title: PropTypes.node.isRequired, - icon: PropTypes.string.isRequired, + title: PropTypes.node, + icon: PropTypes.string, active: PropTypes.bool, multiColumn: PropTypes.bool, + extraButton: PropTypes.node, showBackButton: PropTypes.bool, children: PropTypes.node, pinned: PropTypes.bool, @@ -63,7 +64,7 @@ export default class ColumnHeader extends React.PureComponent { } render () { - const { title, icon, active, children, pinned, onPin, multiColumn, showBackButton, intl: { formatMessage } } = this.props; + const { title, icon, active, children, pinned, onPin, multiColumn, extraButton, showBackButton, intl: { formatMessage } } = this.props; const { collapsed, animating } = this.state; const wrapperClassName = classNames('column-header__wrapper', { @@ -125,21 +126,26 @@ export default class ColumnHeader extends React.PureComponent { } if (children || multiColumn) { - collapseButton = ; + collapseButton = ; } + const hasTitle = icon && title; + return (

- + + )} + + {!hasTitle && backButton}
- {backButton} + {hasTitle && backButton} + {extraButton} {collapseButton}

diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index 43dc0d6e3..c5c6f73b3 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -8,6 +8,7 @@ import spring from 'react-motion/lib/spring'; import detectPassiveEvents from 'detect-passive-events'; const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; +let id = 0; class DropdownMenu extends React.PureComponent { @@ -29,6 +30,10 @@ class DropdownMenu extends React.PureComponent { placement: 'bottom', }; + state = { + mounted: false, + }; + handleDocumentClick = e => { if (this.node && !this.node.contains(e.target)) { this.props.onClose(); @@ -38,6 +43,7 @@ class DropdownMenu extends React.PureComponent { componentDidMount () { document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + this.setState({ mounted: true }); } componentWillUnmount () { @@ -82,11 +88,15 @@ class DropdownMenu extends React.PureComponent { render () { const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props; + const { mounted } = this.state; return ( {({ opacity, scaleX, scaleY }) => ( -
+ // It should not be transformed when mounting because the resulting + // size will be used to determine the coordinate of the menu by + // react-overlays +
    @@ -115,8 +125,10 @@ export default class Dropdown extends React.PureComponent { status: ImmutablePropTypes.map, isUserTouching: PropTypes.func, isModalOpen: PropTypes.bool.isRequired, - onModalOpen: PropTypes.func, - onModalClose: PropTypes.func, + onOpen: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + dropdownPlacement: PropTypes.string, + openDropdownId: PropTypes.number, }; static defaultProps = { @@ -124,37 +136,28 @@ export default class Dropdown extends React.PureComponent { }; state = { - expanded: false, + id: id++, }; - handleClick = () => { - if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) { - const { status, items } = this.props; + handleClick = ({ target }) => { + if (this.state.id === this.props.openDropdownId) { + this.handleClose(); + } else { + const { top } = target.getBoundingClientRect(); + const placement = top * 2 < innerHeight ? 'bottom' : 'top'; - this.props.onModalOpen({ - status, - actions: items, - onClick: this.handleItemClick, - }); - - return; + this.props.onOpen(this.state.id, this.handleItemClick, placement); } - - this.setState({ expanded: !this.state.expanded }); } handleClose = () => { - if (this.props.onModalClose) { - this.props.onModalClose(); - } - - this.setState({ expanded: false }); + this.props.onClose(this.state.id); } handleKeyDown = e => { switch(e.key) { case 'Enter': - this.handleClick(); + this.handleClick(e); break; case 'Escape': this.handleClose(); @@ -186,22 +189,22 @@ export default class Dropdown extends React.PureComponent { } render () { - const { icon, items, size, title, disabled } = this.props; - const { expanded } = this.state; + const { icon, items, size, title, disabled, dropdownPlacement, openDropdownId } = this.props; + const open = this.state.id === openDropdownId; return (
    - +
    diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js index f8bd067e8..9e2f6835a 100644 --- a/app/javascript/mastodon/components/extended_video_player.js +++ b/app/javascript/mastodon/components/extended_video_player.js @@ -11,6 +11,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { time: PropTypes.number, controls: PropTypes.bool.isRequired, muted: PropTypes.bool.isRequired, + onClick: PropTypes.func, }; handleLoadedData = () => { @@ -31,6 +32,12 @@ export default class ExtendedVideoPlayer extends React.PureComponent { this.video = c; } + handleClick = e => { + e.stopPropagation(); + const handler = this.props.onClick; + if (handler) handler(); + } + render () { const { src, muted, controls, alt } = this.props; @@ -46,6 +53,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { muted={muted} controls={controls} loop={!controls} + onClick={this.handleClick} />
); diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 20febdb16..1cef029d8 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -6,7 +6,7 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from '../is_mobile'; import classNames from 'classnames'; -import { autoPlayGif } from '../initial_state'; +import { autoPlayGif, displaySensitiveMedia } from '../initial_state'; const messages = defineMessages({ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, @@ -116,16 +116,21 @@ class Item extends React.PureComponent { let thumbnail = ''; if (attachment.get('type') === 'image') { - const previewUrl = attachment.get('preview_url'); + const previewUrl = attachment.get('preview_url'); const previewWidth = attachment.getIn(['meta', 'small', 'width']); - const originalUrl = attachment.get('url'); - const originalWidth = attachment.getIn(['meta', 'original', 'width']); + const originalUrl = attachment.get('url'); + const originalWidth = attachment.getIn(['meta', 'original', 'width']); const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null; - const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null; + const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null; + + const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0; + const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0; + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; thumbnail = ( - {attachment.get('description')} + {attachment.get('description')} ); } else if (attachment.get('type') === 'gifv') { @@ -187,7 +199,7 @@ export default class MediaGallery extends React.PureComponent { }; state = { - visible: !this.props.sensitive, + visible: !this.props.sensitive || displaySensitiveMedia, }; componentWillReceiveProps (nextProps) { @@ -205,7 +217,7 @@ export default class MediaGallery extends React.PureComponent { } handleRef = (node) => { - if (node && this.isStandaloneEligible()) { + if (node /*&& this.isStandaloneEligible()*/) { // offsetWidth triggers a layout, so only calculate when we need to this.setState({ width: node.offsetWidth, @@ -227,15 +239,12 @@ export default class MediaGallery extends React.PureComponent { const style = {}; if (this.isStandaloneEligible()) { - if (!visible && width) { - // only need to forcibly set the height in "sensitive" mode + if (width) { style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']); - } else { - // layout automatically, using image's natural aspect ratio - style.height = ''; } + } else if (width) { + style.height = width / (16/9); } else { - // crop the image style.height = height; } @@ -249,7 +258,7 @@ export default class MediaGallery extends React.PureComponent { } children = ( - @@ -265,7 +274,7 @@ export default class MediaGallery extends React.PureComponent { } return ( -
+
diff --git a/app/javascript/mastodon/components/permalink.js b/app/javascript/mastodon/components/permalink.js index d726d37a2..b369e9812 100644 --- a/app/javascript/mastodon/components/permalink.js +++ b/app/javascript/mastodon/components/permalink.js @@ -12,9 +12,15 @@ export default class Permalink extends React.PureComponent { href: PropTypes.string.isRequired, to: PropTypes.string.isRequired, children: PropTypes.node, + onInterceptClick: PropTypes.func, }; - handleClick = (e) => { + handleClick = e => { + if (this.props.onInterceptClick && this.props.onInterceptClick()) { + e.preventDefault(); + return; + } + if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); this.context.router.history.push(this.props.to); @@ -22,7 +28,7 @@ export default class Permalink extends React.PureComponent { } render () { - const { href, children, className, ...other } = this.props; + const { href, children, className, onInterceptClick, ...other } = this.props; return ( diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js index 71228ca6c..ac3e404df 100644 --- a/app/javascript/mastodon/components/scrollable_list.js +++ b/app/javascript/mastodon/components/scrollable_list.js @@ -17,7 +17,7 @@ export default class ScrollableList extends PureComponent { static propTypes = { scrollKey: PropTypes.string.isRequired, - onScrollToBottom: PropTypes.func, + onLoadMore: PropTypes.func.isRequired, onScrollToTop: PropTypes.func, onScroll: PropTypes.func, trackScroll: PropTypes.bool, @@ -45,9 +45,11 @@ export default class ScrollableList extends PureComponent { const offset = scrollHeight - scrollTop - clientHeight; this._oldScrollPosition = scrollHeight - scrollTop; - if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { - this.props.onScrollToBottom(); - } else if (scrollTop < 100 && this.props.onScrollToTop) { + if (400 > offset && this.props.onLoadMore && !this.props.isLoading) { + this.props.onLoadMore(); + } + + if (scrollTop < 100 && this.props.onScrollToTop) { this.props.onScrollToTop(); } else if (this.props.onScroll) { this.props.onScroll(); @@ -138,7 +140,7 @@ export default class ScrollableList extends PureComponent { handleLoadMore = (e) => { e.preventDefault(); - this.props.onScrollToBottom(); + this.props.onLoadMore(); } _recentlyMoved () { diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index c030510a0..a918a94f8 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -7,6 +7,7 @@ import RelativeTimestamp from './relative_timestamp'; import DisplayName from './display_name'; import StatusContent from './status_content'; import StatusActionBar from './status_action_bar'; +import AttachmentList from './attachment_list'; import { FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { MediaGallery, Video } from '../features/ui/util/async-components'; @@ -36,16 +37,13 @@ export default class Status extends ImmutablePureComponent { onBlock: PropTypes.func, onEmbed: PropTypes.func, onHeightChange: PropTypes.func, + onToggleHidden: PropTypes.func, muted: PropTypes.bool, hidden: PropTypes.bool, onMoveUp: PropTypes.func, onMoveDown: PropTypes.func, }; - state = { - isExpanded: false, - } - // Avoid checking props that are functions (and whose equality will always // evaluate to false. See react-immutable-pure-component for usage. updateOnProps = [ @@ -55,8 +53,6 @@ export default class Status extends ImmutablePureComponent { 'hidden', ] - updateOnStates = ['isExpanded'] - handleClick = () => { if (!this.context.router) { return; @@ -75,7 +71,7 @@ export default class Status extends ImmutablePureComponent { } handleExpandedToggle = () => { - this.setState({ isExpanded: !this.state.isExpanded }); + this.props.onToggleHidden(this._properStatus()); }; renderLoadingMediaGallery () { @@ -138,8 +134,7 @@ export default class Status extends ImmutablePureComponent { let media = null; let statusAvatar, prepend; - const { hidden } = this.props; - const { isExpanded } = this.state; + const { hidden, featured } = this.props; let { status, account, ...other } = this.props; @@ -156,7 +151,14 @@ export default class Status extends ImmutablePureComponent { ); } - if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + if (featured) { + prepend = ( +
+
+ +
+ ); + } else if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; prepend = ( @@ -170,9 +172,14 @@ export default class Status extends ImmutablePureComponent { status = status.get('reblog'); } - if (status.get('media_attachments').size > 0 && !this.props.muted) { - if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { - + if (status.get('media_attachments').size > 0) { + if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) { + media = ( + + ); } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { const video = status.getIn(['media_attachments', 0]); @@ -184,6 +191,7 @@ export default class Status extends ImmutablePureComponent { src={video.get('url')} width={239} height={110} + inline sensitive={status.get('sensitive')} onOpenVideo={this.handleOpenVideo} /> @@ -234,7 +242,7 @@ export default class Status extends ImmutablePureComponent {
- + {media} diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 3b8155632..b6082f008 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -24,7 +24,12 @@ export default class StatusContent extends React.PureComponent { }; _updateStatusLinks () { - const node = this.node; + const node = this.node; + + if (!node) { + return; + } + const links = node.querySelectorAll('a'); for (var i = 0; i < links.length; ++i) { @@ -115,6 +120,10 @@ export default class StatusContent extends React.PureComponent { render () { const { status } = this.props; + if (status.get('content').length === 0) { + return null; + } + const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; const content = { __html: status.get('contentHtml') }; @@ -145,7 +154,7 @@ export default class StatusContent extends React.PureComponent { } return ( -
+
- + diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index b8605d11f..bb7b3b632 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -13,6 +13,7 @@ const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, }); class Avatar extends ImmutablePureComponent { @@ -69,6 +70,7 @@ export default class Header extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map, onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; @@ -80,11 +82,20 @@ export default class Header extends ImmutablePureComponent { } let info = ''; + let mutingInfo = ''; let actionBtn = ''; let lockedIcon = ''; if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { info = ; + } else if (me !== account.get('id') && account.getIn(['relationship', 'blocking'])) { + info = ; + } + + if (me !== account.get('id') && account.getIn(['relationship', 'muting'])) { + mutingInfo = ; + } else if (me !== account.get('id') && account.getIn(['relationship', 'domain_blocking'])) { + mutingInfo = ; } if (me !== account.get('id')) { @@ -100,6 +111,12 @@ export default class Header extends ImmutablePureComponent {
); + } else if (account.getIn(['relationship', 'blocking'])) { + actionBtn = ( +
+ +
+ ); } } @@ -124,6 +141,7 @@ export default class Header extends ImmutablePureComponent {
{info} + {mutingInfo} {actionBtn}
diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js index dda3d4e37..f7a802dc7 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.js +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js @@ -2,6 +2,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Permalink from '../../../components/permalink'; +import { displaySensitiveMedia } from '../../../initial_state'; export default class MediaItem extends ImmutablePureComponent { @@ -9,28 +10,51 @@ export default class MediaItem extends ImmutablePureComponent { media: ImmutablePropTypes.map.isRequired, }; - render () { - const { media } = this.props; - const status = media.get('status'); + state = { + visible: !this.props.media.getIn(['status', 'sensitive']) || displaySensitiveMedia, + }; - let content, style; - - if (media.get('type') === 'gifv') { - content = GIF; + handleClick = () => { + if (!this.state.visible) { + this.setState({ visible: true }); + return true; } - if (!status.get('sensitive')) { - style = { backgroundImage: `url(${media.get('preview_url')})` }; + return false; + } + + render () { + const { media } = this.props; + const { visible } = this.state; + const status = media.get('status'); + const focusX = media.getIn(['meta', 'focus', 'x']); + const focusY = media.getIn(['meta', 'focus', 'y']); + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; + const style = {}; + + let label, icon; + + if (media.get('type') === 'gifv') { + label = GIF; + } + + if (visible) { + style.backgroundImage = `url(${media.get('preview_url')})`; + style.backgroundPosition = `${x}% ${y}%`; + } else { + icon = ( + + + + ); } return (
- - {content} + + {icon} + {label}
); diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index ece219a3d..4b408256a 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -11,7 +11,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { getAccountGallery } from '../../selectors'; import MediaItem from './components/media_item'; import HeaderContainer from '../account_timeline/containers/header_container'; -import { FormattedMessage } from 'react-intl'; import { ScrollContainer } from 'react-router-scroll-4'; import LoadMore from '../../components/load_more'; @@ -89,10 +88,6 @@ export default class AccountGallery extends ImmutablePureComponent {
-
- -
-
{medias.map(media => ( ; @@ -79,6 +82,7 @@ export default class Header extends ImmutablePureComponent { + + {!hideTabs && ( +
+ + + +
+ )}
); } diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index f8c85c296..5e21cf7c6 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { fetchAccount } from '../../actions/accounts'; -import { refreshAccountTimeline, expandAccountTimeline } from '../../actions/timelines'; +import { refreshAccountTimeline, refreshAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines'; import StatusList from '../../components/status_list'; import LoadingIndicator from '../../components/loading_indicator'; import Column from '../ui/components/column'; @@ -12,11 +12,16 @@ import ColumnBackButton from '../../components/column_back_button'; import { List as ImmutableList } from 'immutable'; import ImmutablePureComponent from 'react-immutable-pure-component'; -const mapStateToProps = (state, props) => ({ - statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()), - isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']), - hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']), -}); +const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => { + const path = withReplies ? `${accountId}:with_replies` : accountId; + + return { + statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()), + featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()), + isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), + hasMore: !!state.getIn(['timelines', `account:${path}`, 'next']), + }; +}; @connect(mapStateToProps) export default class AccountTimeline extends ImmutablePureComponent { @@ -25,30 +30,40 @@ export default class AccountTimeline extends ImmutablePureComponent { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, statusIds: ImmutablePropTypes.list, + featuredStatusIds: ImmutablePropTypes.list, isLoading: PropTypes.bool, hasMore: PropTypes.bool, + withReplies: PropTypes.bool, }; componentWillMount () { - this.props.dispatch(fetchAccount(this.props.params.accountId)); - this.props.dispatch(refreshAccountTimeline(this.props.params.accountId)); + const { params: { accountId }, withReplies } = this.props; + + this.props.dispatch(fetchAccount(accountId)); + if (!withReplies) { + this.props.dispatch(refreshAccountFeaturedTimeline(accountId)); + } + this.props.dispatch(refreshAccountTimeline(accountId, withReplies)); } componentWillReceiveProps (nextProps) { - if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) { this.props.dispatch(fetchAccount(nextProps.params.accountId)); - this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId)); + if (!nextProps.withReplies) { + this.props.dispatch(refreshAccountFeaturedTimeline(nextProps.params.accountId)); + } + this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId, nextProps.params.withReplies)); } } - handleScrollToBottom = () => { + handleLoadMore = () => { if (!this.props.isLoading && this.props.hasMore) { - this.props.dispatch(expandAccountTimeline(this.props.params.accountId)); + this.props.dispatch(expandAccountTimeline(this.props.params.accountId, this.props.withReplies)); } } render () { - const { statusIds, isLoading, hasMore } = this.props; + const { statusIds, featuredStatusIds, isLoading, hasMore } = this.props; if (!statusIds && isLoading) { return ( @@ -66,9 +81,10 @@ export default class AccountTimeline extends ImmutablePureComponent { prepend={} scrollKey='account_timeline' statusIds={statusIds} + featuredStatusIds={featuredStatusIds} isLoading={isLoading} hasMore={hasMore} - onScrollToBottom={this.handleScrollToBottom} + onLoadMore={this.handleLoadMore} /> ); diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index a876c5197..663ccfb8e 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -50,6 +50,7 @@ export default class ComposeForm extends ImmutablePureComponent { onPaste: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired, showSearch: PropTypes.bool, + anyMedia: PropTypes.bool, }; static defaultProps = { @@ -142,10 +143,10 @@ export default class ComposeForm extends ImmutablePureComponent { } render () { - const { intl, onPaste, showSearch } = this.props; + const { intl, onPaste, showSearch, anyMedia } = this.props; const disabled = this.props.is_submitting; const text = [this.props.spoiler_text, countableText(this.props.text)].join(''); - + const disabledButton = disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0 && !anyMedia); let publishText = ''; if (this.props.privacy === 'private' || this.props.privacy === 'direct') { @@ -203,7 +204,7 @@ export default class ComposeForm extends ImmutablePureComponent {
-
+
); diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js index 398fc44ce..71c0a203f 100644 --- a/app/javascript/mastodon/features/compose/components/search.js +++ b/app/javascript/mastodon/features/compose/components/search.js @@ -4,6 +4,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Overlay from 'react-overlays/lib/Overlay'; import Motion from '../../ui/util/optional_motion'; import spring from 'react-motion/lib/spring'; +import { searchEnabled } from '../../../initial_state'; const messages = defineMessages({ placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, @@ -17,7 +18,7 @@ class SearchPopout extends React.PureComponent { render () { const { style } = this.props; - + const extraInformation = searchEnabled ? : ; return (
@@ -32,7 +33,7 @@ class SearchPopout extends React.PureComponent {
  • URL
  • - + {extraInformation}
    )} diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js index d16f7fce7..84455563c 100644 --- a/app/javascript/mastodon/features/compose/components/search_results.js +++ b/app/javascript/mastodon/features/compose/components/search_results.js @@ -22,6 +22,8 @@ export default class SearchResults extends ImmutablePureComponent { count += results.get('accounts').size; accounts = (
    +
    + {results.get('accounts').map(accountId => )}
    ); @@ -31,6 +33,8 @@ export default class SearchResults extends ImmutablePureComponent { count += results.get('statuses').size; statuses = (
    +
    + {results.get('statuses').map(statusId => )}
    ); @@ -40,6 +44,8 @@ export default class SearchResults extends ImmutablePureComponent { count += results.get('hashtags').size; hashtags = (
    +
    + {results.get('hashtags').map(hashtag => ( #{hashtag} diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js index 3a3d17710..61b2d19e0 100644 --- a/app/javascript/mastodon/features/compose/components/upload.js +++ b/app/javascript/mastodon/features/compose/components/upload.js @@ -1,15 +1,13 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import IconButton from '../../../components/icon_button'; import Motion from '../../ui/util/optional_motion'; import spring from 'react-motion/lib/spring'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; const messages = defineMessages({ - undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }, description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, }); @@ -21,6 +19,7 @@ export default class Upload extends ImmutablePureComponent { intl: PropTypes.object.isRequired, onUndo: PropTypes.func.isRequired, onDescriptionChange: PropTypes.func.isRequired, + onOpenFocalPoint: PropTypes.func.isRequired, }; state = { @@ -33,6 +32,10 @@ export default class Upload extends ImmutablePureComponent { this.props.onUndo(this.props.media.get('id')); } + handleFocalPointClick = () => { + this.props.onOpenFocalPoint(this.props.media.get('id')); + } + handleInputChange = e => { this.setState({ dirtyDescription: e.target.value }); } @@ -63,13 +66,20 @@ export default class Upload extends ImmutablePureComponent { const { intl, media } = this.props; const active = this.state.hovered || this.state.focused; const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || ''; + const focusX = media.getIn(['meta', 'focus', 'x']); + const focusY = media.getIn(['meta', 'focus', 'y']); + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; return (
    {({ scale }) => ( -
    - +
    +
    + + {media.get('type') === 'image' && } +