diff --git a/.env.nanobox b/.env.nanobox index 48204a6bf431c3..0d14f8a00567fe 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 3f0edd72ff6430..579ad667bb885c 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 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.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 e25c040ac049a2..b57f52e309bb47 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 35fc49dde450d7..61d51ca21cc34f 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,7 +52,6 @@ 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 diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 00000000000000..c4bbb60140b178 --- /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 765df58c250bad..7a195b31e6af17 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,16 @@ FROM ruby:2.5.0-alpine3.7 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 -ARG YARN_DOWNLOAD_SHA256=6cfe82e530ef0837212f13e45c1565ba53f5199eec2527b85ecbcd88bf26821d +ARG YARN_VERSION=1.5.1 +ARG YARN_DOWNLOAD_SHA256=cd31657232cf48d57fdbff55f38bfa058d2fb4950450bd34af72dac796af4de1 ARG LIBICONV_VERSION=1.15 ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178 @@ -36,7 +38,6 @@ RUN apk -U upgrade \ libidn \ libpq \ nodejs \ - nodejs-npm \ protobuf \ su-exec \ tini \ @@ -68,12 +69,16 @@ RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-in && yarn --pure-lockfile \ && yarn cache clean -COPY . /mastodon +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 docker_entrypoint.sh /usr/local/bin/run +COPY . /mastodon -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 eaa1d29de68ff6..3fce2ddc75849b 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,20 @@ 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' + +gem 'devise_pam_authenticatable2', '~> 8.0', install_if: -> { ENV['PAM_ENABLED'] == 'true' } +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' @@ -69,6 +78,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 +96,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 +120,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 b116318a7c884f..0640b140b17023 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 (8.0.1) + 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) @@ -274,6 +295,7 @@ GEM 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,9 +306,12 @@ 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) @@ -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,6 +491,8 @@ 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) @@ -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 (~> 8.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) @@ -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/README.md b/README.md index 5cf91d52ca971c..e9fb685c41fc8c 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/ --- diff --git a/Vagrantfile b/Vagrantfile index 0c21bed68c83b1..ddcdf351024044 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 00000000000000..8bf5b4af7cf52c --- /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 47690e81eb9253..4ffdfb6856c3d4 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -31,7 +31,7 @@ def set_body_classes 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 69fd20e27f4a55..7bf35825f9b93a 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 @@ def show 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 @@ def set_account @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 @@ def media_requested? 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 00000000000000..081914016e4ebd --- /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 7d0bc74d3ee3bb..af51e32d5d3114 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -11,7 +11,7 @@ def create 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 9f97ff6226d674..9ed700c1e23eb0 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 487282dc35b8fd..ce32082099e384 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -16,9 +16,11 @@ class SettingsController < BaseController 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 @@ class SettingsController < BaseController 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 52e68ab35fc9ef..7b5168b314a5df 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -51,6 +51,10 @@ def limit_param(default_limit) [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 143e9d3cdc6b8b..ac5f3268d8c78a 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 @@ def update 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 da534d960e51d1..68af225295a336 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -20,6 +20,6 @@ def update 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 91a942d7530fb5..70236d1a8bfc1c 100644 --- a/app/controllers/api/v1/accounts/relationships_controller.rb +++ b/app/controllers/api/v1/accounts/relationships_controller.rb @@ -10,7 +10,7 @@ def index 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 @@ def relationships 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 11e647c3cbee84..7649da4339e7a0 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -22,8 +22,4 @@ def account_search 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 095f6937b00eb8..7261ccd24733ab 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -28,9 +28,9 @@ def cached_account_statuses 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 diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 4e73e9e8b564c3..d6432594410406 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -13,9 +13,9 @@ def show 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 @@ def block 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 9f330f0dfe962d..d4e6337e76436b 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -27,7 +27,7 @@ def update 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 22828217d2f0d7..f5095e07304538 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -13,14 +13,14 @@ def index 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 @@ def reported_account 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 997eed6e2c5418..05754d0f2438b3 100644 --- a/app/controllers/api/v1/search_controller.rb +++ b/app/controllers/api/v1/search_controller.rb @@ -33,12 +33,8 @@ def search_results 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 3de1009b842810..bba6a6f4802023 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 @@ def destroy 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 49887778e0962e..d7d70b94d52e47 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -21,15 +21,23 @@ def cached_public_statuses 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 @@ def insert_pagination_headers 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 08db04a39d27d6..eb32611ad40509 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -29,16 +29,24 @@ def tagged_statuses 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 @@ def insert_pagination_headers 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 e1aae0b67778e5..6e504261781242 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 @@ def https_enabled? 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 @@ def single_user_mode? @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 2fdb281f40e87b..a240425cd8f40d 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 00000000000000..bbf63bed304f9e --- /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 b8ff4e54f28b4d..417e2b63bddd7e 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -14,6 +14,11 @@ def destroy 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 a5acb6c36fa325..c1ebe760c5d908 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 @@ def find_user 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 @@ def after_sign_in_path_for(resource) 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 a9ea60f7dc65e5..e697284a8c74e8 100644 --- a/app/controllers/concerns/localized.rb +++ b/app/controllers/concerns/localized.rb @@ -17,11 +17,7 @@ def set_locale end def default_locale - request_locale || env_locale || I18n.default_locale - end - - def env_locale - ENV['DEFAULT_LOCALE'] + request_locale || I18n.default_locale end def request_locale @@ -29,12 +25,10 @@ def request_locale 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 00000000000000..beec932238195f --- /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 399e79665e728b..2d2315034c4d42 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -7,7 +7,9 @@ def index @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 1e73d4bd4087a0..169f9057d86815 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -7,7 +7,9 @@ def index @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 21dde20ce40379..b1f8f1ad902234 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -34,7 +34,8 @@ def authenticate_user! 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 f652f5acef9964..88c7232dd848b5 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 ae62f00c1e2ccc..869e11d3bf6017 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 0690267151c958..8397631388da12 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -39,6 +39,7 @@ def user_settings_params :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 367ea34e78d9db..45226c8d2ae4ed 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 cc579dbc8898b1..f81856cc6ba1c1 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 @@ def show 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 22a19c52b57040..dd0b25f3ef9ec5 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 44511498577e90..54b92bdf4719a9 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -8,6 +8,27 @@ def display_name(account) 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 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 00000000000000..53e97e4f8af1f2 --- /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 00000000000000..8a6a8673bcba80 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 00000000000000..998994f5c00a37 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 8a35049b32cd7e..130b4af2395bdb 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -1,6 +1,7 @@ import api from '../api'; import { throttle } from 'lodash'; import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; +import { tagHistory } from '../settings'; import { useEmoji } from './emojis'; import { @@ -27,6 +28,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 +96,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 +107,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 +116,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 +184,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)); @@ -273,12 +279,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 +324,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 +341,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 00000000000000..217ba4e74a58a8 --- /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/reports.js b/app/javascript/mastodon/actions/reports.js index b19a07285bd1c0..afa0c341218b4b 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 78c6109f7291ad..73cb106ec4f831 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/store.js b/app/javascript/mastodon/actions/store.js index a1db0fdd51c60c..2dd94a9983268f 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 df6a3637955e7f..f0ab16a2d76852 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 7856b26f9d8860..8fbb17785811c5 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 9f2d46ddd76ba7..8e5bb0e0be85b1 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 ( +
+ +
+ ); + } 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 6a16e2fc7aedb0..34904194f73414 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 3b4f46d99f44de..964c100befe446 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 c300db89b9d880..6b79ec02daa092 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -133,9 +133,7 @@ export default class ColumnHeader extends React.PureComponent {

diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index 43dc0d6e3e63ce..c5c6f73b337b6a 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; - - this.props.onModalOpen({ - status, - actions: items, - onClick: this.handleItemClick, - }); + handleClick = ({ target }) => { + if (this.state.id === this.props.openDropdownId) { + this.handleClose(); + } else { + const { top } = target.getBoundingClientRect(); + const placement = top * 2 < innerHeight ? 'bottom' : 'top'; - 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 f8bd067e8e5165..9e2f6835a8bdf9 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 20febdb166953c..9310e7c963b10d 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -6,12 +6,32 @@ 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' }, }); +const shiftToPoint = (containerToImageRatio, containerSize, imageSize, focusSize, toMinus) => { + const containerCenter = Math.floor(containerSize / 2); + const focusFactor = (focusSize + 1) / 2; + const scaledImage = Math.floor(imageSize / containerToImageRatio); + + let focus = Math.floor(focusFactor * scaledImage); + + if (toMinus) focus = scaledImage - focus; + + let focusOffset = focus - containerCenter; + + const remainder = scaledImage - focus; + const containerRemainder = containerSize - containerCenter; + + if (remainder < containerRemainder) focusOffset -= containerRemainder - remainder; + if (focusOffset < 0) focusOffset = 0; + + return (focusOffset * -100 / containerSize) + '%'; +}; + class Item extends React.PureComponent { static contextTypes = { @@ -24,6 +44,8 @@ class Item extends React.PureComponent { index: PropTypes.number.isRequired, size: PropTypes.number.isRequired, onClick: PropTypes.func.isRequired, + containerWidth: PropTypes.number, + containerHeight: PropTypes.number, }; static defaultProps = { @@ -62,7 +84,7 @@ class Item extends React.PureComponent { } render () { - const { attachment, index, size, standalone } = this.props; + const { attachment, index, size, standalone, containerWidth, containerHeight } = this.props; let width = 50; let height = 100; @@ -116,16 +138,50 @@ 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 originalHeight = attachment.getIn(['meta', 'original', 'height']); 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']); + const focusY = attachment.getIn(['meta', 'focus', 'y']); + const imageStyle = {}; + + if (originalWidth && originalHeight && containerWidth && containerHeight && focusX && focusY) { + const widthRatio = originalWidth / (containerWidth * (width / 100)); + const heightRatio = originalHeight / (containerHeight * (height / 100)); + + let hShift = 0; + let vShift = 0; + + if (widthRatio > heightRatio) { + hShift = shiftToPoint(heightRatio, (containerWidth * (width / 100)), originalWidth, focusX); + } else if(widthRatio < heightRatio) { + vShift = shiftToPoint(widthRatio, (containerHeight * (height / 100)), originalHeight, focusY, true); + } + + if (originalWidth > originalHeight) { + imageStyle.height = '100%'; + imageStyle.width = 'auto'; + imageStyle.minWidth = '100%'; + } else { + imageStyle.height = 'auto'; + imageStyle.width = '100%'; + imageStyle.minHeight = '100%'; + } + + imageStyle.top = vShift; + imageStyle.left = hShift; + } else { + imageStyle.height = '100%'; + } thumbnail = ( - {attachment.get('description')} + {attachment.get('description')} ); } else if (attachment.get('type') === 'gifv') { @@ -187,7 +250,7 @@ export default class MediaGallery extends React.PureComponent { }; state = { - visible: !this.props.sensitive, + visible: !this.props.sensitive || displaySensitiveMedia, }; componentWillReceiveProps (nextProps) { @@ -205,7 +268,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 +290,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 +309,7 @@ export default class MediaGallery extends React.PureComponent { } children = ( - @@ -260,12 +320,12 @@ export default class MediaGallery extends React.PureComponent { if (this.isStandaloneEligible()) { children = ; } else { - children = media.take(4).map((attachment, i) => ); + children = media.take(4).map((attachment, i) => ); } } return ( -
+
diff --git a/app/javascript/mastodon/components/permalink.js b/app/javascript/mastodon/components/permalink.js index d726d37a20a3e9..b369e98126d36d 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 71228ca6cb86e9..ac3e404df4ff50 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 c030510a09b2bd..8102d1e06a4d1d 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'; @@ -138,7 +139,7 @@ export default class Status extends ImmutablePureComponent { let media = null; let statusAvatar, prepend; - const { hidden } = this.props; + const { hidden, featured } = this.props; const { isExpanded } = this.state; let { status, account, ...other } = this.props; @@ -156,7 +157,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 +178,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 +197,7 @@ export default class Status extends ImmutablePureComponent { src={video.get('url')} width={239} height={110} + inline sensitive={status.get('sensitive')} onOpenVideo={this.handleOpenVideo} /> diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 9e1d8da64c5ef4..32153da36300c7 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,7 +120,11 @@ export default class StatusContent extends React.PureComponent { render () { const { status } = this.props; - const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden + 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') }; const spoilerContent = { __html: status.get('spoilerHtml') }; diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 5acaf714ec6312..3bebf702cf7e07 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -11,7 +11,8 @@ export default class StatusList extends ImmutablePureComponent { static propTypes = { scrollKey: PropTypes.string.isRequired, statusIds: ImmutablePropTypes.list.isRequired, - onScrollToBottom: PropTypes.func, + featuredStatusIds: ImmutablePropTypes.list, + onLoadMore: PropTypes.func, onScrollToTop: PropTypes.func, onScroll: PropTypes.func, trackScroll: PropTypes.bool, @@ -50,7 +51,7 @@ export default class StatusList extends ImmutablePureComponent { } render () { - const { statusIds, ...other } = this.props; + const { statusIds, featuredStatusIds, ...other } = this.props; const { isLoading, isPartial } = other; if (isPartial) { @@ -68,8 +69,8 @@ export default class StatusList extends ImmutablePureComponent { ); } - const scrollableContent = (isLoading || statusIds.size > 0) ? ( - statusIds.map((statusId) => ( + let scrollableContent = (isLoading || statusIds.size > 0) ? ( + statusIds.map(statusId => ( ( + + )).concat(scrollableContent); + } + return ( {scrollableContent} diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js index 151f253908cfb2..7cbcdcd35728f2 100644 --- a/app/javascript/mastodon/containers/dropdown_menu_container.js +++ b/app/javascript/mastodon/containers/dropdown_menu_container.js @@ -1,3 +1,4 @@ +import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu'; import { openModal, closeModal } from '../actions/modal'; import { connect } from 'react-redux'; import DropdownMenu from '../components/dropdown_menu'; @@ -5,12 +6,22 @@ import { isUserTouching } from '../is_mobile'; const mapStateToProps = state => ({ isModalOpen: state.get('modal').modalType === 'ACTIONS', + dropdownPlacement: state.getIn(['dropdown_menu', 'placement']), + openDropdownId: state.getIn(['dropdown_menu', 'openId']), }); -const mapDispatchToProps = dispatch => ({ - isUserTouching, - onModalOpen: props => dispatch(openModal('ACTIONS', props)), - onModalClose: () => dispatch(closeModal()), +const mapDispatchToProps = (dispatch, { status, items }) => ({ + onOpen(id, onItemClick, dropdownPlacement) { + dispatch(isUserTouching() ? openModal('ACTIONS', { + status, + actions: items, + onClick: onItemClick, + }) : openDropdownMenu(id, dropdownPlacement)); + }, + onClose(id) { + dispatch(closeModal()); + dispatch(closeDropdownMenu(id)); + }, }); export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu); diff --git a/app/javascript/mastodon/containers/timeline_container.js b/app/javascript/mastodon/containers/timeline_container.js index e84c921eeb1f7a..8719bb5c9eeab5 100644 --- a/app/javascript/mastodon/containers/timeline_container.js +++ b/app/javascript/mastodon/containers/timeline_container.js @@ -6,6 +6,7 @@ import { hydrateStore } from '../actions/store'; import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from '../locales'; import PublicTimeline from '../features/standalone/public_timeline'; +import CommunityTimeline from '../features/standalone/community_timeline'; import HashtagTimeline from '../features/standalone/hashtag_timeline'; import initialState from '../initial_state'; @@ -23,17 +24,24 @@ export default class TimelineContainer extends React.PureComponent { static propTypes = { locale: PropTypes.string.isRequired, hashtag: PropTypes.string, + showPublicTimeline: PropTypes.bool.isRequired, + }; + + static defaultProps = { + showPublicTimeline: initialState.settings.known_fediverse, }; render () { - const { locale, hashtag } = this.props; + const { locale, hashtag, showPublicTimeline } = this.props; let timeline; if (hashtag) { timeline = ; - } else { + } else if (showPublicTimeline) { timeline = ; + } else { + timeline = ; } return ( diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js index cb849fa5dea3dc..b538fa5fcb78f5 100644 --- a/app/javascript/mastodon/features/account/components/action_bar.js +++ b/app/javascript/mastodon/features/account/components/action_bar.js @@ -53,11 +53,11 @@ export default class ActionBar extends React.PureComponent { let extraInfo = ''; menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention }); + if ('share' in navigator) { menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare }); } - menu.push(null); - menu.push({ text: intl.formatMessage(messages.media), to: `/accounts/${account.get('id')}/media` }); + menu.push(null); if (account.get('id') === me) { @@ -122,7 +122,7 @@ export default class ActionBar extends React.PureComponent {
- + diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index b8605d11f8009d..bb7b3b6329be27 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 dda3d4e37baaf3..f7a802dc7949cd 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, }; + state = { + visible: !this.props.media.getIn(['status', 'sensitive']) || displaySensitiveMedia, + }; + + handleClick = () => { + if (!this.state.visible) { + this.setState({ visible: true }); + return true; + } + + 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 content, style; + let label, icon; if (media.get('type') === 'gifv') { - content = GIF; + label = GIF; } - if (!status.get('sensitive')) { - style = { backgroundImage: `url(${media.get('preview_url')})` }; + 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 ece219a3d50ac3..4b408256a18d06 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 f8c85c296d99ef..f5f2475ea77c97 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: 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,36 @@ 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)); + 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)); + 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 +77,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 1daa296fb09245..9813cb3d6d2b91 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -55,6 +55,7 @@ export default class ComposeForm extends ImmutablePureComponent { onPaste: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired, showSearch: PropTypes.bool, + anyMedia: PropTypes.bool, onRisaSubmit: PropTypes.func.isRequired, onGojiSubmit: PropTypes.func.isRequired, @@ -155,10 +156,10 @@ export default class ComposeForm extends ImmutablePureComponent { handleOnHarukinSubmit = () => this.props.onHarukinSubmit(this.autosuggestTextarea.textarea); 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') { @@ -218,8 +219,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 398fc44ceebc25..71c0a203f0d033 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 d16f7fce7699c7..84455563c49dd1 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 3a3d17710086a8..61b2d19e0dbc0c 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' && } +
    - {multiColumn &&
    } + {multiColumn && ( +
    + +
    + )}
    diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js index 67b107bc8f31b7..6f1c863b43c6cb 100644 --- a/app/javascript/mastodon/features/favourited_statuses/index.js +++ b/app/javascript/mastodon/features/favourited_statuses/index.js @@ -62,7 +62,7 @@ export default class Favourites extends ImmutablePureComponent { this.column = c; } - handleScrollToBottom = debounce(() => { + handleLoadMore = debounce(() => { this.props.dispatch(expandFavouritedStatuses()); }, 300, { leading: true }) @@ -89,7 +89,7 @@ export default class Favourites extends ImmutablePureComponent { scrollKey={`favourited_statuses-${columnId}`} hasMore={hasMore} isLoading={isLoading} - onScrollToBottom={this.handleScrollToBottom} + onLoadMore={this.handleLoadMore} /> ); diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js index f64ed7948378a1..919a89332b12fe 100644 --- a/app/javascript/mastodon/features/followers/index.js +++ b/app/javascript/mastodon/features/followers/index.js @@ -80,7 +80,7 @@ export default class Followers extends ImmutablePureComponent {
    - + {accountIds.map(id => )} {loadMore}
    diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js index a0c0fac0512cc2..5719259d11114e 100644 --- a/app/javascript/mastodon/features/following/index.js +++ b/app/javascript/mastodon/features/following/index.js @@ -80,7 +80,7 @@ export default class Following extends ImmutablePureComponent {
    - + {accountIds.map(id => )} {loadMore}
    diff --git a/app/javascript/mastodon/features/notifications/components/clear_column_button.js b/app/javascript/mastodon/features/notifications/components/clear_column_button.js index 22a10753f555a5..e0bf4c82d5b202 100644 --- a/app/javascript/mastodon/features/notifications/components/clear_column_button.js +++ b/app/javascript/mastodon/features/notifications/components/clear_column_button.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; -export default class ClearColumnButton extends React.Component { +export default class ClearColumnButton extends React.PureComponent { static propTypes = { onClick: PropTypes.func.isRequired, diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 35b430bfb518c2..cb9d025eafa720 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -50,8 +50,14 @@ export default class Notifications extends React.PureComponent { trackScroll: true, }; - handleScrollToBottom = debounce(() => { + componentWillUnmount () { + this.handleLoadMore.cancel(); + this.handleScrollToTop.cancel(); + this.handleScroll.cancel(); this.props.dispatch(scrollTopNotifications(false)); + } + + handleLoadMore = debounce(() => { this.props.dispatch(expandNotifications()); }, 300, { leading: true }); @@ -136,7 +142,7 @@ export default class Notifications extends React.PureComponent { isLoading={isLoading} hasMore={hasMore} emptyMessage={emptyMessage} - onScrollToBottom={this.handleScrollToBottom} + onLoadMore={this.handleLoadMore} onScrollToTop={this.handleScrollToTop} onScroll={this.handleScroll} shouldUpdateScroll={shouldUpdateScroll} diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js index cc92322011a864..9ff75a082c2ed1 100644 --- a/app/javascript/mastodon/features/report/components/status_check_box.js +++ b/app/javascript/mastodon/features/report/components/status_check_box.js @@ -2,6 +2,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Toggle from 'react-toggle'; +import noop from 'lodash/noop'; +import StatusContent from '../../../components/status_content'; +import { MediaGallery, Video } from '../../ui/util/async-components'; +import Bundle from '../../ui/components/bundle'; export default class StatusCheckBox extends React.PureComponent { @@ -14,18 +18,48 @@ export default class StatusCheckBox extends React.PureComponent { render () { const { status, checked, onToggle, disabled } = this.props; - const content = { __html: status.get('contentHtml') }; + let media = null; if (status.get('reblog')) { return null; } + if (status.get('media_attachments').size > 0) { + if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { + + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + const video = status.getIn(['media_attachments', 0]); + + media = ( + + {Component => ( + + )} + + ); + } else { + media = ( + + {Component => } + + ); + } + } + return (
    -
    +
    + + {media} +
    diff --git a/app/javascript/mastodon/features/standalone/community_timeline/index.js b/app/javascript/mastodon/features/standalone/community_timeline/index.js new file mode 100644 index 00000000000000..51e50e1f50979d --- /dev/null +++ b/app/javascript/mastodon/features/standalone/community_timeline/index.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from '../../ui/containers/status_list_container'; +import { + refreshCommunityTimeline, + expandCommunityTimeline, +} from '../../../actions/timelines'; +import Column from '../../../components/column'; +import ColumnHeader from '../../../components/column_header'; +import { defineMessages, injectIntl } from 'react-intl'; +import { connectCommunityStream } from '../../../actions/streaming'; + +const messages = defineMessages({ + title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' }, +}); + +@connect() +@injectIntl +export default class CommunityTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(refreshCommunityTimeline()); + this.disconnect = dispatch(connectCommunityStream()); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + handleLoadMore = () => { + this.props.dispatch(expandCommunityTimeline()); + } + + render () { + const { intl } = this.props; + + return ( + + + + + + ); + } + +} diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index 2f6a7831e996af..b52f3c4fafd468 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -20,6 +20,39 @@ const getHostname = url => { return parser.hostname; }; +const trim = (text, len) => { + const cut = text.indexOf(' ', len); + + if (cut === -1) { + return text; + } + + return text.substring(0, cut) + (text.length > len ? '…' : ''); +}; + +const domParser = new DOMParser(); + +const addAutoPlay = html => { + const document = domParser.parseFromString(html, 'text/html').documentElement; + const iframe = document.querySelector('iframe'); + + if (iframe) { + if (iframe.src.indexOf('?') !== -1) { + iframe.src += '&'; + } else { + iframe.src += '?'; + } + + iframe.src += 'autoplay=1&auto_play=1'; + + // DOM parser creates html/body elements around original HTML fragment, + // so we need to get innerHTML out of the body and not the entire document + return document.querySelector('body').innerHTML; + } + + return html; +}; + export default class Card extends React.PureComponent { static propTypes = { @@ -33,9 +66,16 @@ export default class Card extends React.PureComponent { }; state = { - width: 0, + width: 280, + embedded: false, }; + componentWillReceiveProps (nextProps) { + if (this.props.card !== nextProps.card) { + this.setState({ embedded: false }); + } + } + handlePhotoClick = () => { const { card, onOpenMedia } = this.props; @@ -57,56 +97,14 @@ export default class Card extends React.PureComponent { ); }; - renderLink () { - const { card, maxDescription } = this.props; - const { width } = this.state; - const horizontal = card.get('width') > card.get('height') && (card.get('width') + 100 >= width); - - let image = ''; - let provider = card.get('provider_name'); - - if (card.get('image')) { - image = ( -
    - {card.get('title')} -
    - ); - } - - if (provider.length < 1) { - provider = decodeIDNA(getHostname(card.get('url'))); - } - - const className = classnames('status-card', { horizontal }); - - return ( - - {image} - -
    - {card.get('title')} - {!horizontal &&

    {(card.get('description') || '').substring(0, maxDescription)}

    } - {provider} -
    -
    - ); - } - - renderPhoto () { + handleEmbedClick = () => { const { card } = this.props; - return ( - {card.get('title')} - ); + if (card.get('type') === 'photo') { + this.handlePhotoClick(); + } else { + this.setState({ embedded: true }); + } } setRef = c => { @@ -117,7 +115,7 @@ export default class Card extends React.PureComponent { renderVideo () { const { card } = this.props; - const content = { __html: card.get('html') }; + const content = { __html: addAutoPlay(card.get('html')) }; const { width } = this.state; const ratio = card.get('width') / card.get('height'); const height = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio); @@ -125,7 +123,7 @@ export default class Card extends React.PureComponent { return (
    @@ -133,23 +131,76 @@ export default class Card extends React.PureComponent { } render () { - const { card } = this.props; + const { card, maxDescription } = this.props; + const { width, embedded } = this.state; if (card === null) { return null; } - switch(card.get('type')) { - case 'link': - return this.renderLink(); - case 'photo': - return this.renderPhoto(); - case 'video': - return this.renderVideo(); - case 'rich': - default: - return null; + const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name'); + const horizontal = card.get('width') > card.get('height') && (card.get('width') + 100 >= width) || card.get('type') !== 'link'; + const className = classnames('status-card', { horizontal }); + const interactive = card.get('type') !== 'link'; + const title = interactive ? {card.get('title')} : {card.get('title')}; + const ratio = card.get('width') / card.get('height'); + const height = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio); + + const description = ( +
    + {title} + {!horizontal &&

    {trim(card.get('description') || '', maxDescription)}

    } + {provider} +
    + ); + + let embed = ''; + let thumbnail =
    ; + + if (interactive) { + if (embedded) { + embed = this.renderVideo(); + } else { + let iconVariant = 'play'; + + if (card.get('type') === 'photo') { + iconVariant = 'search-plus'; + } + + embed = ( +
    + {thumbnail} + +
    +
    + + +
    +
    +
    + ); + } + + return ( +
    + {embed} + {description} +
    + ); + } else if (card.get('image')) { + embed = ( +
    + {thumbnail} +
    + ); } + + return ( + + {embed} + {description} + + ); } } diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index abdb9a3f685da3..d4f21fc32b07cd 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -57,6 +57,7 @@ export default class DetailedStatus extends ImmutablePureComponent { src={video.get('url')} width={300} height={150} + inline onOpenVideo={this.handleOpenVideo} sensitive={status.get('sensitive')} /> diff --git a/app/javascript/mastodon/features/ui/components/bundle.js b/app/javascript/mastodon/features/ui/components/bundle.js index fc88e0c70f17dc..e7d93525175c26 100644 --- a/app/javascript/mastodon/features/ui/components/bundle.js +++ b/app/javascript/mastodon/features/ui/components/bundle.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; const emptyComponent = () => null; const noop = () => { }; -class Bundle extends React.Component { +class Bundle extends React.PureComponent { static propTypes = { fetchComponent: PropTypes.func.isRequired, @@ -26,7 +26,7 @@ class Bundle extends React.Component { onFetchFail: noop, } - static cache = {} + static cache = new Map state = { mod: undefined, @@ -51,13 +51,12 @@ class Bundle extends React.Component { load = (props) => { const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props; + const cachedMod = Bundle.cache.get(fetchComponent); onFetch(); - if (Bundle.cache[fetchComponent.name]) { - const mod = Bundle.cache[fetchComponent.name]; - - this.setState({ mod: mod.default }); + if (cachedMod) { + this.setState({ mod: cachedMod.default }); onFetchSuccess(); return Promise.resolve(); } @@ -71,7 +70,7 @@ class Bundle extends React.Component { return fetchComponent() .then((mod) => { - Bundle.cache[fetchComponent.name] = mod; + Bundle.cache.set(fetchComponent, mod); this.setState({ mod: mod.default }); onFetchSuccess(); }) diff --git a/app/javascript/mastodon/features/ui/components/bundle_column_error.js b/app/javascript/mastodon/features/ui/components/bundle_column_error.js index cd124746acbcc5..f39ebd900b103e 100644 --- a/app/javascript/mastodon/features/ui/components/bundle_column_error.js +++ b/app/javascript/mastodon/features/ui/components/bundle_column_error.js @@ -13,7 +13,7 @@ const messages = defineMessages({ retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' }, }); -class BundleColumnError extends React.Component { +class BundleColumnError extends React.PureComponent { static propTypes = { onRetry: PropTypes.func.isRequired, diff --git a/app/javascript/mastodon/features/ui/components/bundle_modal_error.js b/app/javascript/mastodon/features/ui/components/bundle_modal_error.js index 928bfe1f7d5f5b..f9365b95bcca35 100644 --- a/app/javascript/mastodon/features/ui/components/bundle_modal_error.js +++ b/app/javascript/mastodon/features/ui/components/bundle_modal_error.js @@ -10,7 +10,7 @@ const messages = defineMessages({ close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' }, }); -class BundleModalError extends React.Component { +class BundleModalError extends React.PureComponent { static propTypes = { onRetry: PropTypes.func.isRequired, diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index a01e5a390ebf28..e82c46402b69ca 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -6,6 +6,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import ReactSwipeableViews from 'react-swipeable-views'; import { links, getIndex, getLink } from './tabs_bar'; +import { Link } from 'react-router-dom'; import BundleContainer from '../containers/bundle_container'; import ColumnLoading from './column_loading'; @@ -152,11 +153,19 @@ export default class ColumnsArea extends ImmutablePureComponent { this.pendingIndex = null; if (singleColumn) { - return columnIndex !== -1 ? ( - + const floatingActionButton = this.context.router.history.location.pathname === '/statuses/new' ? null : ; + + return columnIndex !== -1 ? [ + {links.map(this.renderView)} - - ) :
    {children}
    ; +
    , + + floatingActionButton, + ] : [ +
    {children}
    , + + floatingActionButton, + ]; } return ( diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js new file mode 100644 index 00000000000000..1038e186470bee --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js @@ -0,0 +1,122 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; +import ImageLoader from './image_loader'; +import classNames from 'classnames'; +import { changeUploadCompose } from '../../../actions/compose'; +import { getPointerPosition } from '../../video'; + +const mapStateToProps = (state, { id }) => ({ + media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), +}); + +const mapDispatchToProps = (dispatch, { id }) => ({ + + onSave: (x, y) => { + dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` })); + }, + +}); + +@connect(mapStateToProps, mapDispatchToProps) +export default class FocalPointModal extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + }; + + state = { + x: 0, + y: 0, + focusX: 0, + focusY: 0, + dragging: false, + }; + + componentWillMount () { + this.updatePositionFromMedia(this.props.media); + } + + componentWillReceiveProps (nextProps) { + if (this.props.media.get('id') !== nextProps.media.get('id')) { + this.updatePositionFromMedia(nextProps.media); + } + } + + componentWillUnmount () { + document.removeEventListener('mousemove', this.handleMouseMove); + document.removeEventListener('mouseup', this.handleMouseUp); + } + + handleMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseMove); + document.addEventListener('mouseup', this.handleMouseUp); + + this.updatePosition(e); + this.setState({ dragging: true }); + } + + handleMouseMove = e => { + this.updatePosition(e); + } + + handleMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseMove); + document.removeEventListener('mouseup', this.handleMouseUp); + + this.setState({ dragging: false }); + this.props.onSave(this.state.focusX, this.state.focusY); + } + + updatePosition = e => { + const { x, y } = getPointerPosition(this.node, e); + const focusX = (x - .5) * 2; + const focusY = (y - .5) * -2; + + this.setState({ x, y, focusX, focusY }); + } + + updatePositionFromMedia = media => { + const focusX = media.getIn(['meta', 'focus', 'x']); + const focusY = media.getIn(['meta', 'focus', 'y']); + + if (focusX && focusY) { + const x = (focusX / 2) + .5; + const y = (focusY / -2) + .5; + + this.setState({ x, y, focusX, focusY }); + } else { + this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 }); + } + } + + setRef = c => { + this.node = c; + } + + render () { + const { media } = this.props; + const { x, y, dragging } = this.state; + + const width = media.getIn(['meta', 'original', 'width']) || null; + const height = media.getIn(['meta', 'original', 'height']) || null; + + return ( +
    +
    + + +
    +
    +
    +
    + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/image_loader.js b/app/javascript/mastodon/features/ui/components/image_loader.js index e3e7197c541b2a..c7360a7264755a 100644 --- a/app/javascript/mastodon/features/ui/components/image_loader.js +++ b/app/javascript/mastodon/features/ui/components/image_loader.js @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import ZoomableImage from './zoomable_image'; export default class ImageLoader extends React.PureComponent { @@ -10,6 +11,7 @@ export default class ImageLoader extends React.PureComponent { previewSrc: PropTypes.string, width: PropTypes.number, height: PropTypes.number, + onClick: PropTypes.func, } static defaultProps = { @@ -24,6 +26,7 @@ export default class ImageLoader extends React.PureComponent { } removers = []; + canvas = null; get canvasContext() { if (!this.canvas) { @@ -43,6 +46,10 @@ export default class ImageLoader extends React.PureComponent { } } + componentWillUnmount () { + this.removeEventListeners(); + } + loadImage (props) { this.removeEventListeners(); this.setState({ loading: true, error: false }); @@ -118,7 +125,7 @@ export default class ImageLoader extends React.PureComponent { } render () { - const { alt, src, width, height } = this.props; + const { alt, src, width, height, onClick } = this.props; const { loading } = this.state; const className = classNames('image-loader', { @@ -128,22 +135,19 @@ export default class ImageLoader extends React.PureComponent { return (
    - - - {!loading && ( - {alt} + ) : ( + )}
    ); diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js index 02591a51f4cc5a..72ef32256f90f9 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.js +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -3,6 +3,7 @@ import ReactSwipeableViews from 'react-swipeable-views'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import ExtendedVideoPlayer from '../../../components/extended_video_player'; +import classNames from 'classnames'; import { defineMessages, injectIntl } from 'react-intl'; import IconButton from '../../../components/icon_button'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -26,6 +27,7 @@ export default class MediaModal extends ImmutablePureComponent { state = { index: null, + navigationHidden: false, }; handleSwipe = (index) => { @@ -68,14 +70,21 @@ export default class MediaModal extends ImmutablePureComponent { return this.state.index !== null ? this.state.index : this.props.index; } + toggleNavigation = () => { + this.setState(prevState => ({ + navigationHidden: !prevState.navigationHidden, + })); + }; + render () { const { media, intl, onClose } = this.props; + const { navigationHidden } = this.state; const index = this.getIndex(); let pagination = []; - const leftNav = media.size > 1 && ; - const rightNav = media.size > 1 && ; + const leftNav = media.size > 1 && ; + const rightNav = media.size > 1 && ; if (media.size > 1) { pagination = media.map((item, i) => { @@ -92,9 +101,30 @@ export default class MediaModal extends ImmutablePureComponent { const height = image.getIn(['meta', 'original', 'height']) || null; if (image.get('type') === 'image') { - return ; + return ( + + ); } else if (image.get('type') === 'gifv') { - return ; + return ( + + ); } return null; @@ -104,21 +134,43 @@ export default class MediaModal extends ImmutablePureComponent { alignItems: 'center', // center vertically }; + const navigationClassName = classNames('media-modal__navigation', { + 'media-modal__navigation--hidden': navigationHidden, + }); + return (
    - {leftNav} - -
    - - - {content} - +
    +
    + + {content} + +
    +
    +
    + + {leftNav} + {rightNav} +
      + {pagination} +
    -
      - {pagination} -
    - - {rightNav}
    ); } diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index 5839ba40a640cf..20bf21153c4772 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -8,6 +8,7 @@ import MediaModal from './media_modal'; import VideoModal from './video_modal'; import BoostModal from './boost_modal'; import ConfirmationModal from './confirmation_modal'; +import FocalPointModal from './focal_point_modal'; import { OnboardingModal, MuteModal, @@ -27,6 +28,7 @@ const MODAL_COMPONENTS = { 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'EMBED': EmbedModal, 'LIST_EDITOR': ListEditor, + 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), }; export default class ModalRoot extends React.PureComponent { diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js index b5dfa422e43065..3ae97646fc1131 100644 --- a/app/javascript/mastodon/features/ui/components/report_modal.js +++ b/app/javascript/mastodon/features/ui/components/report_modal.js @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { changeReportComment, submitReport } from '../../../actions/reports'; +import { changeReportComment, changeReportForward, submitReport } from '../../../actions/reports'; import { refreshAccountTimeline } from '../../../actions/timelines'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; @@ -10,8 +10,11 @@ import StatusCheckBox from '../../report/containers/status_check_box_container'; import { OrderedSet } from 'immutable'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Button from '../../../components/button'; +import Toggle from 'react-toggle'; +import IconButton from '../../../components/icon_button'; const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' }, submit: { id: 'report.submit', defaultMessage: 'Submit' }, }); @@ -26,6 +29,7 @@ const makeMapStateToProps = () => { isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), account: getAccount(state, accountId), comment: state.getIn(['reports', 'new', 'comment']), + forward: state.getIn(['reports', 'new', 'forward']), statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])), }; }; @@ -42,14 +46,19 @@ export default class ReportModal extends ImmutablePureComponent { account: ImmutablePropTypes.map, statusIds: ImmutablePropTypes.orderedSet.isRequired, comment: PropTypes.string.isRequired, + forward: PropTypes.bool, dispatch: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; - handleCommentChange = (e) => { + handleCommentChange = e => { this.props.dispatch(changeReportComment(e.target.value)); } + handleForwardChange = e => { + this.props.dispatch(changeReportForward(e.target.checked)); + } + handleSubmit = () => { this.props.dispatch(submitReport()); } @@ -65,26 +74,25 @@ export default class ReportModal extends ImmutablePureComponent { } render () { - const { account, comment, intl, statusIds, isSubmitting } = this.props; + const { account, comment, intl, statusIds, isSubmitting, forward, onClose } = this.props; if (!account) { return null; } + const domain = account.get('acct').split('@')[1]; + return (
    + {account.get('acct')} }} />
    -
    -
    - {statusIds.map(statusId => )} -
    -
    -
    +

    +