Merge tag 'v2.3.2' into kosmos

This commit is contained in:
Basti 2018-05-08 15:32:18 +02:00
commit 818a285769
451 changed files with 10737 additions and 3069 deletions

View File

@ -4,6 +4,7 @@
[ [
"env", "env",
{ {
"exclude": ["transform-async-to-generator", "transform-regenerator"],
"loose": true, "loose": true,
"modules": false, "modules": false,
"targets": { "targets": {

View File

@ -13,11 +13,29 @@ DB_PORT=5432
DATABASE_URL=postgresql://$DATA_DB_USER:$DATA_DB_PASS@$DATA_DB_HOST/gonano 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 # 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 should *NOT* contain the protocol part of the domain e.g https://example.com.
LOCAL_DOMAIN=${APP_NAME}.nanoapp.io 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. # 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 # 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 # Application secrets
# Generate each with the `rake secret` task (`nanobox run bundle exec rake secret`) # Generate each with the `rake secret` task (`nanobox run bundle exec rake secret`)
PAPERCLIP_SECRET=$PAPERCLIP_SECRET
SECRET_KEY_BASE=$SECRET_KEY_BASE SECRET_KEY_BASE=$SECRET_KEY_BASE
OTP_SECRET=$OTP_SECRET OTP_SECRET=$OTP_SECRET
@ -131,9 +148,79 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io
# Cluster number setting for streaming API server. # Cluster number setting for streaming API server.
# If you comment out following line, cluster number will be `numOfCpuCores - 1`. # If you comment out following line, cluster number will be `numOfCpuCores - 1`.
STREAMING_CLUSTER_NUM=1 # STREAMING_CLUSTER_NUM=1
# Docker mastodon user # Docker mastodon user
# If you use Docker, you may want to assign UID/GID manually. # If you use Docker, you may want to assign UID/GID manually.
# UID=1000 # UID=1000
# GID=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=

View File

@ -9,11 +9,15 @@ DB_USER=postgres
DB_NAME=postgres DB_NAME=postgres
DB_PASS= DB_PASS=
DB_PORT=5432 DB_PORT=5432
# Optional ElasticSearch configuration
# ES_ENABLED=true
# ES_HOST=es
# ES_PORT=9200
# Federation # Federation
# Note: Changing LOCAL_DOMAIN at a later time will cause unwanted side effects, including breaking all existing 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 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) # 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 # 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) # 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= SECRET_KEY_BASE=
OTP_SECRET= OTP_SECRET=
@ -58,7 +61,7 @@ VAPID_PUBLIC_KEY=
# E-mail configuration # E-mail configuration
# Note: Mailgun and SparkPost (https://sparkpo.st/smtp) each have good free tiers # 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) # 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). # *comment* SMTP_LOGIN and SMTP_PASSWORD (leaving them blank is not enough).
SMTP_SERVER=smtp.mailgun.org SMTP_SERVER=smtp.mailgun.org
SMTP_PORT=587 SMTP_PORT=587
@ -135,3 +138,75 @@ STREAMING_CLUSTER_NUM=1
# If you use Docker, you may want to assign UID/GID manually. # If you use Docker, you may want to assign UID/GID manually.
# UID=1000 # UID=1000
# GID=1000 # GID=1000
# LDAP authentication (optional)
# LDAP_ENABLED=true
# LDAP_HOST=localhost
# LDAP_PORT=389
# LDAP_METHOD=simple_tls
# LDAP_BASE=
# LDAP_BIND_DN=
# LDAP_PASSWORD=
# LDAP_UID=cn
# PAM authentication (optional)
# PAM authentication uses for the email generation the "email" pam variable
# and optional as fallback PAM_DEFAULT_SUFFIX
# The pam environment variable "email" is provided by:
# https://github.com/devkral/pam_email_extractor
# PAM_ENABLED=true
# Fallback email domain for email address generation (LOCAL_DOMAIN by default)
# PAM_EMAIL_DOMAIN=example.com
# Name of the pam service (pam "auth" section is evaluated)
# PAM_DEFAULT_SERVICE=rpam
# Name of the pam service used for checking if an user can register (pam "account" section is evaluated) (nil (disabled) by default)
# PAM_CONTROLLED_SERVICE=rpam
# Global OAuth settings (optional) :
# If you have only one strategy, you may want to enable this
# OAUTH_REDIRECT_AT_SIGN_IN=true
# Optional CAS authentication (cf. omniauth-cas) :
# CAS_ENABLED=true
# CAS_URL=https://sso.myserver.com/
# CAS_HOST=sso.myserver.com/
# CAS_PORT=443
# CAS_SSL=true
# CAS_VALIDATE_URL=
# CAS_CALLBACK_URL=
# CAS_LOGOUT_URL=
# CAS_LOGIN_URL=
# CAS_UID_FIELD='user'
# CAS_CA_PATH=
# CAS_DISABLE_SSL_VERIFICATION=false
# CAS_UID_KEY='user'
# CAS_NAME_KEY='name'
# CAS_EMAIL_KEY='email'
# CAS_NICKNAME_KEY='nickname'
# CAS_FIRST_NAME_KEY='firstname'
# CAS_LAST_NAME_KEY='lastname'
# CAS_LOCATION_KEY='location'
# CAS_IMAGE_KEY='image'
# CAS_PHONE_KEY='phone'
# Optional SAML authentication (cf. omniauth-saml)
# SAML_ENABLED=true
# SAML_ACS_URL=
# SAML_ISSUER=http://localhost:3000/auth/auth/saml/callback
# SAML_IDP_SSO_TARGET_URL=https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO
# SAML_IDP_CERT=
# SAML_IDP_CERT_FINGERPRINT=
# SAML_NAME_IDENTIFIER_FORMAT=
# SAML_CERT=
# SAML_PRIVATE_KEY=
# SAML_SECURITY_WANT_ASSERTION_SIGNED=true
# SAML_SECURITY_WANT_ASSERTION_ENCRYPTED=true
# SAML_SECURITY_ASSUME_EMAIL_IS_VERIFIED=true
# SAML_ATTRIBUTES_STATEMENTS_UID="urn:oid:0.9.2342.19200300.100.1.1"
# SAML_ATTRIBUTES_STATEMENTS_EMAIL="urn:oid:1.3.6.1.4.1.5923.1.1.1.6"
# SAML_ATTRIBUTES_STATEMENTS_FULL_NAME="urn:oid:2.16.840.1.113730.3.1.241"
# SAML_ATTRIBUTES_STATEMENTS_FIRST_NAME="urn:oid:2.5.4.42"
# SAML_ATTRIBUTES_STATEMENTS_LAST_NAME="urn:oid:2.5.4.4"
# SAML_UID_ATTRIBUTE="urn:oid:0.9.2342.19200300.100.1.1"
# SAML_ATTRIBUTES_STATEMENTS_VERIFIED=
# SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL=

View File

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

View File

@ -3,15 +3,15 @@ cache:
bundler: true bundler: true
yarn: true yarn: true
directories: directories:
- node_modules - node_modules
- public/assets - public/assets
- public/packs-test - public/packs-test
- tmp/cache/babel-loader - tmp/cache/babel-loader
dist: trusty dist: trusty
sudo: false sudo: false
branches: branches:
only: only:
- master - master
notifications: notifications:
email: false email: false
@ -23,21 +23,20 @@ env:
- RAILS_ENV=test - RAILS_ENV=test
- NOKOGIRI_USE_SYSTEM_LIBRARIES=true - NOKOGIRI_USE_SYSTEM_LIBRARIES=true
- PARALLEL_TEST_PROCESSORS=2 - PARALLEL_TEST_PROCESSORS=2
- "PATH=$HOME:$PATH"
addons: addons:
postgresql: 9.4 postgresql: 9.4
apt: apt:
sources: sources:
- trusty-media - trusty-media
- sourceline: deb https://dl.yarnpkg.com/debian/ stable main - sourceline: deb https://dl.yarnpkg.com/debian/ stable main
key_url: https://dl.yarnpkg.com/debian/pubkey.gpg key_url: https://dl.yarnpkg.com/debian/pubkey.gpg
packages: packages:
- ffmpeg - ffmpeg
- libicu-dev - libicu-dev
- libprotobuf-dev - libprotobuf-dev
- protobuf-compiler - protobuf-compiler
- yarn - yarn
rvm: rvm:
- 2.4.2 - 2.4.2
@ -53,9 +52,8 @@ install:
before_script: before_script:
- ./bin/rails parallel:create parallel:load_schema parallel:prepare assets:precompile - ./bin/rails parallel:create parallel:load_schema parallel:prepare assets:precompile
- ln -s /usr/bin/x86_64-linux-gnu-g++-6 "$HOME/g++"
script: script:
- travis_retry bundle exec parallel_test spec/ --group-by filesize --type rspec - travis_retry bundle exec parallel_test spec/ --group-by filesize --type rspec
- yarn test - yarn run test:jest
- bundle exec i18n-tasks check-normalized && bundle exec i18n-tasks unused - bundle exec i18n-tasks check-normalized && bundle exec i18n-tasks unused

450
AUTHORS.md Normal file
View File

@ -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.

View File

@ -1,10 +1,12 @@
FROM ruby:2.5.0-alpine3.7 FROM ruby:2.4.3-alpine3.6
LABEL maintainer="https://github.com/tootsuite/mastodon" \ 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 \ ARG UID=991
RAILS_SERVE_STATIC_FILES=true \ ARG GID=991
ENV RAILS_SERVE_STATIC_FILES=true \
RAILS_ENV=production NODE_ENV=production RAILS_ENV=production NODE_ENV=production
ARG YARN_VERSION=1.3.2 ARG YARN_VERSION=1.3.2
@ -38,7 +40,6 @@ RUN apk -U upgrade \
nodejs \ nodejs \
nodejs-npm \ nodejs-npm \
protobuf \ protobuf \
su-exec \
tini \ tini \
tzdata \ tzdata \
&& update-ca-certificates \ && update-ca-certificates \
@ -68,12 +69,16 @@ RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-in
&& yarn --pure-lockfile \ && yarn --pure-lockfile \
&& yarn cache clean && yarn cache clean
RUN addgroup -g ${GID} mastodon && adduser -h /mastodon -s /bin/sh -D -G mastodon -u ${UID} mastodon \
&& mkdir -p /mastodon/public/system /mastodon/public/assets /mastodon/public/packs \
&& chown -R mastodon:mastodon /mastodon/public
COPY . /mastodon COPY . /mastodon
COPY docker_entrypoint.sh /usr/local/bin/run RUN chown -R mastodon:mastodon /mastodon
RUN chmod +x /usr/local/bin/run
VOLUME /mastodon/public/system /mastodon/public/assets /mastodon/public/packs VOLUME /mastodon/public/system /mastodon/public/assets /mastodon/public/packs
ENTRYPOINT ["/usr/local/bin/run"] USER mastodon
ENTRYPOINT ["/sbin/tini", "--"]

22
Gemfile
View File

@ -7,7 +7,6 @@ gem 'pkg-config', '~> 1.2'
gem 'puma', '~> 3.10' gem 'puma', '~> 3.10'
gem 'rails', '~> 5.1.4' gem 'rails', '~> 5.1.4'
gem 'uglifier', '~> 3.2'
gem 'hamlit-rails', '~> 0.2' gem 'hamlit-rails', '~> 0.2'
gem 'pg', '~> 0.20' gem 'pg', '~> 0.20'
@ -20,6 +19,7 @@ gem 'fog-local', '~> 0.4', require: false
gem 'fog-openstack', '~> 0.1', require: false gem 'fog-openstack', '~> 0.1', require: false
gem 'paperclip', '~> 5.1' gem 'paperclip', '~> 5.1'
gem 'paperclip-av-transcoder', '~> 0.6' gem 'paperclip-av-transcoder', '~> 0.6'
gem 'streamio-ffmpeg', '~> 3.0'
gem 'active_model_serializers', '~> 0.10' gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.5' gem 'addressable', '~> 2.5'
@ -27,11 +27,22 @@ gem 'bootsnap'
gem 'browser' gem 'browser'
gem 'charlock_holmes', '~> 0.7.5' gem 'charlock_holmes', '~> 0.7.5'
gem 'iso-639' gem 'iso-639'
gem 'chewy', '~> 5.0'
gem 'cld3', '~> 3.2.0' gem 'cld3', '~> 3.2.0'
gem 'devise', '~> 4.4' gem 'devise', '~> 4.4'
gem 'devise-two-factor', '~> 3.0' gem 'devise-two-factor', '~> 3.0'
group :pam_authentication, optional: true do
gem 'devise_pam_authenticatable2', '~> 9.0'
end
gem 'net-ldap', '~> 0.10'
gem 'omniauth-cas', '~> 1.1'
gem 'omniauth-saml', '~> 1.10'
gem 'omniauth', '~> 1.2'
gem 'doorkeeper', '~> 4.2' gem 'doorkeeper', '~> 4.2'
gem 'fast_blank', '~> 1.0' gem 'fast_blank', '~> 1.0'
gem 'fastimage'
gem 'goldfinger', '~> 2.1' gem 'goldfinger', '~> 2.1'
gem 'hiredis', '~> 0.6' gem 'hiredis', '~> 0.6'
gem 'redis-namespace', '~> 1.5' gem 'redis-namespace', '~> 1.5'
@ -60,7 +71,7 @@ gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'rqrcode', '~> 0.10' gem 'rqrcode', '~> 0.10'
gem 'ruby-oembed', '~> 0.12', require: 'oembed' gem 'ruby-oembed', '~> 0.12', require: 'oembed'
gem 'ruby-progressbar', '~> 1.4' gem 'ruby-progressbar', '~> 1.4'
gem 'sanitize', '~> 4.4' gem 'sanitize', '~> 4.6.4'
gem 'sidekiq', '~> 5.0' gem 'sidekiq', '~> 5.0'
gem 'sidekiq-scheduler', '~> 2.1' gem 'sidekiq-scheduler', '~> 2.1'
gem 'sidekiq-unique-jobs', '~> 5.0' gem 'sidekiq-unique-jobs', '~> 5.0'
@ -69,6 +80,8 @@ gem 'simple-navigation', '~> 4.0'
gem 'simple_form', '~> 3.4' gem 'simple_form', '~> 3.4'
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
gem 'strong_migrations' gem 'strong_migrations'
gem 'tty-command'
gem 'tty-prompt'
gem 'twitter-text', '~> 1.14' gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2017' gem 'tzinfo-data', '~> 1.2017'
gem 'webpacker', '~> 3.0' gem 'webpacker', '~> 3.0'
@ -85,6 +98,10 @@ group :development, :test do
gem 'rspec-rails', '~> 3.7' gem 'rspec-rails', '~> 3.7'
end end
group :production, :test do
gem 'private_address_check', '~> 0.4.1'
end
group :test do group :test do
gem 'capybara', '~> 2.15' gem 'capybara', '~> 2.15'
gem 'climate_control', '~> 0.2' gem 'climate_control', '~> 0.2'
@ -105,6 +122,7 @@ group :development do
gem 'bullet', '~> 5.5' gem 'bullet', '~> 5.5'
gem 'letter_opener', '~> 1.4' gem 'letter_opener', '~> 1.4'
gem 'letter_opener_web', '~> 1.3' gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler'
gem 'rubocop', require: false gem 'rubocop', require: false
gem 'brakeman', '~> 4.0', require: false gem 'brakeman', '~> 4.0', require: false
gem 'bundler-audit', '~> 0.6', require: false gem 'bundler-audit', '~> 0.6', require: false

View File

@ -109,6 +109,10 @@ GEM
case_transform (0.2) case_transform (0.2)
activesupport activesupport
charlock_holmes (0.7.5) charlock_holmes (0.7.5)
chewy (5.0.0)
activesupport (>= 4.0)
elasticsearch (>= 2.0.0)
elasticsearch-dsl
chunky_png (1.3.8) chunky_png (1.3.8)
cld3 (3.2.2) cld3 (3.2.2)
ffi (>= 1.1.0, < 1.10.0) ffi (>= 1.1.0, < 1.10.0)
@ -137,6 +141,9 @@ GEM
devise (~> 4.0) devise (~> 4.0)
railties (< 5.2) railties (< 5.2)
rotp (~> 2.0) rotp (~> 2.0)
devise_pam_authenticatable2 (9.0.0)
devise (>= 4.0.0)
rpam2 (~> 3.0)
diff-lcs (1.3) diff-lcs (1.3)
docile (1.1.5) docile (1.1.5)
domain_name (0.5.20170404) domain_name (0.5.20170404)
@ -151,16 +158,28 @@ GEM
json json
thread thread
thread_safe 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) encryptor (3.0.0)
equatable (0.5.0)
erubi (1.7.0) erubi (1.7.0)
et-orbi (1.0.8) et-orbi (1.0.8)
tzinfo tzinfo
excon (0.59.0) excon (0.59.0)
execjs (2.7.0)
fabrication (2.18.0) fabrication (2.18.0)
faker (1.8.4) faker (1.8.4)
i18n (~> 0.5) i18n (~> 0.5)
faraday (0.14.0)
multipart-post (>= 1.2, < 3)
fast_blank (1.0.0) fast_blank (1.0.0)
fastimage (2.1.1)
ffi (1.9.18) ffi (1.9.18)
fog-core (1.45.0) fog-core (1.45.0)
builder builder
@ -198,8 +217,10 @@ GEM
hamster (3.0.0) hamster (3.0.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
hashdiff (0.3.7) hashdiff (0.3.7)
hashie (3.5.7)
highline (1.7.10) highline (1.7.10)
hiredis (0.6.1) hiredis (0.6.1)
hitimes (1.2.6)
hkdf (0.3.0) hkdf (0.3.0)
htmlentities (4.3.4) htmlentities (4.3.4)
http (3.0.0) http (3.0.0)
@ -215,7 +236,7 @@ GEM
httplog (0.99.7) httplog (0.99.7)
colorize colorize
rack rack
i18n (0.9.1) i18n (0.9.3)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
i18n-tasks (0.9.19) i18n-tasks (0.9.19)
activesupport (>= 4.0.2) activesupport (>= 4.0.2)
@ -267,13 +288,14 @@ GEM
activesupport (>= 4, < 5.2) activesupport (>= 4, < 5.2)
railties (>= 4, < 5.2) railties (>= 4, < 5.2)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.1.1) loofah (2.2.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
mail (2.7.0) mail (2.7.0)
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
mario-redis-lock (1.2.0) mario-redis-lock (1.2.0)
redis (~> 3, >= 3.0.5) redis (~> 3, >= 3.0.5)
memory_profiler (0.9.10)
method_source (0.9.0) method_source (0.9.0)
microformats (4.0.7) microformats (4.0.7)
json json
@ -284,16 +306,19 @@ GEM
mimemagic (0.3.2) mimemagic (0.3.2)
mini_mime (1.0.0) mini_mime (1.0.0)
mini_portile2 (2.3.0) mini_portile2 (2.3.0)
minitest (5.10.3) minitest (5.11.3)
msgpack (1.1.0) msgpack (1.1.0)
multi_json (1.12.2) 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-scp (1.2.1)
net-ssh (>= 2.6.5) net-ssh (>= 2.6.5)
net-ssh (4.2.0) net-ssh (4.2.0)
nio4r (2.1.0) nio4r (2.1.0)
nokogiri (1.8.1) nokogiri (1.8.2)
mini_portile2 (~> 2.3.0) mini_portile2 (~> 2.3.0)
nokogumbo (1.4.13) nokogumbo (1.5.0)
nokogiri nokogiri
nsa (0.2.4) nsa (0.2.4)
activesupport (>= 4.2, < 6) activesupport (>= 4.2, < 6)
@ -301,13 +326,23 @@ GEM
sidekiq (>= 3.5.0) sidekiq (>= 3.5.0)
statsd-ruby (~> 1.2.0) statsd-ruby (~> 1.2.0)
oj (3.3.10) 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) orm_adapter (0.5.0)
ostatus2 (2.0.3) ostatus2 (2.0.3)
addressable (~> 2.5) addressable (~> 2.5)
http (~> 3.0) http (~> 3.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
ox (2.8.2) ox (2.8.2)
paperclip (5.1.0) paperclip (5.2.1)
activemodel (>= 4.2.0) activemodel (>= 4.2.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
cocaine (~> 0.5.5) cocaine (~> 0.5.5)
@ -321,6 +356,9 @@ GEM
parallel parallel
parser (2.4.0.2) parser (2.4.0.2)
ast (~> 2.3) ast (~> 2.3)
pastel (0.7.2)
equatable (~> 0.5.0)
tty-color (~> 0.4.0)
pg (0.21.0) pg (0.21.0)
pghero (1.7.0) pghero (1.7.0)
activerecord activerecord
@ -333,6 +371,7 @@ GEM
premailer-rails (1.10.1) premailer-rails (1.10.1)
actionmailer (>= 3, < 6) actionmailer (>= 3, < 6)
premailer (~> 1.7, >= 1.7.9) premailer (~> 1.7, >= 1.7.9)
private_address_check (0.4.1)
pry (0.11.3) pry (0.11.3)
coderay (~> 1.1.0) coderay (~> 1.1.0)
method_source (~> 0.9.0) method_source (~> 0.9.0)
@ -420,6 +459,7 @@ GEM
actionpack (>= 4.2.0, < 5.3) actionpack (>= 4.2.0, < 5.3)
railties (>= 4.2.0, < 5.3) railties (>= 4.2.0, < 5.3)
rotp (2.1.2) rotp (2.1.2)
rpam2 (3.1.0)
rqrcode (0.10.1) rqrcode (0.10.1)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rspec-core (3.7.0) rspec-core (3.7.0)
@ -451,13 +491,15 @@ GEM
unicode-display_width (~> 1.0, >= 1.0.1) unicode-display_width (~> 1.0, >= 1.0.1)
ruby-oembed (0.12.0) ruby-oembed (0.12.0)
ruby-progressbar (1.9.0) ruby-progressbar (1.9.0)
ruby-saml (1.7.2)
nokogiri (>= 1.5.10)
rufus-scheduler (3.4.2) rufus-scheduler (3.4.2)
et-orbi (~> 1.0) et-orbi (~> 1.0)
safe_yaml (1.0.4) safe_yaml (1.0.4)
sanitize (4.5.0) sanitize (4.6.4)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.4.4) nokogiri (>= 1.4.4)
nokogumbo (~> 1.4.1) nokogumbo (~> 1.4)
sass (3.5.3) sass (3.5.3)
sass-listen (~> 4.0.0) sass-listen (~> 4.0.0)
sass-listen (4.0.0) sass-listen (4.0.0)
@ -503,6 +545,8 @@ GEM
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
statsd-ruby (1.2.1) statsd-ruby (1.2.1)
streamio-ffmpeg (3.0.2)
multi_json (~> 1.8)
strong_migrations (0.1.9) strong_migrations (0.1.9)
activerecord (>= 3.2.0) activerecord (>= 3.2.0)
temple (0.8.0) temple (0.8.0)
@ -512,14 +556,29 @@ GEM
thread (0.2.2) thread (0.2.2)
thread_safe (0.3.6) thread_safe (0.3.6)
tilt (2.0.8) 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) twitter-text (1.14.7)
unf (~> 0.1.0) unf (~> 0.1.0)
tzinfo (1.2.4) tzinfo (1.2.4)
thread_safe (~> 0.1) thread_safe (~> 0.1)
tzinfo-data (1.2017.3) tzinfo-data (1.2017.3)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
uglifier (3.2.0)
execjs (>= 0.3.0, < 3)
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.7.4) unf_ext (0.0.7.4)
@ -541,6 +600,7 @@ GEM
websocket-driver (0.6.5) websocket-driver (0.6.5)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.3) websocket-extensions (0.1.3)
wisper (2.0.0)
xpath (2.1.0) xpath (2.1.0)
nokogiri (~> 1.3) nokogiri (~> 1.3)
@ -566,15 +626,18 @@ DEPENDENCIES
capistrano-yarn (~> 2.0) capistrano-yarn (~> 2.0)
capybara (~> 2.15) capybara (~> 2.15)
charlock_holmes (~> 0.7.5) charlock_holmes (~> 0.7.5)
chewy (~> 5.0)
cld3 (~> 3.2.0) cld3 (~> 3.2.0)
climate_control (~> 0.2) climate_control (~> 0.2)
devise (~> 4.4) devise (~> 4.4)
devise-two-factor (~> 3.0) devise-two-factor (~> 3.0)
devise_pam_authenticatable2 (~> 9.0)
doorkeeper (~> 4.2) doorkeeper (~> 4.2)
dotenv-rails (~> 2.2) dotenv-rails (~> 2.2)
fabrication (~> 2.18) fabrication (~> 2.18)
faker (~> 1.7) faker (~> 1.7)
fast_blank (~> 1.0) fast_blank (~> 1.0)
fastimage
fog-core (~> 1.45) fog-core (~> 1.45)
fog-local (~> 0.4) fog-local (~> 0.4)
fog-openstack (~> 0.1) fog-openstack (~> 0.1)
@ -596,11 +659,16 @@ DEPENDENCIES
link_header (~> 0.0) link_header (~> 0.0)
lograge (~> 0.7) lograge (~> 0.7)
mario-redis-lock (~> 1.2) mario-redis-lock (~> 1.2)
memory_profiler
microformats (~> 4.0) microformats (~> 4.0)
mime-types (~> 3.1) mime-types (~> 3.1)
net-ldap (~> 0.10)
nokogiri (~> 1.8) nokogiri (~> 1.8)
nsa (~> 0.2) nsa (~> 0.2)
oj (~> 3.3) oj (~> 3.3)
omniauth (~> 1.2)
omniauth-cas (~> 1.1)
omniauth-saml (~> 1.10)
ostatus2 (~> 2.0) ostatus2 (~> 2.0)
ox (~> 2.8) ox (~> 2.8)
paperclip (~> 5.1) paperclip (~> 5.1)
@ -610,6 +678,7 @@ DEPENDENCIES
pghero (~> 1.7) pghero (~> 1.7)
pkg-config (~> 1.2) pkg-config (~> 1.2)
premailer-rails premailer-rails
private_address_check (~> 0.4.1)
pry-rails (~> 0.3) pry-rails (~> 0.3)
puma (~> 3.10) puma (~> 3.10)
pundit (~> 1.1) pundit (~> 1.1)
@ -630,7 +699,7 @@ DEPENDENCIES
rubocop rubocop
ruby-oembed (~> 0.12) ruby-oembed (~> 0.12)
ruby-progressbar (~> 1.4) ruby-progressbar (~> 1.4)
sanitize (~> 4.4) sanitize (~> 4.6.4)
scss_lint (~> 0.55) scss_lint (~> 0.55)
sidekiq (~> 5.0) sidekiq (~> 5.0)
sidekiq-bulk (~> 0.1.1) sidekiq-bulk (~> 0.1.1)
@ -640,10 +709,12 @@ DEPENDENCIES
simple_form (~> 3.4) simple_form (~> 3.4)
simplecov (~> 0.14) simplecov (~> 0.14)
sprockets-rails (~> 3.2) sprockets-rails (~> 3.2)
streamio-ffmpeg (~> 3.0)
strong_migrations strong_migrations
tty-command
tty-prompt
twitter-text (~> 1.14) twitter-text (~> 1.14)
tzinfo-data (~> 1.2017) tzinfo-data (~> 1.2017)
uglifier (~> 3.2)
webmock (~> 3.0) webmock (~> 3.0)
webpacker (~> 3.0) webpacker (~> 3.0)
webpush webpush

View File

@ -1,4 +1,4 @@
web: PORT=3000 bundle exec puma -C config/puma.rb web: env PORT=3000 bundle exec puma -C config/puma.rb
sidekiq: PORT=3000 bundle exec sidekiq sidekiq: env PORT=3000 bundle exec sidekiq
stream: PORT=4000 yarn run start stream: env PORT=4000 yarn run start
webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0 webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0

View File

@ -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. **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 [patreon]: https://www.patreon.com/user?u=619786
[liberapay]: https://liberapay.com/Mastodon/
--- ---
@ -78,6 +79,16 @@ You can open issues for bugs you've found or features you think are missing. You
**IRC channel**: #mastodon on irc.freenode.net **IRC channel**: #mastodon on irc.freenode.net
## License
Copyright (C) 2016-2018 Eugen Rochko & other Mastodon contributors (see AUTHORS.md)
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
--- ---
## Extra credits ## Extra credits

5
Vagrantfile vendored
View File

@ -39,6 +39,7 @@ sudo apt-get install \
libidn11-dev \ libidn11-dev \
libprotobuf-dev \ libprotobuf-dev \
libreadline-dev \ libreadline-dev \
libpam0g-dev \
-y -y
# Install rvm # Install rvm
@ -48,7 +49,7 @@ curl -sSL https://raw.githubusercontent.com/rvm/rvm/stable/binscripts/rvm-instal
source /home/vagrant/.rvm/scripts/rvm source /home/vagrant/.rvm/scripts/rvm
# Install Ruby # Install Ruby
rvm install ruby-$RUBY_VERSION rvm reinstall ruby-$RUBY_VERSION --disable-binary
# Configure database # Configure database
sudo -u postgres createuser -U postgres vagrant -s sudo -u postgres createuser -U postgres vagrant -s
@ -79,7 +80,7 @@ VAGRANTFILE_API_VERSION = "2"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.box = "ubuntu/trusty64" config.vm.box = "ubuntu/xenial64"
config.vm.provider :virtualbox do |vb| config.vm.provider :virtualbox do |vb|
vb.name = "mastodon" vb.name = "mastodon"

View File

@ -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

View File

@ -31,7 +31,7 @@ class AboutController < ApplicationController
def initial_state_params def initial_state_params
{ {
settings: {}, settings: { known_fediverse: Setting.show_known_fediverse_at_about_page },
token: current_session&.token, token: current_session&.token,
} }
end end

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class AccountsController < ApplicationController class AccountsController < ApplicationController
PAGE_SIZE = 20
include AccountControllerConcern include AccountControllerConcern
before_action :set_cache_headers before_action :set_cache_headers
@ -16,13 +18,16 @@ class AccountsController < ApplicationController
end end
@pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses? @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) @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 end
format.atom do 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? })) render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? }))
end end
@ -69,13 +74,22 @@ class AccountsController < ApplicationController
@account = Account.find_local!(params[:username]) @account = Account.find_local!(params[:username])
end 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? 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? 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 else
short_account_url(@account, max_id: @statuses.last.id) short_account_url(@account, max_id: max_id, min_id: min_id)
end end
end end
@ -86,4 +100,12 @@ class AccountsController < ApplicationController
def replies_requested? def replies_requested?
request.path.ends_with?('/with_replies') request.path.ends_with?('/with_replies')
end 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 end

View File

@ -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

View File

@ -11,7 +11,7 @@ class ActivityPub::InboxesController < Api::BaseController
process_payload process_payload
head 202 head 202
else else
[signature_verification_failure_reason, 401] render plain: signature_verification_failure_reason, status: 401
end end
end end

View File

@ -1,13 +1,15 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::OutboxesController < Api::BaseController class ActivityPub::OutboxesController < Api::BaseController
include SignatureVerification
before_action :set_account before_action :set_account
def show 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) @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 end
private private

View File

@ -16,9 +16,11 @@ module Admin
show_staff_badge show_staff_badge
bootstrap_timeline_accounts bootstrap_timeline_accounts
thumbnail thumbnail
hero
min_invite_role min_invite_role
activity_api_enabled activity_api_enabled
peers_api_enabled peers_api_enabled
show_known_fediverse_at_about_page
).freeze ).freeze
BOOLEAN_SETTINGS = %w( BOOLEAN_SETTINGS = %w(
@ -28,10 +30,12 @@ module Admin
show_staff_badge show_staff_badge
activity_api_enabled activity_api_enabled
peers_api_enabled peers_api_enabled
show_known_fediverse_at_about_page
).freeze ).freeze
UPLOAD_SETTINGS = %w( UPLOAD_SETTINGS = %w(
thumbnail thumbnail
hero
).freeze ).freeze
def edit def edit

View File

@ -51,6 +51,10 @@ class Api::BaseController < ApplicationController
[params[:limit].to_i.abs, default_limit * 2].min [params[:limit].to_i.abs, default_limit * 2].min
end end
def truthy_param?(key)
ActiveModel::Type::Boolean.new.cast(params[key])
end
def current_resource_owner def current_resource_owner
@current_user ||= User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token @current_user ||= User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
end end

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::SalmonController < Api::BaseController class Api::SalmonController < Api::BaseController
include SignatureVerification
before_action :set_account before_action :set_account
respond_to :txt respond_to :txt
@ -9,7 +11,7 @@ class Api::SalmonController < Api::BaseController
process_salmon process_salmon
head 202 head 202
elsif payload.present? elsif payload.present?
[signature_verification_failure_reason, 401] render plain: signature_verification_failure_reason, status: 401
else else
head 400 head 400
end end

View File

@ -20,6 +20,6 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
private private
def account_params def account_params
params.permit(:display_name, :note, :avatar, :header) params.permit(:display_name, :note, :avatar, :header, :locked)
end end
end end

View File

@ -10,7 +10,7 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
accounts = Account.where(id: account_ids).select('id') accounts = Account.where(id: account_ids).select('id')
# .where doesn't guarantee that our results are in the same order # .where doesn't guarantee that our results are in the same order
# we requested them, so return the "right" order to the requestor. # 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 render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships
end end
@ -21,6 +21,6 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
end end
def account_ids def account_ids
@_account_ids ||= Array(params[:id]).map(&:to_i) Array(params[:id]).map(&:to_i)
end end
end end

View File

@ -22,8 +22,4 @@ class Api::V1::Accounts::SearchController < Api::BaseController
following: truthy_param?(:following) following: truthy_param?(:following)
) )
end end
def truthy_param?(key)
params[key] == 'true'
end
end end

View File

@ -28,9 +28,9 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
def account_statuses def account_statuses
default_statuses.tap do |statuses| default_statuses.tap do |statuses|
statuses.merge!(only_media_scope) if params[:only_media] statuses.merge!(only_media_scope) if truthy_param?(:only_media)
statuses.merge!(pinned_scope) if params[:pinned] statuses.merge!(pinned_scope) if truthy_param?(:pinned)
statuses.merge!(no_replies_scope) if params[:exclude_replies] statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
end end
end end
@ -51,7 +51,13 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
end end
def account_media_status_ids def account_media_status_ids
@account.media_attachments.attached.reorder(nil).select(:status_id).distinct # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.
# Also, Avoid getting slow by not narrowing down by `statuses.account_id`.
# When narrowing down by `statuses.account_id`, `index_statuses_20180106` will be used
# and the table will be joined by `Merge Semi Join`, so the query will be slow.
Status.joins(:media_attachments).merge(@account.media_attachments).permitted_for(@account, current_account)
.paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
.reorder(id: :desc).distinct(:id).pluck(:id)
end end
def pinned_scope def pinned_scope

View File

@ -13,9 +13,9 @@ class Api::V1::AccountsController < Api::BaseController
end end
def follow 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) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options)
end end
@ -26,7 +26,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def mute 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 render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end end

View File

@ -27,7 +27,7 @@ class Api::V1::MediaController < Api::BaseController
private private
def media_params def media_params
params.permit(:file, :description) params.permit(:file, :description, :focus)
end end
def file_type_error def file_type_error

View File

@ -13,14 +13,14 @@ class Api::V1::ReportsController < Api::BaseController
end end
def create def create
@report = current_account.reports.create!( @report = ReportService.new.call(
target_account: reported_account, current_account,
reported_account,
status_ids: reported_status_ids, 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 render json: @report, serializer: REST::ReportSerializer
end end
@ -39,6 +39,6 @@ class Api::V1::ReportsController < Api::BaseController
end end
def report_params def report_params
params.permit(:account_id, :comment, status_ids: []) params.permit(:account_id, :comment, :forward, status_ids: [])
end end
end end

View File

@ -33,12 +33,8 @@ class Api::V1::SearchController < Api::BaseController
SearchService.new.call( SearchService.new.call(
params[:q], params[:q],
RESULTS_LIMIT, RESULTS_LIMIT,
resolving_search?, truthy_param?(:resolve),
current_account current_account
) )
end end
def resolving_search?
params[:resolve] == 'true'
end
end end

View File

@ -11,12 +11,18 @@ class Api::V1::Statuses::PinsController < Api::BaseController
def create def create
StatusPin.create!(account: current_account, status: @status) StatusPin.create!(account: current_account, status: @status)
distribute_add_activity!
render json: @status, serializer: REST::StatusSerializer render json: @status, serializer: REST::StatusSerializer
end end
def destroy def destroy
pin = StatusPin.find_by(account: current_account, status: @status) 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 render json: @status, serializer: REST::StatusSerializer
end end
@ -25,4 +31,24 @@ class Api::V1::Statuses::PinsController < Api::BaseController
def set_status def set_status
@status = Status.find(params[:status_id]) @status = Status.find(params[:status_id])
end 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 end

View File

@ -21,15 +21,23 @@ class Api::V1::Timelines::PublicController < Api::BaseController
end end
def public_statuses def public_statuses
public_timeline_statuses.paginate_by_max_id( statuses = public_timeline_statuses.paginate_by_max_id(
limit_param(DEFAULT_STATUSES_LIMIT), limit_param(DEFAULT_STATUSES_LIMIT),
params[:max_id], params[:max_id],
params[:since_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 public_timeline_statuses def public_timeline_statuses
Status.as_public_timeline(current_account, params[:local]) Status.as_public_timeline(current_account, truthy_param?(:local))
end end
def insert_pagination_headers def insert_pagination_headers
@ -37,7 +45,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController
end end
def pagination_params(core_params) def pagination_params(core_params)
params.permit(:local, :limit).merge(core_params) params.permit(:local, :limit, :only_media).merge(core_params)
end end
def next_path def next_path

View File

@ -29,16 +29,24 @@ class Api::V1::Timelines::TagController < Api::BaseController
if @tag.nil? if @tag.nil?
[] []
else else
tag_timeline_statuses.paginate_by_max_id( statuses = tag_timeline_statuses.paginate_by_max_id(
limit_param(DEFAULT_STATUSES_LIMIT), limit_param(DEFAULT_STATUSES_LIMIT),
params[:max_id], params[:max_id],
params[:since_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
end end
def tag_timeline_statuses def tag_timeline_statuses
Status.as_tag_timeline(@tag, current_account, params[:local]) Status.as_tag_timeline(@tag, current_account, truthy_param?(:local))
end end
def insert_pagination_headers def insert_pagination_headers
@ -46,7 +54,7 @@ class Api::V1::Timelines::TagController < Api::BaseController
end end
def pagination_params(core_params) def pagination_params(core_params)
params.permit(:local, :limit).merge(core_params) params.permit(:local, :limit, :only_media).merge(core_params)
end end
def next_path def next_path

View File

@ -14,6 +14,7 @@ class ApplicationController < ActionController::Base
helper_method :current_session helper_method :current_session
helper_method :current_theme helper_method :current_theme
helper_method :single_user_mode? helper_method :single_user_mode?
helper_method :use_seamless_external_login?
rescue_from ActionController::RoutingError, with: :not_found rescue_from ActionController::RoutingError, with: :not_found
rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActiveRecord::RecordNotFound, with: :not_found
@ -34,7 +35,7 @@ class ApplicationController < ActionController::Base
end end
def store_current_location def store_current_location
store_location_for(:user, request.url) store_location_for(:user, request.url) unless request.format == :json
end end
def require_admin! def require_admin!
@ -75,6 +76,10 @@ class ApplicationController < ActionController::Base
@single_user_mode ||= Rails.configuration.x.single_user_mode && Account.exists? @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.exists?
end end
def use_seamless_external_login?
Devise.pam_authentication || Devise.ldap_authentication
end
def current_account def current_account
@current_account ||= current_user.try(:account) @current_account ||= current_user.try(:account)
end end

View File

@ -2,4 +2,28 @@
class Auth::ConfirmationsController < Devise::ConfirmationsController class Auth::ConfirmationsController < Devise::ConfirmationsController
layout 'auth' 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 end

View File

@ -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

View File

@ -14,6 +14,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController
protected protected
def update_resource(resource, params)
params[:password] = nil if Devise.pam_authentication && resource.encrypted_password.blank?
super
end
def build_resource(hash = nil) def build_resource(hash = nil)
super(hash) super(hash)

View File

@ -10,6 +10,14 @@ class Auth::SessionsController < Devise::SessionsController
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
before_action :set_instance_presenter, only: [:new] 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 def create
super do |resource| super do |resource|
remember_me(resource) remember_me(resource)
@ -28,7 +36,11 @@ class Auth::SessionsController < Devise::SessionsController
if session[:otp_user_id] if session[:otp_user_id]
User.find(session[:otp_user_id]) User.find(session[:otp_user_id])
elsif user_params[:email] 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
end end
@ -46,6 +58,14 @@ class Auth::SessionsController < Devise::SessionsController
end end
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? def two_factor_enabled?
find_user.try(:otp_required_for_login?) find_user.try(:otp_required_for_login?)
end end

View File

@ -17,11 +17,11 @@ module Localized
end end
def default_locale def default_locale
request_locale || env_locale || I18n.default_locale if ENV['DEFAULT_LOCALE'].present?
end I18n.default_locale
else
def env_locale request_locale || I18n.default_locale
ENV['DEFAULT_LOCALE'] end
end end
def request_locale def request_locale
@ -29,12 +29,10 @@ module Localized
end end
def preferred_locale 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 end
def compatible_locale 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
end end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
module SignatureAuthentication
extend ActiveSupport::Concern
include SignatureVerification
def current_account
super || signed_request_account
end
end

View File

@ -7,7 +7,9 @@ class FollowerAccountsController < ApplicationController
@follows = Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account) @follows = Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account)
respond_to do |format| 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 format.json do
render json: collection_presenter, render json: collection_presenter,

View File

@ -7,7 +7,9 @@ class FollowingAccountsController < ApplicationController
@follows = Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account) @follows = Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account)
respond_to do |format| 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 format.json do
render json: collection_presenter, render json: collection_presenter,

View File

@ -34,7 +34,8 @@ class HomeController < ApplicationController
end end
end end
redirect_to(default_redirect_path) matches = request.path.match(%r{\A/web/timelines/tag/(?<tag>.+)\z})
redirect_to(matches ? tag_path(CGI.unescape(matches[:tag])) : default_redirect_path)
end end
def set_initial_state_json def set_initial_state_json

View File

@ -3,20 +3,26 @@
class MediaController < ApplicationController class MediaController < ApplicationController
include Authorization include Authorization
before_action :verify_permitted_status before_action :set_media_attachment
before_action :verify_permitted_status!
def show 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 end
private private
def media_attachment def set_media_attachment
MediaAttachment.attached.find_by!(shortcode: params[:id]) @media_attachment = MediaAttachment.attached.find_by!(shortcode: params[:id] || params[:medium_id])
end end
def verify_permitted_status def verify_permitted_status!
authorize media_attachment.status, :show? authorize @media_attachment.status, :show?
rescue Mastodon::NotPermittedError rescue Mastodon::NotPermittedError
# Reraise in order to get a 404 instead of a 403 error code # Reraise in order to get a 404 instead of a 403 error code
raise ActiveRecord::RecordNotFound raise ActiveRecord::RecordNotFound

View File

@ -1,11 +1,23 @@
# frozen_string_literal: true # frozen_string_literal: true
class Settings::ExportsController < ApplicationController class Settings::ExportsController < ApplicationController
include Authorization
layout 'admin' layout 'admin'
before_action :authenticate_user! before_action :authenticate_user!
def show 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
end end

View File

@ -39,6 +39,7 @@ class Settings::PreferencesController < ApplicationController
:setting_boost_modal, :setting_boost_modal,
:setting_delete_modal, :setting_delete_modal,
:setting_auto_play_gif, :setting_auto_play_gif,
:setting_display_sensitive_media,
:setting_reduce_motion, :setting_reduce_motion,
:setting_system_font_ui, :setting_system_font_ui,
:setting_noindex, :setting_noindex,

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class StatusesController < ApplicationController class StatusesController < ApplicationController
include SignatureAuthentication
include Authorization include Authorization
layout 'public' layout 'public'

View File

@ -10,6 +10,7 @@ class StreamEntriesController < ApplicationController
before_action :set_stream_entry before_action :set_stream_entry
before_action :set_link_headers before_action :set_link_headers
before_action :check_account_suspension before_action :check_account_suspension
before_action :set_cache_headers
def show def show
respond_to do |format| respond_to do |format|
@ -19,6 +20,10 @@ class StreamEntriesController < ApplicationController
end end
format.atom do 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)) render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.entry(@stream_entry, true))
end end
end end

View File

@ -2,7 +2,7 @@
module InstanceHelper module InstanceHelper
def site_title def site_title
Setting.site_title.presence || site_hostname Setting.site_title
end end
def site_hostname def site_hostname

View File

@ -8,6 +8,56 @@ module StreamEntriesHelper
account.display_name.presence || account.username account.display_name.presence || account.username
end end
def account_description(account)
prepend_str = [
[
number_to_human(account.statuses_count, strip_insignificant_zeros: true),
t('accounts.posts'),
].join(' '),
[
number_to_human(account.following_count, strip_insignificant_zeros: true),
t('accounts.following'),
].join(' '),
[
number_to_human(account.followers_count, strip_insignificant_zeros: true),
t('accounts.followers'),
].join(' '),
].join(', ')
[prepend_str, account.note].join(' · ')
end
def media_summary(status)
attachments = { image: 0, video: 0 }
status.media_attachments.each do |media|
if media.video?
attachments[:video] += 1
else
attachments[:image] += 1
end
end
text = attachments.to_a.reject { |_, value| value.zero? }.map { |key, value| t("statuses.attached.#{key}", count: value) }.join(' · ')
return if text.blank?
t('statuses.attached.description', attached: text)
end
def status_text_summary(status)
return if status.spoiler_text.blank?
t('statuses.content_warning', warning: status.spoiler_text)
end
def status_description(status)
components = [[media_summary(status), status_text_summary(status)].reject(&:blank?).join(' · ')]
components << status.text if status.spoiler_text.blank?
components.reject(&:blank?).join("\n\n")
end
def stream_link_target def stream_link_target
embedded_view? ? '_blank' : nil embedded_view? ? '_blank' : nil
end end

View File

@ -0,0 +1,4 @@
<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -1,6 +1,8 @@
import api from '../api'; import api from '../api';
import { CancelToken } from 'axios';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
import { tagHistory } from '../settings';
import { useEmoji } from './emojis'; import { useEmoji } from './emojis';
import { import {
@ -10,6 +12,8 @@ import {
refreshPublicTimeline, refreshPublicTimeline,
} from './timelines'; } from './timelines';
let cancelFetchComposeSuggestionsAccounts;
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
@ -27,6 +31,9 @@ export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; 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_MOUNT = 'COMPOSE_MOUNT';
export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
@ -92,8 +99,9 @@ export function mentionCompose(account, router) {
export function submitCompose() { export function submitCompose() {
return function (dispatch, getState) { return function (dispatch, getState) {
const status = getState().getIn(['compose', 'text'], ''); 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; return;
} }
@ -102,7 +110,7 @@ export function submitCompose() {
api(getState).post('/api/v1/statuses', { api(getState).post('/api/v1/statuses', {
status, status,
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), 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']), sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
visibility: getState().getIn(['compose', 'privacy']), visibility: getState().getIn(['compose', 'privacy']),
@ -111,6 +119,7 @@ export function submitCompose() {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
}, },
}).then(function (response) { }).then(function (response) {
dispatch(insertIntoTagHistory(response.data.tags));
dispatch(submitComposeSuccess({ ...response.data })); dispatch(submitComposeSuccess({ ...response.data }));
// To make the app more responsive, immediately get the status into the columns // To make the app more responsive, immediately get the status into the columns
@ -178,11 +187,11 @@ export function uploadCompose(files) {
}; };
}; };
export function changeUploadCompose(id, description) { export function changeUploadCompose(id, params) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(changeUploadComposeRequest()); 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)); dispatch(changeUploadComposeSuccess(response.data));
}).catch(error => { }).catch(error => {
dispatch(changeUploadComposeFail(id, error)); dispatch(changeUploadComposeFail(id, error));
@ -251,13 +260,22 @@ export function undoUploadCompose(media_id) {
}; };
export function clearComposeSuggestions() { export function clearComposeSuggestions() {
if (cancelFetchComposeSuggestionsAccounts) {
cancelFetchComposeSuggestionsAccounts();
}
return { return {
type: COMPOSE_SUGGESTIONS_CLEAR, type: COMPOSE_SUGGESTIONS_CLEAR,
}; };
}; };
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
if (cancelFetchComposeSuggestionsAccounts) {
cancelFetchComposeSuggestionsAccounts();
}
api(getState).get('/api/v1/accounts/search', { api(getState).get('/api/v1/accounts/search', {
cancelToken: new CancelToken(cancel => {
cancelFetchComposeSuggestionsAccounts = cancel;
}),
params: { params: {
q: token.slice(1), q: token.slice(1),
resolve: false, resolve: false,
@ -273,12 +291,22 @@ const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
dispatch(readyComposeSuggestionsEmojis(token, results)); dispatch(readyComposeSuggestionsEmojis(token, results));
}; };
const fetchComposeSuggestionsTags = (dispatch, getState, token) => {
dispatch(updateSuggestionTags(token));
};
export function fetchComposeSuggestions(token) { export function fetchComposeSuggestions(token) {
return (dispatch, getState) => { return (dispatch, getState) => {
if (token[0] === ':') { switch (token[0]) {
case ':':
fetchComposeSuggestionsEmojis(dispatch, getState, token); fetchComposeSuggestionsEmojis(dispatch, getState, token);
} else { break;
case '#':
fetchComposeSuggestionsTags(dispatch, getState, token);
break;
default:
fetchComposeSuggestionsAccounts(dispatch, getState, token); fetchComposeSuggestionsAccounts(dispatch, getState, token);
break;
} }
}; };
}; };
@ -308,6 +336,9 @@ export function selectComposeSuggestion(position, token, suggestion) {
startPosition = position - 1; startPosition = position - 1;
dispatch(useEmoji(suggestion)); dispatch(useEmoji(suggestion));
} else if (suggestion[0] === '#') {
completion = suggestion;
startPosition = position - 1;
} else { } else {
completion = getState().getIn(['accounts', suggestion, 'acct']); completion = getState().getIn(['accounts', suggestion, 'acct']);
startPosition = position; startPosition = position;
@ -322,6 +353,48 @@ export function selectComposeSuggestion(position, token, suggestion) {
}; };
}; };
export function updateSuggestionTags(token) {
return {
type: COMPOSE_SUGGESTION_TAGS_UPDATE,
token,
};
}
export function updateTagHistory(tags) {
return {
type: COMPOSE_TAG_HISTORY_UPDATE,
tags,
};
}
export function hydrateCompose() {
return (dispatch, getState) => {
const me = getState().getIn(['meta', 'me']);
const history = tagHistory.get(me);
if (history !== null) {
dispatch(updateTagHistory(history));
}
};
}
function insertIntoTagHistory(tags) {
return (dispatch, getState) => {
const state = getState();
const oldHistory = state.getIn(['compose', 'tagHistory']);
const me = state.getIn(['meta', 'me']);
const names = tags.map(({ name }) => name);
const intersectedOldHistory = oldHistory.filter(name => !names.includes(name));
names.push(...intersectedOldHistory.toJS());
const newHistory = names.slice(0, 1000);
tagHistory.set(me, newHistory);
dispatch(updateTagHistory(newHistory));
};
}
export function mountCompose() { export function mountCompose() {
return { return {
type: COMPOSE_MOUNT, type: COMPOSE_MOUNT,

View File

@ -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 };
}

View File

@ -62,6 +62,7 @@ export function reblogRequest(status) {
return { return {
type: REBLOG_REQUEST, type: REBLOG_REQUEST,
status: status, status: status,
skipLoading: true,
}; };
}; };
@ -70,6 +71,7 @@ export function reblogSuccess(status, response) {
type: REBLOG_SUCCESS, type: REBLOG_SUCCESS,
status: status, status: status,
response: response, response: response,
skipLoading: true,
}; };
}; };
@ -78,6 +80,7 @@ export function reblogFail(status, error) {
type: REBLOG_FAIL, type: REBLOG_FAIL,
status: status, status: status,
error: error, error: error,
skipLoading: true,
}; };
}; };
@ -85,6 +88,7 @@ export function unreblogRequest(status) {
return { return {
type: UNREBLOG_REQUEST, type: UNREBLOG_REQUEST,
status: status, status: status,
skipLoading: true,
}; };
}; };
@ -93,6 +97,7 @@ export function unreblogSuccess(status, response) {
type: UNREBLOG_SUCCESS, type: UNREBLOG_SUCCESS,
status: status, status: status,
response: response, response: response,
skipLoading: true,
}; };
}; };
@ -101,6 +106,7 @@ export function unreblogFail(status, error) {
type: UNREBLOG_FAIL, type: UNREBLOG_FAIL,
status: status, status: status,
error: error, error: error,
skipLoading: true,
}; };
}; };
@ -132,6 +138,7 @@ export function favouriteRequest(status) {
return { return {
type: FAVOURITE_REQUEST, type: FAVOURITE_REQUEST,
status: status, status: status,
skipLoading: true,
}; };
}; };
@ -140,6 +147,7 @@ export function favouriteSuccess(status, response) {
type: FAVOURITE_SUCCESS, type: FAVOURITE_SUCCESS,
status: status, status: status,
response: response, response: response,
skipLoading: true,
}; };
}; };
@ -148,6 +156,7 @@ export function favouriteFail(status, error) {
type: FAVOURITE_FAIL, type: FAVOURITE_FAIL,
status: status, status: status,
error: error, error: error,
skipLoading: true,
}; };
}; };
@ -155,6 +164,7 @@ export function unfavouriteRequest(status) {
return { return {
type: UNFAVOURITE_REQUEST, type: UNFAVOURITE_REQUEST,
status: status, status: status,
skipLoading: true,
}; };
}; };
@ -163,6 +173,7 @@ export function unfavouriteSuccess(status, response) {
type: UNFAVOURITE_SUCCESS, type: UNFAVOURITE_SUCCESS,
status: status, status: status,
response: response, response: response,
skipLoading: true,
}; };
}; };
@ -171,6 +182,7 @@ export function unfavouriteFail(status, error) {
type: UNFAVOURITE_FAIL, type: UNFAVOURITE_FAIL,
status: status, status: status,
error: error, error: error,
skipLoading: true,
}; };
}; };
@ -258,6 +270,7 @@ export function pinRequest(status) {
return { return {
type: PIN_REQUEST, type: PIN_REQUEST,
status, status,
skipLoading: true,
}; };
}; };
@ -266,6 +279,7 @@ export function pinSuccess(status, response) {
type: PIN_SUCCESS, type: PIN_SUCCESS,
status, status,
response, response,
skipLoading: true,
}; };
}; };
@ -274,6 +288,7 @@ export function pinFail(status, error) {
type: PIN_FAIL, type: PIN_FAIL,
status, status,
error, error,
skipLoading: true,
}; };
}; };
@ -293,6 +308,7 @@ export function unpinRequest(status) {
return { return {
type: UNPIN_REQUEST, type: UNPIN_REQUEST,
status, status,
skipLoading: true,
}; };
}; };
@ -301,6 +317,7 @@ export function unpinSuccess(status, response) {
type: UNPIN_SUCCESS, type: UNPIN_SUCCESS,
status, status,
response, response,
skipLoading: true,
}; };
}; };
@ -309,5 +326,6 @@ export function unpinFail(status, error) {
type: UNPIN_FAIL, type: UNPIN_FAIL,
status, status,
error, error,
skipLoading: true,
}; };
}; };

View File

@ -24,7 +24,7 @@ defineMessages({
const fetchRelatedRelationships = (dispatch, notifications) => { const fetchRelatedRelationships = (dispatch, notifications) => {
const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
if (accountIds > 0) { if (accountIds.length > 0) {
dispatch(fetchRelationships(accountIds)); dispatch(fetchRelationships(accountIds));
} }
}; };

View File

@ -10,6 +10,7 @@ export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL';
export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE';
export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE'; export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE';
export const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE';
export function initReport(account, status) { export function initReport(account, status) {
return dispatch => { return dispatch => {
@ -45,6 +46,7 @@ export function submitReport() {
account_id: getState().getIn(['reports', 'new', 'account_id']), account_id: getState().getIn(['reports', 'new', 'account_id']),
status_ids: getState().getIn(['reports', 'new', 'status_ids']), status_ids: getState().getIn(['reports', 'new', 'status_ids']),
comment: getState().getIn(['reports', 'new', 'comment']), comment: getState().getIn(['reports', 'new', 'comment']),
forward: getState().getIn(['reports', 'new', 'forward']),
}).then(response => { }).then(response => {
dispatch(closeModal()); dispatch(closeModal());
dispatch(submitReportSuccess(response.data)); dispatch(submitReportSuccess(response.data));
@ -78,3 +80,10 @@ export function changeReportComment(comment) {
comment, comment,
}; };
}; };
export function changeReportForward(forward) {
return {
type: REPORT_FORWARD_CHANGE,
forward,
};
};

View File

@ -1,4 +1,5 @@
import api from '../api'; import api from '../api';
import { fetchRelationships } from './accounts';
export const SEARCH_CHANGE = 'SEARCH_CHANGE'; export const SEARCH_CHANGE = 'SEARCH_CHANGE';
export const SEARCH_CLEAR = 'SEARCH_CLEAR'; export const SEARCH_CLEAR = 'SEARCH_CLEAR';
@ -38,6 +39,7 @@ export function submitSearch() {
}, },
}).then(response => { }).then(response => {
dispatch(fetchSearchSuccess(response.data)); dispatch(fetchSearchSuccess(response.data));
dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
}).catch(error => { }).catch(error => {
dispatch(fetchSearchFail(error)); dispatch(fetchSearchFail(error));
}); });

View File

@ -23,6 +23,9 @@ export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
export const STATUS_REVEAL = 'STATUS_REVEAL';
export const STATUS_HIDE = 'STATUS_HIDE';
export function fetchStatusRequest(id, skipLoading) { export function fetchStatusRequest(id, skipLoading) {
return { return {
type: STATUS_FETCH_REQUEST, type: STATUS_FETCH_REQUEST,
@ -215,3 +218,25 @@ export function unmuteStatusFail(id, error) {
error, error,
}; };
}; };
export function hideStatus(ids) {
if (!Array.isArray(ids)) {
ids = [ids];
}
return {
type: STATUS_HIDE,
ids,
};
};
export function revealStatus(ids) {
if (!Array.isArray(ids)) {
ids = [ids];
}
return {
type: STATUS_REVEAL,
ids,
};
};

View File

@ -1,4 +1,5 @@
import { Iterable, fromJS } from 'immutable'; import { Iterable, fromJS } from 'immutable';
import { hydrateCompose } from './compose';
export const STORE_HYDRATE = 'STORE_HYDRATE'; export const STORE_HYDRATE = 'STORE_HYDRATE';
export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
@ -8,10 +9,14 @@ const convertState = rawState =>
Iterable.isIndexed(v) ? v.toList() : v.toMap()); Iterable.isIndexed(v) ? v.toList() : v.toMap());
export function hydrateStore(rawState) { export function hydrateStore(rawState) {
const state = convertState(rawState); return dispatch => {
const state = convertState(rawState);
return { dispatch({
type: STORE_HYDRATE, type: STORE_HYDRATE,
state, state,
});
dispatch(hydrateCompose());
}; };
}; };

View File

@ -117,13 +117,14 @@ export function refreshTimeline(timelineId, path, params = {}) {
}; };
}; };
export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home'); export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home');
export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public'); export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public');
export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true }); 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 refreshAccountTimeline = (accountId, withReplies) => refreshTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies });
export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); export const refreshAccountFeaturedTimeline = accountId => refreshTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
export const refreshListTimeline = id => refreshTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`); 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) { export function refreshTimelineFail(timeline, error, skipLoading) {
return { return {
@ -161,7 +162,7 @@ export function expandTimeline(timelineId, path, params = {}) {
export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home'); export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home');
export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public'); export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public');
export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true }); 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 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 expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
export const expandListTimeline = id => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`); export const expandListTimeline = id => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`);

View File

@ -3,6 +3,7 @@ import 'intl/locale-data/jsonp/en';
import 'es6-symbol/implement'; import 'es6-symbol/implement';
import includes from 'array-includes'; import includes from 'array-includes';
import assign from 'object-assign'; import assign from 'object-assign';
import values from 'object.values';
import isNaN from 'is-nan'; import isNaN from 'is-nan';
if (!Array.prototype.includes) { if (!Array.prototype.includes) {
@ -13,6 +14,10 @@ if (!Object.assign) {
Object.assign = assign; Object.assign = assign;
} }
if (!Object.values) {
values.shim();
}
if (!Number.isNaN) { if (!Number.isNaN) {
Number.isNaN = isNaN; Number.isNaN = isNaN;
} }

View File

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
@ -8,10 +9,29 @@ export default class AttachmentList extends ImmutablePureComponent {
static propTypes = { static propTypes = {
media: ImmutablePropTypes.list.isRequired, media: ImmutablePropTypes.list.isRequired,
compact: PropTypes.bool,
}; };
render () { render () {
const { media } = this.props; const { media, compact } = this.props;
if (compact) {
return (
<div className='attachment-list compact'>
<ul className='attachment-list__list'>
{media.map(attachment => {
const displayUrl = attachment.get('remote_url') || attachment.get('url');
return (
<li key={attachment.get('id')}>
<a href={displayUrl} target='_blank' rel='noopener'><i className='fa fa-link' /> {filename(displayUrl)}</a>
</li>
);
})}
</ul>
</div>
);
}
return ( return (
<div className='attachment-list'> <div className='attachment-list'>
@ -20,11 +40,15 @@ export default class AttachmentList extends ImmutablePureComponent {
</div> </div>
<ul className='attachment-list__list'> <ul className='attachment-list__list'>
{media.map(attachment => ( {media.map(attachment => {
<li key={attachment.get('id')}> const displayUrl = attachment.get('remote_url') || attachment.get('url');
<a href={attachment.get('remote_url')} target='_blank' rel='noopener'>{filename(attachment.get('remote_url'))}</a>
</li> return (
))} <li key={attachment.get('id')}>
<a href={displayUrl} target='_blank' rel='noopener'>{filename(displayUrl)}</a>
</li>
);
})}
</ul> </ul>
</div> </div>
); );

View File

@ -20,7 +20,7 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
word = str.slice(left, right + 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]; return [null, null];
} }
@ -170,6 +170,9 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
if (typeof suggestion === 'object') { if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />; inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id; key = suggestion.id;
} else if (suggestion[0] === '#') {
inner = suggestion;
key = suggestion;
} else { } else {
inner = <AutosuggestAccountContainer id={suggestion} />; inner = <AutosuggestAccountContainer id={suggestion} />;
key = suggestion; key = suggestion;

View File

@ -1,17 +1,8 @@
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types'; import ColumnBackButton from './column_back_button';
export default class ColumnBackButtonSlim extends React.PureComponent { export default class ColumnBackButtonSlim extends ColumnBackButton {
static contextTypes = {
router: PropTypes.object,
};
handleClick = () => {
if (window.history && window.history.length === 1) this.context.router.history.push('/');
else this.context.router.history.goBack();
}
render () { render () {
return ( return (

View File

@ -19,10 +19,11 @@ export default class ColumnHeader extends React.PureComponent {
static propTypes = { static propTypes = {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
title: PropTypes.node.isRequired, title: PropTypes.node,
icon: PropTypes.string.isRequired, icon: PropTypes.string,
active: PropTypes.bool, active: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
extraButton: PropTypes.node,
showBackButton: PropTypes.bool, showBackButton: PropTypes.bool,
children: PropTypes.node, children: PropTypes.node,
pinned: PropTypes.bool, pinned: PropTypes.bool,
@ -63,7 +64,7 @@ export default class ColumnHeader extends React.PureComponent {
} }
render () { render () {
const { title, icon, active, children, pinned, onPin, multiColumn, showBackButton, intl: { formatMessage } } = this.props; const { title, icon, active, children, pinned, onPin, multiColumn, extraButton, showBackButton, intl: { formatMessage } } = this.props;
const { collapsed, animating } = this.state; const { collapsed, animating } = this.state;
const wrapperClassName = classNames('column-header__wrapper', { const wrapperClassName = classNames('column-header__wrapper', {
@ -125,21 +126,26 @@ export default class ColumnHeader extends React.PureComponent {
} }
if (children || multiColumn) { if (children || multiColumn) {
collapseButton = <button className={collapsibleButtonClassName} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>; collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>;
} }
const hasTitle = icon && title;
return ( return (
<div className={wrapperClassName}> <div className={wrapperClassName}>
<h1 className={buttonClassName}> <h1 className={buttonClassName}>
<button onClick={this.handleTitleClick}> {hasTitle && (
<i className={`fa fa-fw fa-${icon} column-header__icon`} /> <button onClick={this.handleTitleClick}>
<span className='column-header__title'> <i className={`fa fa-fw fa-${icon} column-header__icon`} />
{title} {title}
</span> </button>
</button> )}
{!hasTitle && backButton}
<div className='column-header__buttons'> <div className='column-header__buttons'>
{backButton} {hasTitle && backButton}
{extraButton}
{collapseButton} {collapseButton}
</div> </div>
</h1> </h1>

View File

@ -8,6 +8,7 @@ import spring from 'react-motion/lib/spring';
import detectPassiveEvents from 'detect-passive-events'; import detectPassiveEvents from 'detect-passive-events';
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
let id = 0;
class DropdownMenu extends React.PureComponent { class DropdownMenu extends React.PureComponent {
@ -29,6 +30,10 @@ class DropdownMenu extends React.PureComponent {
placement: 'bottom', placement: 'bottom',
}; };
state = {
mounted: false,
};
handleDocumentClick = e => { handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) { if (this.node && !this.node.contains(e.target)) {
this.props.onClose(); this.props.onClose();
@ -38,6 +43,7 @@ class DropdownMenu extends React.PureComponent {
componentDidMount () { componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
this.setState({ mounted: true });
} }
componentWillUnmount () { componentWillUnmount () {
@ -82,11 +88,15 @@ class DropdownMenu extends React.PureComponent {
render () { render () {
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props; const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
const { mounted } = this.state;
return ( return (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => ( {({ opacity, scaleX, scaleY }) => (
<div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}> // It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} /> <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
<ul> <ul>
@ -115,8 +125,10 @@ export default class Dropdown extends React.PureComponent {
status: ImmutablePropTypes.map, status: ImmutablePropTypes.map,
isUserTouching: PropTypes.func, isUserTouching: PropTypes.func,
isModalOpen: PropTypes.bool.isRequired, isModalOpen: PropTypes.bool.isRequired,
onModalOpen: PropTypes.func, onOpen: PropTypes.func.isRequired,
onModalClose: PropTypes.func, onClose: PropTypes.func.isRequired,
dropdownPlacement: PropTypes.string,
openDropdownId: PropTypes.number,
}; };
static defaultProps = { static defaultProps = {
@ -124,37 +136,28 @@ export default class Dropdown extends React.PureComponent {
}; };
state = { state = {
expanded: false, id: id++,
}; };
handleClick = () => { handleClick = ({ target }) => {
if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) { if (this.state.id === this.props.openDropdownId) {
const { status, items } = this.props; this.handleClose();
} else {
const { top } = target.getBoundingClientRect();
const placement = top * 2 < innerHeight ? 'bottom' : 'top';
this.props.onModalOpen({ this.props.onOpen(this.state.id, this.handleItemClick, placement);
status,
actions: items,
onClick: this.handleItemClick,
});
return;
} }
this.setState({ expanded: !this.state.expanded });
} }
handleClose = () => { handleClose = () => {
if (this.props.onModalClose) { this.props.onClose(this.state.id);
this.props.onModalClose();
}
this.setState({ expanded: false });
} }
handleKeyDown = e => { handleKeyDown = e => {
switch(e.key) { switch(e.key) {
case 'Enter': case 'Enter':
this.handleClick(); this.handleClick(e);
break; break;
case 'Escape': case 'Escape':
this.handleClose(); this.handleClose();
@ -186,22 +189,22 @@ export default class Dropdown extends React.PureComponent {
} }
render () { render () {
const { icon, items, size, title, disabled } = this.props; const { icon, items, size, title, disabled, dropdownPlacement, openDropdownId } = this.props;
const { expanded } = this.state; const open = this.state.id === openDropdownId;
return ( return (
<div onKeyDown={this.handleKeyDown}> <div onKeyDown={this.handleKeyDown}>
<IconButton <IconButton
icon={icon} icon={icon}
title={title} title={title}
active={expanded} active={open}
disabled={disabled} disabled={disabled}
size={size} size={size}
ref={this.setTargetRef} ref={this.setTargetRef}
onClick={this.handleClick} onClick={this.handleClick}
/> />
<Overlay show={expanded} placement='bottom' target={this.findTarget}> <Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
<DropdownMenu items={items} onClose={this.handleClose} /> <DropdownMenu items={items} onClose={this.handleClose} />
</Overlay> </Overlay>
</div> </div>

View File

@ -11,6 +11,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
time: PropTypes.number, time: PropTypes.number,
controls: PropTypes.bool.isRequired, controls: PropTypes.bool.isRequired,
muted: PropTypes.bool.isRequired, muted: PropTypes.bool.isRequired,
onClick: PropTypes.func,
}; };
handleLoadedData = () => { handleLoadedData = () => {
@ -31,6 +32,12 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
this.video = c; this.video = c;
} }
handleClick = e => {
e.stopPropagation();
const handler = this.props.onClick;
if (handler) handler();
}
render () { render () {
const { src, muted, controls, alt } = this.props; const { src, muted, controls, alt } = this.props;
@ -46,6 +53,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
muted={muted} muted={muted}
controls={controls} controls={controls}
loop={!controls} loop={!controls}
onClick={this.handleClick}
/> />
</div> </div>
); );

View File

@ -6,7 +6,7 @@ import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../is_mobile'; import { isIOS } from '../is_mobile';
import classNames from 'classnames'; import classNames from 'classnames';
import { autoPlayGif } from '../initial_state'; import { autoPlayGif, displaySensitiveMedia } from '../initial_state';
const messages = defineMessages({ const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
@ -116,16 +116,21 @@ class Item extends React.PureComponent {
let thumbnail = ''; let thumbnail = '';
if (attachment.get('type') === 'image') { 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 previewWidth = attachment.getIn(['meta', 'small', 'width']);
const originalUrl = attachment.get('url'); const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']); const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null; const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null; const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
thumbnail = ( thumbnail = (
<a <a
@ -134,7 +139,14 @@ class Item extends React.PureComponent {
onClick={this.handleClick} onClick={this.handleClick}
target='_blank' target='_blank'
> >
<img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} /> <img
src={previewUrl}
srcSet={srcSet}
sizes={sizes}
alt={attachment.get('description')}
title={attachment.get('description')}
style={{ objectPosition: `${x}% ${y}%` }}
/>
</a> </a>
); );
} else if (attachment.get('type') === 'gifv') { } else if (attachment.get('type') === 'gifv') {
@ -187,7 +199,7 @@ export default class MediaGallery extends React.PureComponent {
}; };
state = { state = {
visible: !this.props.sensitive, visible: !this.props.sensitive || displaySensitiveMedia,
}; };
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
@ -205,7 +217,7 @@ export default class MediaGallery extends React.PureComponent {
} }
handleRef = (node) => { handleRef = (node) => {
if (node && this.isStandaloneEligible()) { if (node /*&& this.isStandaloneEligible()*/) {
// offsetWidth triggers a layout, so only calculate when we need to // offsetWidth triggers a layout, so only calculate when we need to
this.setState({ this.setState({
width: node.offsetWidth, width: node.offsetWidth,
@ -227,15 +239,12 @@ export default class MediaGallery extends React.PureComponent {
const style = {}; const style = {};
if (this.isStandaloneEligible()) { if (this.isStandaloneEligible()) {
if (!visible && width) { if (width) {
// only need to forcibly set the height in "sensitive" mode
style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']); 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 { } else {
// crop the image
style.height = height; style.height = height;
} }
@ -249,7 +258,7 @@ export default class MediaGallery extends React.PureComponent {
} }
children = ( children = (
<button className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}> <button type='button' className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
<span className='media-spoiler__warning'>{warning}</span> <span className='media-spoiler__warning'>{warning}</span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</button> </button>
@ -265,7 +274,7 @@ export default class MediaGallery extends React.PureComponent {
} }
return ( return (
<div className='media-gallery' style={style}> <div className='media-gallery' style={style} ref={this.handleRef}>
<div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}> <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
</div> </div>

View File

@ -12,9 +12,15 @@ export default class Permalink extends React.PureComponent {
href: PropTypes.string.isRequired, href: PropTypes.string.isRequired,
to: PropTypes.string.isRequired, to: PropTypes.string.isRequired,
children: PropTypes.node, 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)) { if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.preventDefault();
this.context.router.history.push(this.props.to); this.context.router.history.push(this.props.to);
@ -22,7 +28,7 @@ export default class Permalink extends React.PureComponent {
} }
render () { render () {
const { href, children, className, ...other } = this.props; const { href, children, className, onInterceptClick, ...other } = this.props;
return ( return (
<a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}> <a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}>

View File

@ -17,7 +17,7 @@ export default class ScrollableList extends PureComponent {
static propTypes = { static propTypes = {
scrollKey: PropTypes.string.isRequired, scrollKey: PropTypes.string.isRequired,
onScrollToBottom: PropTypes.func, onLoadMore: PropTypes.func.isRequired,
onScrollToTop: PropTypes.func, onScrollToTop: PropTypes.func,
onScroll: PropTypes.func, onScroll: PropTypes.func,
trackScroll: PropTypes.bool, trackScroll: PropTypes.bool,
@ -45,9 +45,11 @@ export default class ScrollableList extends PureComponent {
const offset = scrollHeight - scrollTop - clientHeight; const offset = scrollHeight - scrollTop - clientHeight;
this._oldScrollPosition = scrollHeight - scrollTop; this._oldScrollPosition = scrollHeight - scrollTop;
if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { if (400 > offset && this.props.onLoadMore && !this.props.isLoading) {
this.props.onScrollToBottom(); this.props.onLoadMore();
} else if (scrollTop < 100 && this.props.onScrollToTop) { }
if (scrollTop < 100 && this.props.onScrollToTop) {
this.props.onScrollToTop(); this.props.onScrollToTop();
} else if (this.props.onScroll) { } else if (this.props.onScroll) {
this.props.onScroll(); this.props.onScroll();
@ -138,7 +140,7 @@ export default class ScrollableList extends PureComponent {
handleLoadMore = (e) => { handleLoadMore = (e) => {
e.preventDefault(); e.preventDefault();
this.props.onScrollToBottom(); this.props.onLoadMore();
} }
_recentlyMoved () { _recentlyMoved () {

View File

@ -7,6 +7,7 @@ import RelativeTimestamp from './relative_timestamp';
import DisplayName from './display_name'; import DisplayName from './display_name';
import StatusContent from './status_content'; import StatusContent from './status_content';
import StatusActionBar from './status_action_bar'; import StatusActionBar from './status_action_bar';
import AttachmentList from './attachment_list';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video } from '../features/ui/util/async-components'; import { MediaGallery, Video } from '../features/ui/util/async-components';
@ -36,16 +37,13 @@ export default class Status extends ImmutablePureComponent {
onBlock: PropTypes.func, onBlock: PropTypes.func,
onEmbed: PropTypes.func, onEmbed: PropTypes.func,
onHeightChange: PropTypes.func, onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,
muted: PropTypes.bool, muted: PropTypes.bool,
hidden: PropTypes.bool, hidden: PropTypes.bool,
onMoveUp: PropTypes.func, onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func, onMoveDown: PropTypes.func,
}; };
state = {
isExpanded: false,
}
// Avoid checking props that are functions (and whose equality will always // Avoid checking props that are functions (and whose equality will always
// evaluate to false. See react-immutable-pure-component for usage. // evaluate to false. See react-immutable-pure-component for usage.
updateOnProps = [ updateOnProps = [
@ -55,8 +53,6 @@ export default class Status extends ImmutablePureComponent {
'hidden', 'hidden',
] ]
updateOnStates = ['isExpanded']
handleClick = () => { handleClick = () => {
if (!this.context.router) { if (!this.context.router) {
return; return;
@ -75,7 +71,7 @@ export default class Status extends ImmutablePureComponent {
} }
handleExpandedToggle = () => { handleExpandedToggle = () => {
this.setState({ isExpanded: !this.state.isExpanded }); this.props.onToggleHidden(this._properStatus());
}; };
renderLoadingMediaGallery () { renderLoadingMediaGallery () {
@ -138,8 +134,7 @@ export default class Status extends ImmutablePureComponent {
let media = null; let media = null;
let statusAvatar, prepend; let statusAvatar, prepend;
const { hidden } = this.props; const { hidden, featured } = this.props;
const { isExpanded } = this.state;
let { status, account, ...other } = this.props; let { status, account, ...other } = this.props;
@ -156,7 +151,14 @@ export default class Status extends ImmutablePureComponent {
); );
} }
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { if (featured) {
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-thumb-tack status__prepend-icon' /></div>
<FormattedMessage id='status.pinned' defaultMessage='Pinned toot' />
</div>
);
} else if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
prepend = ( prepend = (
@ -170,9 +172,14 @@ export default class Status extends ImmutablePureComponent {
status = status.get('reblog'); status = status.get('reblog');
} }
if (status.get('media_attachments').size > 0 && !this.props.muted) { if (status.get('media_attachments').size > 0) {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
media = (
<AttachmentList
compact
media={status.get('media_attachments')}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const video = status.getIn(['media_attachments', 0]); const video = status.getIn(['media_attachments', 0]);
@ -184,6 +191,7 @@ export default class Status extends ImmutablePureComponent {
src={video.get('url')} src={video.get('url')}
width={239} width={239}
height={110} height={110}
inline
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
onOpenVideo={this.handleOpenVideo} onOpenVideo={this.handleOpenVideo}
/> />
@ -234,7 +242,7 @@ export default class Status extends ImmutablePureComponent {
</a> </a>
</div> </div>
<StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} /> <StatusContent status={status} onClick={this.handleClick} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
{media} {media}

View File

@ -24,7 +24,12 @@ export default class StatusContent extends React.PureComponent {
}; };
_updateStatusLinks () { _updateStatusLinks () {
const node = this.node; const node = this.node;
if (!node) {
return;
}
const links = node.querySelectorAll('a'); const links = node.querySelectorAll('a');
for (var i = 0; i < links.length; ++i) { for (var i = 0; i < links.length; ++i) {
@ -115,6 +120,10 @@ export default class StatusContent extends React.PureComponent {
render () { render () {
const { status } = this.props; const { status } = this.props;
if (status.get('content').length === 0) {
return null;
}
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const content = { __html: status.get('contentHtml') }; const content = { __html: status.get('contentHtml') };
@ -145,7 +154,7 @@ export default class StatusContent extends React.PureComponent {
} }
return ( return (
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
<span dangerouslySetInnerHTML={spoilerContent} /> <span dangerouslySetInnerHTML={spoilerContent} />
{' '} {' '}

View File

@ -11,7 +11,8 @@ export default class StatusList extends ImmutablePureComponent {
static propTypes = { static propTypes = {
scrollKey: PropTypes.string.isRequired, scrollKey: PropTypes.string.isRequired,
statusIds: ImmutablePropTypes.list.isRequired, statusIds: ImmutablePropTypes.list.isRequired,
onScrollToBottom: PropTypes.func, featuredStatusIds: ImmutablePropTypes.list,
onLoadMore: PropTypes.func,
onScrollToTop: PropTypes.func, onScrollToTop: PropTypes.func,
onScroll: PropTypes.func, onScroll: PropTypes.func,
trackScroll: PropTypes.bool, trackScroll: PropTypes.bool,
@ -50,7 +51,7 @@ export default class StatusList extends ImmutablePureComponent {
} }
render () { render () {
const { statusIds, ...other } = this.props; const { statusIds, featuredStatusIds, ...other } = this.props;
const { isLoading, isPartial } = other; const { isLoading, isPartial } = other;
if (isPartial) { if (isPartial) {
@ -68,8 +69,8 @@ export default class StatusList extends ImmutablePureComponent {
); );
} }
const scrollableContent = (isLoading || statusIds.size > 0) ? ( let scrollableContent = (isLoading || statusIds.size > 0) ? (
statusIds.map((statusId) => ( statusIds.map(statusId => (
<StatusContainer <StatusContainer
key={statusId} key={statusId}
id={statusId} id={statusId}
@ -79,6 +80,18 @@ export default class StatusList extends ImmutablePureComponent {
)) ))
) : null; ) : null;
if (scrollableContent && featuredStatusIds) {
scrollableContent = featuredStatusIds.map(statusId => (
<StatusContainer
key={`f-${statusId}`}
id={statusId}
featured
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
/>
)).concat(scrollableContent);
}
return ( return (
<ScrollableList {...other} ref={this.setRef}> <ScrollableList {...other} ref={this.setRef}>
{scrollableContent} {scrollableContent}

View File

@ -1,3 +1,4 @@
import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu';
import { openModal, closeModal } from '../actions/modal'; import { openModal, closeModal } from '../actions/modal';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import DropdownMenu from '../components/dropdown_menu'; import DropdownMenu from '../components/dropdown_menu';
@ -5,12 +6,22 @@ import { isUserTouching } from '../is_mobile';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
isModalOpen: state.get('modal').modalType === 'ACTIONS', isModalOpen: state.get('modal').modalType === 'ACTIONS',
dropdownPlacement: state.getIn(['dropdown_menu', 'placement']),
openDropdownId: state.getIn(['dropdown_menu', 'openId']),
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = (dispatch, { status, items }) => ({
isUserTouching, onOpen(id, onItemClick, dropdownPlacement) {
onModalOpen: props => dispatch(openModal('ACTIONS', props)), dispatch(isUserTouching() ? openModal('ACTIONS', {
onModalClose: () => dispatch(closeModal()), status,
actions: items,
onClick: onItemClick,
}) : openDropdownMenu(id, dropdownPlacement));
},
onClose(id) {
dispatch(closeModal());
dispatch(closeDropdownMenu(id));
},
}); });
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu); export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);

View File

@ -15,7 +15,13 @@ import {
unpin, unpin,
} from '../actions/interactions'; } from '../actions/interactions';
import { blockAccount } from '../actions/accounts'; import { blockAccount } from '../actions/accounts';
import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses'; import {
muteStatus,
unmuteStatus,
deleteStatus,
hideStatus,
revealStatus,
} from '../actions/statuses';
import { initMuteModal } from '../actions/mutes'; import { initMuteModal } from '../actions/mutes';
import { initReport } from '../actions/reports'; import { initReport } from '../actions/reports';
import { openModal } from '../actions/modal'; import { openModal } from '../actions/modal';
@ -128,6 +134,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} }
}, },
onToggleHidden (status) {
if (status.get('hidden')) {
dispatch(revealStatus(status.get('id')));
} else {
dispatch(hideStatus(status.get('id')));
}
},
}); });
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));

View File

@ -6,6 +6,7 @@ import { hydrateStore } from '../actions/store';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales'; import { getLocale } from '../locales';
import PublicTimeline from '../features/standalone/public_timeline'; import PublicTimeline from '../features/standalone/public_timeline';
import CommunityTimeline from '../features/standalone/community_timeline';
import HashtagTimeline from '../features/standalone/hashtag_timeline'; import HashtagTimeline from '../features/standalone/hashtag_timeline';
import initialState from '../initial_state'; import initialState from '../initial_state';
@ -23,17 +24,24 @@ export default class TimelineContainer extends React.PureComponent {
static propTypes = { static propTypes = {
locale: PropTypes.string.isRequired, locale: PropTypes.string.isRequired,
hashtag: PropTypes.string, hashtag: PropTypes.string,
showPublicTimeline: PropTypes.bool.isRequired,
};
static defaultProps = {
showPublicTimeline: initialState.settings.known_fediverse,
}; };
render () { render () {
const { locale, hashtag } = this.props; const { locale, hashtag, showPublicTimeline } = this.props;
let timeline; let timeline;
if (hashtag) { if (hashtag) {
timeline = <HashtagTimeline hashtag={hashtag} />; timeline = <HashtagTimeline hashtag={hashtag} />;
} else { } else if (showPublicTimeline) {
timeline = <PublicTimeline />; timeline = <PublicTimeline />;
} else {
timeline = <CommunityTimeline />;
} }
return ( return (

View File

@ -53,11 +53,11 @@ export default class ActionBar extends React.PureComponent {
let extraInfo = ''; let extraInfo = '';
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention }); menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
if ('share' in navigator) { if ('share' in navigator) {
menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare }); 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); menu.push(null);
if (account.get('id') === me) { if (account.get('id') === me) {
@ -122,7 +122,7 @@ export default class ActionBar extends React.PureComponent {
<div className='account__action-bar-links'> <div className='account__action-bar-links'>
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}> <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
<span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span> <span><FormattedMessage id='account.posts' defaultMessage='Toots' /></span>
<strong><FormattedNumber value={account.get('statuses_count')} /></strong> <strong><FormattedNumber value={account.get('statuses_count')} /></strong>
</Link> </Link>

View File

@ -13,6 +13,7 @@ const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
}); });
class Avatar extends ImmutablePureComponent { class Avatar extends ImmutablePureComponent {
@ -69,6 +70,7 @@ export default class Header extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map, account: ImmutablePropTypes.map,
onFollow: PropTypes.func.isRequired, onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -80,11 +82,20 @@ export default class Header extends ImmutablePureComponent {
} }
let info = ''; let info = '';
let mutingInfo = '';
let actionBtn = ''; let actionBtn = '';
let lockedIcon = ''; let lockedIcon = '';
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>; info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>;
} else if (me !== account.get('id') && account.getIn(['relationship', 'blocking'])) {
info = <span className='account--follows-info'><FormattedMessage id='account.blocked' defaultMessage='Blocked' /></span>;
}
if (me !== account.get('id') && account.getIn(['relationship', 'muting'])) {
mutingInfo = <span className='account--muting-info'><FormattedMessage id='account.muted' defaultMessage='Muted' /></span>;
} else if (me !== account.get('id') && account.getIn(['relationship', 'domain_blocking'])) {
mutingInfo = <span className='account--muting-info'><FormattedMessage id='account.domain_blocked' defaultMessage='Domain hidden' /></span>;
} }
if (me !== account.get('id')) { if (me !== account.get('id')) {
@ -100,6 +111,12 @@ export default class Header extends ImmutablePureComponent {
<IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} /> <IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />
</div> </div>
); );
} else if (account.getIn(['relationship', 'blocking'])) {
actionBtn = (
<div className='account--action-button'>
<IconButton size={26} icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />
</div>
);
} }
} }
@ -124,6 +141,7 @@ export default class Header extends ImmutablePureComponent {
<div className='account__header__content' dangerouslySetInnerHTML={content} /> <div className='account__header__content' dangerouslySetInnerHTML={content} />
{info} {info}
{mutingInfo}
{actionBtn} {actionBtn}
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Permalink from '../../../components/permalink'; import Permalink from '../../../components/permalink';
import { displaySensitiveMedia } from '../../../initial_state';
export default class MediaItem extends ImmutablePureComponent { export default class MediaItem extends ImmutablePureComponent {
@ -9,28 +10,51 @@ export default class MediaItem extends ImmutablePureComponent {
media: ImmutablePropTypes.map.isRequired, media: ImmutablePropTypes.map.isRequired,
}; };
render () { state = {
const { media } = this.props; visible: !this.props.media.getIn(['status', 'sensitive']) || displaySensitiveMedia,
const status = media.get('status'); };
let content, style; handleClick = () => {
if (!this.state.visible) {
if (media.get('type') === 'gifv') { this.setState({ visible: true });
content = <span className='media-gallery__gifv__label'>GIF</span>; return true;
} }
if (!status.get('sensitive')) { return false;
style = { backgroundImage: `url(${media.get('preview_url')})` }; }
render () {
const { media } = this.props;
const { visible } = this.state;
const status = media.get('status');
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
const style = {};
let label, icon;
if (media.get('type') === 'gifv') {
label = <span className='media-gallery__gifv__label'>GIF</span>;
}
if (visible) {
style.backgroundImage = `url(${media.get('preview_url')})`;
style.backgroundPosition = `${x}% ${y}%`;
} else {
icon = (
<span className='account-gallery__item__icons'>
<i className='fa fa-eye-slash' />
</span>
);
} }
return ( return (
<div className='account-gallery__item'> <div className='account-gallery__item'>
<Permalink <Permalink to={`/statuses/${status.get('id')}`} href={status.get('url')} style={style} onInterceptClick={this.handleClick}>
to={`/statuses/${status.get('id')}`} {icon}
href={status.get('url')} {label}
style={style}
>
{content}
</Permalink> </Permalink>
</div> </div>
); );

View File

@ -11,7 +11,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { getAccountGallery } from '../../selectors'; import { getAccountGallery } from '../../selectors';
import MediaItem from './components/media_item'; import MediaItem from './components/media_item';
import HeaderContainer from '../account_timeline/containers/header_container'; import HeaderContainer from '../account_timeline/containers/header_container';
import { FormattedMessage } from 'react-intl';
import { ScrollContainer } from 'react-router-scroll-4'; import { ScrollContainer } from 'react-router-scroll-4';
import LoadMore from '../../components/load_more'; import LoadMore from '../../components/load_more';
@ -89,10 +88,6 @@ export default class AccountGallery extends ImmutablePureComponent {
<div className='scrollable' onScroll={this.handleScroll}> <div className='scrollable' onScroll={this.handleScroll}>
<HeaderContainer accountId={this.props.params.accountId} /> <HeaderContainer accountId={this.props.params.accountId} />
<div className='account-section-headline'>
<FormattedMessage id='account.media' defaultMessage='Media' />
</div>
<div className='account-gallery__container'> <div className='account-gallery__container'>
{medias.map(media => ( {medias.map(media => (
<MediaItem <MediaItem

View File

@ -6,6 +6,8 @@ import ActionBar from '../../account/components/action_bar';
import MissingIndicator from '../../../components/missing_indicator'; import MissingIndicator from '../../../components/missing_indicator';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import MovedNote from './moved_note'; import MovedNote from './moved_note';
import { FormattedMessage } from 'react-intl';
import { NavLink } from 'react-router-dom';
export default class Header extends ImmutablePureComponent { export default class Header extends ImmutablePureComponent {
@ -19,6 +21,7 @@ export default class Header extends ImmutablePureComponent {
onMute: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired,
onBlockDomain: PropTypes.func.isRequired, onBlockDomain: PropTypes.func.isRequired,
onUnblockDomain: PropTypes.func.isRequired, onUnblockDomain: PropTypes.func.isRequired,
hideTabs: PropTypes.bool,
}; };
static contextTypes = { static contextTypes = {
@ -66,7 +69,7 @@ export default class Header extends ImmutablePureComponent {
} }
render () { render () {
const { account } = this.props; const { account, hideTabs } = this.props;
if (account === null) { if (account === null) {
return <MissingIndicator />; return <MissingIndicator />;
@ -79,6 +82,7 @@ export default class Header extends ImmutablePureComponent {
<InnerHeader <InnerHeader
account={account} account={account}
onFollow={this.handleFollow} onFollow={this.handleFollow}
onBlock={this.handleBlock}
/> />
<ActionBar <ActionBar
@ -91,6 +95,14 @@ export default class Header extends ImmutablePureComponent {
onBlockDomain={this.handleBlockDomain} onBlockDomain={this.handleBlockDomain}
onUnblockDomain={this.handleUnblockDomain} onUnblockDomain={this.handleUnblockDomain}
/> />
{!hideTabs && (
<div className='account__section-headline'>
<NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink>
<NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink>
<NavLink exact to={`/accounts/${account.get('id')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
</div>
)}
</div> </div>
); );
} }

View File

@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { fetchAccount } from '../../actions/accounts'; 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 StatusList from '../../components/status_list';
import LoadingIndicator from '../../components/loading_indicator'; import LoadingIndicator from '../../components/loading_indicator';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
@ -12,11 +12,16 @@ import ColumnBackButton from '../../components/column_back_button';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => {
statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()), const path = withReplies ? `${accountId}:with_replies` : accountId;
isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']),
hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']), return {
}); statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()),
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()),
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
hasMore: !!state.getIn(['timelines', `account:${path}`, 'next']),
};
};
@connect(mapStateToProps) @connect(mapStateToProps)
export default class AccountTimeline extends ImmutablePureComponent { export default class AccountTimeline extends ImmutablePureComponent {
@ -25,30 +30,40 @@ export default class AccountTimeline extends ImmutablePureComponent {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list, statusIds: ImmutablePropTypes.list,
featuredStatusIds: ImmutablePropTypes.list,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
withReplies: PropTypes.bool,
}; };
componentWillMount () { componentWillMount () {
this.props.dispatch(fetchAccount(this.props.params.accountId)); const { params: { accountId }, withReplies } = this.props;
this.props.dispatch(refreshAccountTimeline(this.props.params.accountId));
this.props.dispatch(fetchAccount(accountId));
if (!withReplies) {
this.props.dispatch(refreshAccountFeaturedTimeline(accountId));
}
this.props.dispatch(refreshAccountTimeline(accountId, withReplies));
} }
componentWillReceiveProps (nextProps) { 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(fetchAccount(nextProps.params.accountId));
this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId)); if (!nextProps.withReplies) {
this.props.dispatch(refreshAccountFeaturedTimeline(nextProps.params.accountId));
}
this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId, nextProps.params.withReplies));
} }
} }
handleScrollToBottom = () => { handleLoadMore = () => {
if (!this.props.isLoading && this.props.hasMore) { 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 () { render () {
const { statusIds, isLoading, hasMore } = this.props; const { statusIds, featuredStatusIds, isLoading, hasMore } = this.props;
if (!statusIds && isLoading) { if (!statusIds && isLoading) {
return ( return (
@ -66,9 +81,10 @@ export default class AccountTimeline extends ImmutablePureComponent {
prepend={<HeaderContainer accountId={this.props.params.accountId} />} prepend={<HeaderContainer accountId={this.props.params.accountId} />}
scrollKey='account_timeline' scrollKey='account_timeline'
statusIds={statusIds} statusIds={statusIds}
featuredStatusIds={featuredStatusIds}
isLoading={isLoading} isLoading={isLoading}
hasMore={hasMore} hasMore={hasMore}
onScrollToBottom={this.handleScrollToBottom} onLoadMore={this.handleLoadMore}
/> />
</Column> </Column>
); );

View File

@ -50,6 +50,7 @@ export default class ComposeForm extends ImmutablePureComponent {
onPaste: PropTypes.func.isRequired, onPaste: PropTypes.func.isRequired,
onPickEmoji: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired,
showSearch: PropTypes.bool, showSearch: PropTypes.bool,
anyMedia: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -142,10 +143,10 @@ export default class ComposeForm extends ImmutablePureComponent {
} }
render () { render () {
const { intl, onPaste, showSearch } = this.props; const { intl, onPaste, showSearch, anyMedia } = this.props;
const disabled = this.props.is_submitting; const disabled = this.props.is_submitting;
const text = [this.props.spoiler_text, countableText(this.props.text)].join(''); 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 = ''; let publishText = '';
if (this.props.privacy === 'private' || this.props.privacy === 'direct') { if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
@ -203,7 +204,7 @@ export default class ComposeForm extends ImmutablePureComponent {
</div> </div>
<div className='compose-form__publish'> <div className='compose-form__publish'>
<div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0)} block /></div> <div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabledButton} block /></div>
</div> </div>
</div> </div>
); );

View File

@ -4,6 +4,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/lib/Overlay';
import Motion from '../../ui/util/optional_motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import { searchEnabled } from '../../../initial_state';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
@ -17,7 +18,7 @@ class SearchPopout extends React.PureComponent {
render () { render () {
const { style } = this.props; const { style } = this.props;
const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />;
return ( return (
<div style={{ ...style, position: 'absolute', width: 285 }}> <div style={{ ...style, position: 'absolute', width: 285 }}>
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
@ -32,7 +33,7 @@ class SearchPopout extends React.PureComponent {
<li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li> <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
</ul> </ul>
<FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' /> {extraInformation}
</div> </div>
)} )}
</Motion> </Motion>

View File

@ -22,6 +22,8 @@ export default class SearchResults extends ImmutablePureComponent {
count += results.get('accounts').size; count += results.get('accounts').size;
accounts = ( accounts = (
<div className='search-results__section'> <div className='search-results__section'>
<h5><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)} {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
</div> </div>
); );
@ -31,6 +33,8 @@ export default class SearchResults extends ImmutablePureComponent {
count += results.get('statuses').size; count += results.get('statuses').size;
statuses = ( statuses = (
<div className='search-results__section'> <div className='search-results__section'>
<h5><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)} {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
</div> </div>
); );
@ -40,6 +44,8 @@ export default class SearchResults extends ImmutablePureComponent {
count += results.get('hashtags').size; count += results.get('hashtags').size;
hashtags = ( hashtags = (
<div className='search-results__section'> <div className='search-results__section'>
<h5><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
{results.get('hashtags').map(hashtag => ( {results.get('hashtags').map(hashtag => (
<Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}> <Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
#{hashtag} #{hashtag}

View File

@ -1,15 +1,13 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import IconButton from '../../../components/icon_button';
import Motion from '../../ui/util/optional_motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
const messages = defineMessages({ const messages = defineMessages({
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, 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, intl: PropTypes.object.isRequired,
onUndo: PropTypes.func.isRequired, onUndo: PropTypes.func.isRequired,
onDescriptionChange: PropTypes.func.isRequired, onDescriptionChange: PropTypes.func.isRequired,
onOpenFocalPoint: PropTypes.func.isRequired,
}; };
state = { state = {
@ -33,6 +32,10 @@ export default class Upload extends ImmutablePureComponent {
this.props.onUndo(this.props.media.get('id')); this.props.onUndo(this.props.media.get('id'));
} }
handleFocalPointClick = () => {
this.props.onOpenFocalPoint(this.props.media.get('id'));
}
handleInputChange = e => { handleInputChange = e => {
this.setState({ dirtyDescription: e.target.value }); this.setState({ dirtyDescription: e.target.value });
} }
@ -63,13 +66,20 @@ export default class Upload extends ImmutablePureComponent {
const { intl, media } = this.props; const { intl, media } = this.props;
const active = this.state.hovered || this.state.focused; const active = this.state.hovered || this.state.focused;
const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || ''; 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 ( return (
<div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => ( {({ scale }) => (
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}> <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} /> <div className={classNames('compose-form__upload__actions', { active })}>
<button className='icon-button' onClick={this.handleUndoClick}><i className='fa fa-times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Undo' /></button>
{media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
</div>
<div className={classNames('compose-form__upload-description', { active })}> <div className={classNames('compose-form__upload-description', { active })}>
<label> <label>

View File

@ -23,6 +23,7 @@ const mapStateToProps = state => ({
is_submitting: state.getIn(['compose', 'is_submitting']), is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']), is_uploading: state.getIn(['compose', 'is_uploading']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({

View File

@ -9,7 +9,8 @@ import spring from 'react-motion/lib/spring';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' }, marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' },
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
@ -50,7 +51,7 @@ class SensitiveButton extends React.PureComponent {
<div className={className} style={{ transform: `scale(${scale})` }}> <div className={className} style={{ transform: `scale(${scale})` }}>
<IconButton <IconButton
className='compose-form__sensitive-button__icon' className='compose-form__sensitive-button__icon'
title={intl.formatMessage(messages.title)} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}
icon={icon} icon={icon}
onClick={onClick} onClick={onClick}
size={18} size={18}

View File

@ -4,12 +4,13 @@ import { changeComposeSpoilerness } from '../../../actions/compose';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind warning' }, marked: { id: 'compose_form.spoiler.marked', defaultMessage: 'Text is hidden behind warning' },
unmarked: { id: 'compose_form.spoiler.unmarked', defaultMessage: 'Text is not hidden' },
}); });
const mapStateToProps = (state, { intl }) => ({ const mapStateToProps = (state, { intl }) => ({
label: 'CW', label: 'CW',
title: intl.formatMessage(messages.title), title: intl.formatMessage(state.getIn(['compose', 'spoiler']) ? messages.marked : messages.unmarked),
active: state.getIn(['compose', 'spoiler']), active: state.getIn(['compose', 'spoiler']),
ariaControls: 'cw-spoiler-input', ariaControls: 'cw-spoiler-input',
}); });

View File

@ -1,6 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import Upload from '../components/upload'; import Upload from '../components/upload';
import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose'; import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
import { openModal } from '../../../actions/modal';
const mapStateToProps = (state, { id }) => ({ const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
@ -13,7 +14,11 @@ const mapDispatchToProps = dispatch => ({
}, },
onDescriptionChange: (id, description) => { onDescriptionChange: (id, description) => {
dispatch(changeUploadCompose(id, description)); dispatch(changeUploadCompose(id, { description }));
},
onOpenFocalPoint: id => {
dispatch(openModal('FOCAL_POINT', { id }));
}, },
}); });

View File

@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { me } from '../../../initial_state'; import { me } from '../../../initial_state';
const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\S+)/i; const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i;
const mapStateToProps = state => ({ const mapStateToProps = state => ({
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']), needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),

View File

@ -12,6 +12,7 @@ import Motion from '../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import SearchResultsContainer from './containers/search_results_container'; import SearchResultsContainer from './containers/search_results_container';
import { changeComposing } from '../../actions/compose'; import { changeComposing } from '../../actions/compose';
import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
const messages = defineMessages({ const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
@ -94,7 +95,11 @@ export default class Compose extends React.PureComponent {
<div className='drawer__inner' onFocus={this.onFocus}> <div className='drawer__inner' onFocus={this.onFocus}>
<NavigationContainer onClose={this.onBlur} /> <NavigationContainer onClose={this.onBlur} />
<ComposeFormContainer /> <ComposeFormContainer />
{multiColumn && <div className='mastodon' />} {multiColumn && (
<div className='drawer__inner__mastodon'>
<img alt='' draggable='false' src={elephantUIPlane} />
</div>
)}
</div> </div>
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>

View File

@ -62,7 +62,7 @@ export default class Favourites extends ImmutablePureComponent {
this.column = c; this.column = c;
} }
handleScrollToBottom = debounce(() => { handleLoadMore = debounce(() => {
this.props.dispatch(expandFavouritedStatuses()); this.props.dispatch(expandFavouritedStatuses());
}, 300, { leading: true }) }, 300, { leading: true })
@ -89,7 +89,7 @@ export default class Favourites extends ImmutablePureComponent {
scrollKey={`favourited_statuses-${columnId}`} scrollKey={`favourited_statuses-${columnId}`}
hasMore={hasMore} hasMore={hasMore}
isLoading={isLoading} isLoading={isLoading}
onScrollToBottom={this.handleScrollToBottom} onLoadMore={this.handleLoadMore}
/> />
</Column> </Column>
); );

View File

@ -80,7 +80,7 @@ export default class Followers extends ImmutablePureComponent {
<ScrollContainer scrollKey='followers'> <ScrollContainer scrollKey='followers'>
<div className='scrollable' onScroll={this.handleScroll}> <div className='scrollable' onScroll={this.handleScroll}>
<div className='followers'> <div className='followers'>
<HeaderContainer accountId={this.props.params.accountId} /> <HeaderContainer accountId={this.props.params.accountId} hideTabs />
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
{loadMore} {loadMore}
</div> </div>

View File

@ -80,7 +80,7 @@ export default class Following extends ImmutablePureComponent {
<ScrollContainer scrollKey='following'> <ScrollContainer scrollKey='following'>
<div className='scrollable' onScroll={this.handleScroll}> <div className='scrollable' onScroll={this.handleScroll}>
<div className='following'> <div className='following'>
<HeaderContainer accountId={this.props.params.accountId} /> <HeaderContainer accountId={this.props.params.accountId} hideTabs />
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
{loadMore} {loadMore}
</div> </div>

View File

@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
export default class ClearColumnButton extends React.Component { export default class ClearColumnButton extends React.PureComponent {
static propTypes = { static propTypes = {
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,

View File

@ -50,8 +50,14 @@ export default class Notifications extends React.PureComponent {
trackScroll: true, trackScroll: true,
}; };
handleScrollToBottom = debounce(() => { componentWillUnmount () {
this.handleLoadMore.cancel();
this.handleScrollToTop.cancel();
this.handleScroll.cancel();
this.props.dispatch(scrollTopNotifications(false)); this.props.dispatch(scrollTopNotifications(false));
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandNotifications()); this.props.dispatch(expandNotifications());
}, 300, { leading: true }); }, 300, { leading: true });
@ -136,7 +142,7 @@ export default class Notifications extends React.PureComponent {
isLoading={isLoading} isLoading={isLoading}
hasMore={hasMore} hasMore={hasMore}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
onScrollToBottom={this.handleScrollToBottom} onLoadMore={this.handleLoadMore}
onScrollToTop={this.handleScrollToTop} onScrollToTop={this.handleScrollToTop}
onScroll={this.handleScroll} onScroll={this.handleScroll}
shouldUpdateScroll={shouldUpdateScroll} shouldUpdateScroll={shouldUpdateScroll}

View File

@ -2,6 +2,10 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import Toggle from 'react-toggle'; 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 { export default class StatusCheckBox extends React.PureComponent {
@ -14,18 +18,48 @@ export default class StatusCheckBox extends React.PureComponent {
render () { render () {
const { status, checked, onToggle, disabled } = this.props; const { status, checked, onToggle, disabled } = this.props;
const content = { __html: status.get('contentHtml') }; let media = null;
if (status.get('reblog')) { if (status.get('reblog')) {
return null; 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 = (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
{Component => (
<Component
preview={video.get('preview_url')}
src={video.get('url')}
width={239}
height={110}
inline
sensitive={status.get('sensitive')}
onOpenVideo={noop}
/>
)}
</Bundle>
);
} else {
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
{Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={noop} />}
</Bundle>
);
}
}
return ( return (
<div className='status-check-box'> <div className='status-check-box'>
<div <div className='status-check-box__status'>
className='status__content' <StatusContent status={status} />
dangerouslySetInnerHTML={content} {media}
/> </div>
<div className='status-check-box-toggle'> <div className='status-check-box-toggle'>
<Toggle checked={checked} onChange={onToggle} disabled={disabled} /> <Toggle checked={checked} onChange={onToggle} disabled={disabled} />

View File

@ -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 (
<Column ref={this.setRef}>
<ColumnHeader
icon='users'
title={intl.formatMessage(messages.title)}
onClick={this.handleHeaderClick}
/>
<StatusListContainer
timelineId='community'
loadMore={this.handleLoadMore}
scrollKey='standalone_public_timeline'
trackScroll={false}
/>
</Column>
);
}
}

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