Merge tag 'v2.7.2' into instance_only_statuses

This commit is contained in:
Renato "Lond" Cerqueira 2019-02-19 21:07:43 +01:00
commit 153385e508
76 changed files with 1440 additions and 577 deletions

View File

@ -9,18 +9,18 @@ and provided thanks to the work of the following contributors:
* [akihikodaki](https://github.com/akihikodaki) * [akihikodaki](https://github.com/akihikodaki)
* [ThibG](https://github.com/ThibG) * [ThibG](https://github.com/ThibG)
* [mjankowski](https://github.com/mjankowski) * [mjankowski](https://github.com/mjankowski)
* [dependabot[bot]](https://github.com/apps/dependabot)
* [unarist](https://github.com/unarist) * [unarist](https://github.com/unarist)
* [m4sk1n](https://github.com/m4sk1n) * [m4sk1n](https://github.com/m4sk1n)
* [dependabot[bot]](https://github.com/apps/dependabot)
* [yiskah](https://github.com/yiskah) * [yiskah](https://github.com/yiskah)
* [nolanlawson](https://github.com/nolanlawson) * [nolanlawson](https://github.com/nolanlawson)
* [sorin-davidoi](https://github.com/sorin-davidoi)
* [ysksn](https://github.com/ysksn) * [ysksn](https://github.com/ysksn)
* [sorin-davidoi](https://github.com/sorin-davidoi)
* [abcang](https://github.com/abcang) * [abcang](https://github.com/abcang)
* [lynlynlynx](https://github.com/lynlynlynx) * [lynlynlynx](https://github.com/lynlynlynx)
* [alpaca-tc](https://github.com/alpaca-tc)
* [mayaeh](https://github.com/mayaeh) * [mayaeh](https://github.com/mayaeh)
* [renatolond](https://github.com/renatolond) * [renatolond](https://github.com/renatolond)
* [alpaca-tc](https://github.com/alpaca-tc)
* [nclm](https://github.com/nclm) * [nclm](https://github.com/nclm)
* [ineffyble](https://github.com/ineffyble) * [ineffyble](https://github.com/ineffyble)
* [jeroenpraat](https://github.com/jeroenpraat) * [jeroenpraat](https://github.com/jeroenpraat)
@ -28,9 +28,9 @@ and provided thanks to the work of the following contributors:
* [Quent-in](https://github.com/Quent-in) * [Quent-in](https://github.com/Quent-in)
* [JantsoP](https://github.com/JantsoP) * [JantsoP](https://github.com/JantsoP)
* [mabkenar](https://github.com/mabkenar) * [mabkenar](https://github.com/mabkenar)
* [Kjwon15](https://github.com/Kjwon15)
* [nullkal](https://github.com/nullkal) * [nullkal](https://github.com/nullkal)
* [yookoala](https://github.com/yookoala) * [yookoala](https://github.com/yookoala)
* [Kjwon15](https://github.com/Kjwon15)
* [shuheiktgw](https://github.com/shuheiktgw) * [shuheiktgw](https://github.com/shuheiktgw)
* [ashfurrow](https://github.com/ashfurrow) * [ashfurrow](https://github.com/ashfurrow)
* [Quenty31](https://github.com/Quenty31) * [Quenty31](https://github.com/Quenty31)
@ -48,16 +48,16 @@ and provided thanks to the work of the following contributors:
* [rkarabut](https://github.com/rkarabut) * [rkarabut](https://github.com/rkarabut)
* [yukimochi](https://github.com/yukimochi) * [yukimochi](https://github.com/yukimochi)
* [Artoria2e5](https://github.com/Artoria2e5) * [Artoria2e5](https://github.com/Artoria2e5)
* [nightpool](https://github.com/nightpool)
* [marrus-sh](https://github.com/marrus-sh) * [marrus-sh](https://github.com/marrus-sh)
* [krainboltgreene](https://github.com/krainboltgreene) * [krainboltgreene](https://github.com/krainboltgreene)
* [patf](https://github.com/patf) * [pfigel](https://github.com/pfigel)
* [Aldarone](https://github.com/Aldarone) * [Aldarone](https://github.com/Aldarone)
* [BoFFire](https://github.com/BoFFire) * [BoFFire](https://github.com/BoFFire)
* [clworld](https://github.com/clworld) * [clworld](https://github.com/clworld)
* [dracos](https://github.com/dracos) * [dracos](https://github.com/dracos)
* [SerCom_KC](mailto:sercom-kc@users.noreply.github.com) * [SerCom_KC](mailto:sercom-kc@users.noreply.github.com)
* [Sylvhem](https://github.com/Sylvhem) * [Sylvhem](https://github.com/Sylvhem)
* [nightpool](https://github.com/nightpool)
* [MasterGroosha](https://github.com/MasterGroosha) * [MasterGroosha](https://github.com/MasterGroosha)
* [JeanGauthier](https://github.com/JeanGauthier) * [JeanGauthier](https://github.com/JeanGauthier)
* [kschaper](https://github.com/kschaper) * [kschaper](https://github.com/kschaper)
@ -77,11 +77,14 @@ and provided thanks to the work of the following contributors:
* [johnsudaar](https://github.com/johnsudaar) * [johnsudaar](https://github.com/johnsudaar)
* [trebmuh](https://github.com/trebmuh) * [trebmuh](https://github.com/trebmuh)
* [Rakib Hasan](mailto:rmhasan@gmail.com) * [Rakib Hasan](mailto:rmhasan@gmail.com)
* [ashleyhull-versent](https://github.com/ashleyhull-versent)
* [lindwurm](https://github.com/lindwurm) * [lindwurm](https://github.com/lindwurm)
* [victorhck](mailto:victorhck@geeko.site) * [victorhck](mailto:victorhck@geeko.site)
* [voidsatisfaction](https://github.com/voidsatisfaction) * [voidsatisfaction](https://github.com/voidsatisfaction)
* [rinsuki](https://github.com/rinsuki)
* [hikari-no-yume](https://github.com/hikari-no-yume) * [hikari-no-yume](https://github.com/hikari-no-yume)
* [angristan](https://github.com/angristan) * [angristan](https://github.com/angristan)
* [hinaloe](https://github.com/hinaloe)
* [seefood](https://github.com/seefood) * [seefood](https://github.com/seefood)
* [jackjennings](https://github.com/jackjennings) * [jackjennings](https://github.com/jackjennings)
* [spla](mailto:spla@mastodont.cat) * [spla](mailto:spla@mastodont.cat)
@ -92,20 +95,20 @@ and provided thanks to the work of the following contributors:
* [dunn](https://github.com/dunn) * [dunn](https://github.com/dunn)
* [xqus](https://github.com/xqus) * [xqus](https://github.com/xqus)
* [hugogameiro](https://github.com/hugogameiro) * [hugogameiro](https://github.com/hugogameiro)
* [ariasuni](https://github.com/ariasuni)
* [pfm-eyesightjp](https://github.com/pfm-eyesightjp) * [pfm-eyesightjp](https://github.com/pfm-eyesightjp)
* [fakenine](https://github.com/fakenine) * [fakenine](https://github.com/fakenine)
* [tsuwatch](https://github.com/tsuwatch) * [tsuwatch](https://github.com/tsuwatch)
* [victorhck](https://github.com/victorhck) * [victorhck](https://github.com/victorhck)
* [ashleyhull-versent](https://github.com/ashleyhull-versent)
* [kedamaDQ](https://github.com/kedamaDQ) * [kedamaDQ](https://github.com/kedamaDQ)
* [puckipedia](https://github.com/puckipedia) * [puckipedia](https://github.com/puckipedia)
* [fvh-P](https://github.com/fvh-P) * [fvh-P](https://github.com/fvh-P)
* [contraexemplo](https://github.com/contraexemplo) * [contraexemplo](https://github.com/contraexemplo)
* [Aditoo17](https://github.com/Aditoo17)
* [kazu9su](https://github.com/kazu9su) * [kazu9su](https://github.com/kazu9su)
* [Komic](https://github.com/Komic) * [Komic](https://github.com/Komic)
* [lmorchard](https://github.com/lmorchard) * [lmorchard](https://github.com/lmorchard)
* [diomed](https://github.com/diomed) * [diomed](https://github.com/diomed)
* [ariasuni](https://github.com/ariasuni)
* [Neetshin](mailto:neetshin@neetsh.in) * [Neetshin](mailto:neetshin@neetsh.in)
* [rainyday](https://github.com/rainyday) * [rainyday](https://github.com/rainyday)
* [ProgVal](https://github.com/ProgVal) * [ProgVal](https://github.com/ProgVal)
@ -114,7 +117,8 @@ and provided thanks to the work of the following contributors:
* [goofy-bz](mailto:goofy@babelzilla.org) * [goofy-bz](mailto:goofy@babelzilla.org)
* [kadiix](https://github.com/kadiix) * [kadiix](https://github.com/kadiix)
* [kodacs](https://github.com/kodacs) * [kodacs](https://github.com/kodacs)
* [rtucker](https://github.com/rtucker) * [trwnh](https://github.com/trwnh)
* [JMendyk](https://github.com/JMendyk)
* [KScl](https://github.com/KScl) * [KScl](https://github.com/KScl)
* [sterdev](https://github.com/sterdev) * [sterdev](https://github.com/sterdev)
* [TheKinrar](https://github.com/TheKinrar) * [TheKinrar](https://github.com/TheKinrar)
@ -125,16 +129,16 @@ and provided thanks to the work of the following contributors:
* [fhemberger](https://github.com/fhemberger) * [fhemberger](https://github.com/fhemberger)
* [greysteil](https://github.com/greysteil) * [greysteil](https://github.com/greysteil)
* [hensmith](https://github.com/hensmith) * [hensmith](https://github.com/hensmith)
* [hinaloe](https://github.com/hinaloe)
* [d6rkaiz](https://github.com/d6rkaiz) * [d6rkaiz](https://github.com/d6rkaiz)
* [Reverite](https://github.com/Reverite) * [Reverite](https://github.com/Reverite)
* [JMendyk](https://github.com/JMendyk)
* [JohnD28](https://github.com/JohnD28) * [JohnD28](https://github.com/JohnD28)
* [znz](https://github.com/znz) * [znz](https://github.com/znz)
* [Naouak](https://github.com/Naouak) * [Naouak](https://github.com/Naouak)
* [pawelngei](https://github.com/pawelngei) * [pawelngei](https://github.com/pawelngei)
* [rtucker](https://github.com/rtucker)
* [reneklacan](https://github.com/reneklacan) * [reneklacan](https://github.com/reneklacan)
* [ekiru](https://github.com/ekiru) * [ekiru](https://github.com/ekiru)
* [noellabo](https://github.com/noellabo)
* [tcitworld](https://github.com/tcitworld) * [tcitworld](https://github.com/tcitworld)
* [geta6](https://github.com/geta6) * [geta6](https://github.com/geta6)
* [happycoloredbanana](https://github.com/happycoloredbanana) * [happycoloredbanana](https://github.com/happycoloredbanana)
@ -144,9 +148,9 @@ and provided thanks to the work of the following contributors:
* [noraworld](https://github.com/noraworld) * [noraworld](https://github.com/noraworld)
* [theboss](https://github.com/theboss) * [theboss](https://github.com/theboss)
* [178inaba](https://github.com/178inaba) * [178inaba](https://github.com/178inaba)
* [Aditoo17](https://github.com/Aditoo17)
* [alyssais](https://github.com/alyssais) * [alyssais](https://github.com/alyssais)
* [kodnaplakal](https://github.com/kodnaplakal) * [hiphref](https://github.com/hiphref)
* [BenLubar](https://github.com/BenLubar)
* [stalker314314](https://github.com/stalker314314) * [stalker314314](https://github.com/stalker314314)
* [huertanix](https://github.com/huertanix) * [huertanix](https://github.com/huertanix)
* [genesixx](https://github.com/genesixx) * [genesixx](https://github.com/genesixx)
@ -157,6 +161,7 @@ and provided thanks to the work of the following contributors:
* [kmichl](https://github.com/kmichl) * [kmichl](https://github.com/kmichl)
* [Kurtis Rainbolt-Greene](mailto:me@kurtisrainboltgreene.name) * [Kurtis Rainbolt-Greene](mailto:me@kurtisrainboltgreene.name)
* [saper](https://github.com/saper) * [saper](https://github.com/saper)
* [marek-lach](https://github.com/marek-lach)
* [nevillepark](https://github.com/nevillepark) * [nevillepark](https://github.com/nevillepark)
* [ornithocoder](https://github.com/ornithocoder) * [ornithocoder](https://github.com/ornithocoder)
* [pierreozoux](https://github.com/pierreozoux) * [pierreozoux](https://github.com/pierreozoux)
@ -164,7 +169,6 @@ and provided thanks to the work of the following contributors:
* [Ram Lmn](mailto:ramlmn@users.noreply.github.com) * [Ram Lmn](mailto:ramlmn@users.noreply.github.com)
* [harukasan](https://github.com/harukasan) * [harukasan](https://github.com/harukasan)
* [stamak](https://github.com/stamak) * [stamak](https://github.com/stamak)
* [noellabo](https://github.com/noellabo)
* [Technowix](mailto:technowix@users.noreply.github.com) * [Technowix](mailto:technowix@users.noreply.github.com)
* [Eychics](https://github.com/Eychics) * [Eychics](https://github.com/Eychics)
* [Thor Harald Johansen](mailto:thj@thj.no) * [Thor Harald Johansen](mailto:thj@thj.no)
@ -179,21 +183,20 @@ and provided thanks to the work of the following contributors:
* [hoodie](mailto:hoodiekitten@outlook.com) * [hoodie](mailto:hoodiekitten@outlook.com)
* [luzi82](https://github.com/luzi82) * [luzi82](https://github.com/luzi82)
* [duxovni](https://github.com/duxovni) * [duxovni](https://github.com/duxovni)
* [trwnh](https://github.com/trwnh) * [tmm576](https://github.com/tmm576)
* [unsmell](https://github.com/unsmell) * [unsmell](https://github.com/unsmell)
* [valerauko](https://github.com/valerauko) * [valerauko](https://github.com/valerauko)
* [chriswmartin](https://github.com/chriswmartin) * [chriswmartin](https://github.com/chriswmartin)
* [vahnj](https://github.com/vahnj) * [vahnj](https://github.com/vahnj)
* [ikuradon](https://github.com/ikuradon) * [ikuradon](https://github.com/ikuradon)
* [AndreLewin](https://github.com/AndreLewin) * [AndreLewin](https://github.com/AndreLewin)
* [rinsuki](https://github.com/rinsuki)
* [0xflotus](https://github.com/0xflotus) * [0xflotus](https://github.com/0xflotus)
* [redtachyons](https://github.com/redtachyons) * [redtachyons](https://github.com/redtachyons)
* [thurloat](https://github.com/thurloat) * [thurloat](https://github.com/thurloat)
* [aaribaud](https://github.com/aaribaud) * [aaribaud](https://github.com/aaribaud)
* [pointlessone](https://github.com/pointlessone)
* [Andrew](mailto:andrewlchronister@gmail.com) * [Andrew](mailto:andrewlchronister@gmail.com)
* [estuans](https://github.com/estuans) * [estuans](https://github.com/estuans)
* [BenLubar](https://github.com/BenLubar)
* [dissolve](https://github.com/dissolve) * [dissolve](https://github.com/dissolve)
* [PurpleBooth](https://github.com/PurpleBooth) * [PurpleBooth](https://github.com/PurpleBooth)
* [bradurani](https://github.com/bradurani) * [bradurani](https://github.com/bradurani)
@ -216,6 +219,7 @@ and provided thanks to the work of the following contributors:
* [ErikXXon](https://github.com/ErikXXon) * [ErikXXon](https://github.com/ErikXXon)
* [ian-kelling](https://github.com/ian-kelling) * [ian-kelling](https://github.com/ian-kelling)
* [immae](https://github.com/immae) * [immae](https://github.com/immae)
* [J0WI](https://github.com/J0WI)
* [foozmeat](https://github.com/foozmeat) * [foozmeat](https://github.com/foozmeat)
* [jasonrhodes](https://github.com/jasonrhodes) * [jasonrhodes](https://github.com/jasonrhodes)
* [Jason Snell](mailto:jason@newrelic.com) * [Jason Snell](mailto:jason@newrelic.com)
@ -230,6 +234,7 @@ and provided thanks to the work of the following contributors:
* [Lorenz Diener](mailto:halcyon@icosahedron.website) * [Lorenz Diener](mailto:halcyon@icosahedron.website)
* [alimony](https://github.com/alimony) * [alimony](https://github.com/alimony)
* [mig5](https://github.com/mig5) * [mig5](https://github.com/mig5)
* [moritzheiber](https://github.com/moritzheiber)
* [ndarville](https://github.com/ndarville) * [ndarville](https://github.com/ndarville)
* [Abzol](https://github.com/Abzol) * [Abzol](https://github.com/Abzol)
* [pwoolcoc](https://github.com/pwoolcoc) * [pwoolcoc](https://github.com/pwoolcoc)
@ -238,6 +243,7 @@ and provided thanks to the work of the following contributors:
* [ignisf](https://github.com/ignisf) * [ignisf](https://github.com/ignisf)
* [raymestalez](https://github.com/raymestalez) * [raymestalez](https://github.com/raymestalez)
* [remram44](https://github.com/remram44) * [remram44](https://github.com/remram44)
* [sts10](https://github.com/sts10)
* [sascha-sl](https://github.com/sascha-sl) * [sascha-sl](https://github.com/sascha-sl)
* [u1-liquid](https://github.com/u1-liquid) * [u1-liquid](https://github.com/u1-liquid)
* [sim6](https://github.com/sim6) * [sim6](https://github.com/sim6)
@ -288,6 +294,7 @@ and provided thanks to the work of the following contributors:
* [857b](https://github.com/857b) * [857b](https://github.com/857b)
* [insom](https://github.com/insom) * [insom](https://github.com/insom)
* [tachyons](https://github.com/tachyons) * [tachyons](https://github.com/tachyons)
* [acid-chicken](https://github.com/acid-chicken)
* [Esteth](https://github.com/Esteth) * [Esteth](https://github.com/Esteth)
* [unascribed](https://github.com/unascribed) * [unascribed](https://github.com/unascribed)
* [Aguay-val](https://github.com/Aguay-val) * [Aguay-val](https://github.com/Aguay-val)
@ -297,7 +304,6 @@ and provided thanks to the work of the following contributors:
* [unleashed](https://github.com/unleashed) * [unleashed](https://github.com/unleashed)
* [alxrcs](https://github.com/alxrcs) * [alxrcs](https://github.com/alxrcs)
* [console-cowboy](https://github.com/console-cowboy) * [console-cowboy](https://github.com/console-cowboy)
* [pointlessone](https://github.com/pointlessone)
* [Alkarex](https://github.com/Alkarex) * [Alkarex](https://github.com/Alkarex)
* [a2](https://github.com/a2) * [a2](https://github.com/a2)
* [0xa](https://github.com/0xa) * [0xa](https://github.com/0xa)
@ -329,6 +335,7 @@ and provided thanks to the work of the following contributors:
* [Motoma](https://github.com/Motoma) * [Motoma](https://github.com/Motoma)
* [chriswk](https://github.com/chriswk) * [chriswk](https://github.com/chriswk)
* [csu](https://github.com/csu) * [csu](https://github.com/csu)
* [clarcharr](https://github.com/clarcharr)
* [kklleemm](https://github.com/kklleemm) * [kklleemm](https://github.com/kklleemm)
* [colindean](https://github.com/colindean) * [colindean](https://github.com/colindean)
* [dachinat](https://github.com/dachinat) * [dachinat](https://github.com/dachinat)
@ -356,6 +363,7 @@ and provided thanks to the work of the following contributors:
* [espenronnevik](https://github.com/espenronnevik) * [espenronnevik](https://github.com/espenronnevik)
* [Finariel](https://github.com/Finariel) * [Finariel](https://github.com/Finariel)
* [siuying](https://github.com/siuying) * [siuying](https://github.com/siuying)
* [zoc](https://github.com/zoc)
* [fwenzel](https://github.com/fwenzel) * [fwenzel](https://github.com/fwenzel)
* [GenbuHase](https://github.com/GenbuHase) * [GenbuHase](https://github.com/GenbuHase)
* [hattori6789](https://github.com/hattori6789) * [hattori6789](https://github.com/hattori6789)
@ -416,6 +424,7 @@ and provided thanks to the work of the following contributors:
* [martymcguire](https://github.com/martymcguire) * [martymcguire](https://github.com/martymcguire)
* [marvinkopf](https://github.com/marvinkopf) * [marvinkopf](https://github.com/marvinkopf)
* [otsune](https://github.com/otsune) * [otsune](https://github.com/otsune)
* [mbugowski](https://github.com/mbugowski)
* [Mathias B](mailto:10813340+mathias-b@users.noreply.github.com) * [Mathias B](mailto:10813340+mathias-b@users.noreply.github.com)
* [matt-auckland](https://github.com/matt-auckland) * [matt-auckland](https://github.com/matt-auckland)
* [webroo](https://github.com/webroo) * [webroo](https://github.com/webroo)
@ -434,7 +443,6 @@ and provided thanks to the work of the following contributors:
* [premist](https://github.com/premist) * [premist](https://github.com/premist)
* [Mnkai](https://github.com/Mnkai) * [Mnkai](https://github.com/Mnkai)
* [mitchhentges](https://github.com/mitchhentges) * [mitchhentges](https://github.com/mitchhentges)
* [moritzheiber](https://github.com/moritzheiber)
* [mouse-reeve](https://github.com/mouse-reeve) * [mouse-reeve](https://github.com/mouse-reeve)
* [Mozinet-fr](https://github.com/Mozinet-fr) * [Mozinet-fr](https://github.com/Mozinet-fr)
* [lae](https://github.com/lae) * [lae](https://github.com/lae)
@ -458,17 +466,17 @@ and provided thanks to the work of the following contributors:
* [Pangoraw](https://github.com/Pangoraw) * [Pangoraw](https://github.com/Pangoraw)
* [peterkeen](https://github.com/peterkeen) * [peterkeen](https://github.com/peterkeen)
* [pgate](https://github.com/pgate) * [pgate](https://github.com/pgate)
* [retokromer](https://github.com/retokromer) * [Reto Kromer](mailto:retokromer@users.noreply.github.com)
* [rfwatson](https://github.com/rfwatson) * [Rey Tucker](mailto:git@reytucker.us)
* [rfreebern](https://github.com/rfreebern) * [Rob Watson](mailto:rfwatson@users.noreply.github.com)
* [Ryan Freebern](mailto:ryan@freebern.org)
* [Ryan Wade](mailto:ryan.wade@protonmail.com) * [Ryan Wade](mailto:ryan.wade@protonmail.com)
* [sylph01](https://github.com/sylph01) * [Ryo Kajiwara](mailto:kfe-fecn6.prussian@s01.info)
* [S-H-GAMELINKS](https://github.com/S-H-GAMELINKS) * [S.H](mailto:gamelinks007@gmail.com)
* [staticsafe](https://github.com/staticsafe) * [Sadiq Saif](mailto:staticsafe@users.noreply.github.com)
* [snwh](https://github.com/snwh) * [Sam Hewitt](mailto:hewittsamuel@gmail.com)
* [sts10](https://github.com/sts10) * [Satoshi KOJIMA](mailto:skoji@mac.com)
* [skoji](https://github.com/skoji) * [ScienJus](mailto:i@scienjus.com)
* [ScienJus](https://github.com/ScienJus)
* [Scott Larkin](mailto:scott@codeclimate.com) * [Scott Larkin](mailto:scott@codeclimate.com)
* [Sebastian Hübner](mailto:imolein@users.noreply.github.com) * [Sebastian Hübner](mailto:imolein@users.noreply.github.com)
* [Sebastian Morr](mailto:sebastian@morr.cc) * [Sebastian Morr](mailto:sebastian@morr.cc)
@ -483,6 +491,7 @@ and provided thanks to the work of the following contributors:
* [Sir-Boops](mailto:admin@boops.me) * [Sir-Boops](mailto:admin@boops.me)
* [Soshi Kato](mailto:mail@sossii.com) * [Soshi Kato](mailto:mail@sossii.com)
* [Spanky](mailto:2788886+spankyworks@users.noreply.github.com) * [Spanky](mailto:2788886+spankyworks@users.noreply.github.com)
* [Stanislas](mailto:angristan@pm.me)
* [StefOfficiel](mailto:pichard.stephane@free.fr) * [StefOfficiel](mailto:pichard.stephane@free.fr)
* [Steven Tappert](mailto:admin@dark-it.net) * [Steven Tappert](mailto:admin@dark-it.net)
* [Svetlozar Todorov](mailto:svetlik@users.noreply.github.com) * [Svetlozar Todorov](mailto:svetlik@users.noreply.github.com)
@ -532,6 +541,7 @@ and provided thanks to the work of the following contributors:
* [fsubal](mailto:fsubal@users.noreply.github.com) * [fsubal](mailto:fsubal@users.noreply.github.com)
* [fusshi-](mailto:dikky1218@users.noreply.github.com) * [fusshi-](mailto:dikky1218@users.noreply.github.com)
* [gentaro](mailto:gentaroooo@gmail.com) * [gentaro](mailto:gentaroooo@gmail.com)
* [gol-cha](mailto:info@mevo.xyz)
* [hakoai](mailto:hk--76@qa2.so-net.ne.jp) * [hakoai](mailto:hk--76@qa2.so-net.ne.jp)
* [haosbvnker](mailto:github@chaosbunker.com) * [haosbvnker](mailto:github@chaosbunker.com)
* [isati](mailto:phil@juchnowi.cz) * [isati](mailto:phil@juchnowi.cz)
@ -549,12 +559,12 @@ and provided thanks to the work of the following contributors:
* [luzpaz](mailto:luzpaz@users.noreply.github.com) * [luzpaz](mailto:luzpaz@users.noreply.github.com)
* [maxypy](mailto:maxime@mpigou.fr) * [maxypy](mailto:maxime@mpigou.fr)
* [mhe](mailto:mail@marcus-herrmann.com) * [mhe](mailto:mail@marcus-herrmann.com)
* [mike castleman](mailto:m@mlcastle.net)
* [mimikun](mailto:dzdzble_effort_311@outlook.jp) * [mimikun](mailto:dzdzble_effort_311@outlook.jp)
* [mshrtkch](mailto:mshrtkch@users.noreply.github.com) * [mshrtkch](mailto:mshrtkch@users.noreply.github.com)
* [muan](mailto:muan@github.com) * [muan](mailto:muan@github.com)
* [namelessGonbai](mailto:43787036+namelessgonbai@users.noreply.github.com) * [namelessGonbai](mailto:43787036+namelessgonbai@users.noreply.github.com)
* [neetshin](mailto:neetshin@neetsh.in) * [neetshin](mailto:neetshin@neetsh.in)
* [nightpool](mailto:nightpool@users.noreply.github.com)
* [rch850](mailto:rich850@gmail.com) * [rch850](mailto:rich850@gmail.com)
* [roikale](mailto:roikale@users.noreply.github.com) * [roikale](mailto:roikale@users.noreply.github.com)
* [rysiekpl](mailto:rysiek@hackerspace.pl) * [rysiekpl](mailto:rysiek@hackerspace.pl)

View File

@ -3,6 +3,47 @@ Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [2.7.2] - 2019-02-17
### Added
- Add support for IPv6 in e-mail validation ([zoc](https://github.com/tootsuite/mastodon/pull/10009))
- Add record of IP address used for signing up ([ThibG](https://github.com/tootsuite/mastodon/pull/10026))
- Add tight rate-limit for API deletions (30 per 30 minutes) ([Gargron](https://github.com/tootsuite/mastodon/pull/10042))
- Add support for embedded `Announce` objects attributed to the same actor ([ThibG](https://github.com/tootsuite/mastodon/pull/9998), [Gargron](https://github.com/tootsuite/mastodon/pull/10065))
- Add spam filter for `Create` and `Announce` activities ([Gargron](https://github.com/tootsuite/mastodon/pull/10005), [Gargron](https://github.com/tootsuite/mastodon/pull/10041), [Gargron](https://github.com/tootsuite/mastodon/pull/10062))
- Add `registrations` attribute to `GET /api/v1/instance` ([Gargron](https://github.com/tootsuite/mastodon/pull/10060))
- Add `vapid_key` to `POST /api/v1/apps` and `GET /api/v1/apps/verify_credentials` ([Gargron](https://github.com/tootsuite/mastodon/pull/10058))
### Fixed
- Fix link color and add link underlines in high-contrast theme ([Gargron](https://github.com/tootsuite/mastodon/pull/9949), [Gargron](https://github.com/tootsuite/mastodon/pull/10028))
- Fix unicode characters in URLs not being linkified ([JMendyk](https://github.com/tootsuite/mastodon/pull/8447), [hinaloe](https://github.com/tootsuite/mastodon/pull/9991))
- Fix URLs linkifier grabbing ending quotation as part of the link ([Gargron](https://github.com/tootsuite/mastodon/pull/9997))
- Fix authorized applications page design ([rinsuki](https://github.com/tootsuite/mastodon/pull/9969))
- Fix custom emojis not showing up in share page emoji picker ([rinsuki](https://github.com/tootsuite/mastodon/pull/9970))
- Fix too liberal application of whitespace in toots ([trwnh](https://github.com/tootsuite/mastodon/pull/9968))
- Fix misleading e-mail hint being displayed in admin view ([ThibG](https://github.com/tootsuite/mastodon/pull/9973))
- Fix tombstones not being cleared out ([abcang](https://github.com/tootsuite/mastodon/pull/9978))
- Fix some timeline jumps ([ThibG](https://github.com/tootsuite/mastodon/pull/9982), [ThibG](https://github.com/tootsuite/mastodon/pull/10001), [rinsuki](https://github.com/tootsuite/mastodon/pull/10046))
- Fix content warning input taking keyboard focus even when hidden ([hinaloe](https://github.com/tootsuite/mastodon/pull/10017))
- Fix hashtags select styling in default and high-contrast themes ([Gargron](https://github.com/tootsuite/mastodon/pull/10029))
- Fix style regressions on landing page ([Gargron](https://github.com/tootsuite/mastodon/pull/10030))
- Fix hashtag column not subscribing to stream on mount ([Gargron](https://github.com/tootsuite/mastodon/pull/10040))
- Fix relay enabling/disabling not resetting inbox availability status ([Gargron](https://github.com/tootsuite/mastodon/pull/10048))
- Fix mutes, blocks, domain blocks and follow requests not paginating ([Gargron](https://github.com/tootsuite/mastodon/pull/10057))
- Fix crash on public hashtag pages when streaming fails ([ThibG](https://github.com/tootsuite/mastodon/pull/10061))
### Changed
- Change icon for unlisted visibility level ([clarcharr](https://github.com/tootsuite/mastodon/pull/9952))
- Change queue of actor deletes from push to pull for non-follower recipients ([ThibG](https://github.com/tootsuite/mastodon/pull/10016))
- Change robots.txt to exclude media proxy URLs ([nightpool](https://github.com/tootsuite/mastodon/pull/10038))
- Change upload description input to allow line breaks ([BenLubar](https://github.com/tootsuite/mastodon/pull/10036))
- Change `dist/mastodon-streaming.service` to recommend running node without intermediary npm command ([nolanlawson](https://github.com/tootsuite/mastodon/pull/10032))
- Change conversations to always show names of other participants ([Gargron](https://github.com/tootsuite/mastodon/pull/10047))
- Change buttons on timeline preview to open the interaction dialog ([Gargron](https://github.com/tootsuite/mastodon/pull/10054))
- Change error graphic to hover-to-play ([Gargron](https://github.com/tootsuite/mastodon/pull/10055))
## [2.7.1] - 2019-01-28 ## [2.7.1] - 2019-01-28
### Fixed ### Fixed

View File

@ -86,7 +86,7 @@ You can open issues for bugs you've found or features you think are missing. You
## License ## License
Copyright (C) 2016-2018 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md)) Copyright (C) 2016-2019 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](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 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.

View File

@ -31,7 +31,7 @@ class StatusesIndex < Chewy::Index
}, },
} }
define_type ::Status.unscoped.without_reblogs do define_type ::Status.unscoped.without_reblogs.includes(:media_attachments) do
crutch :mentions do |collection| crutch :mentions do |collection|
data = ::Mention.where(status_id: collection.map(&:id)).pluck(:status_id, :account_id) data = ::Mention.where(status_id: collection.map(&:id)).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
@ -50,7 +50,7 @@ class StatusesIndex < Chewy::Index
root date_detection: false do root date_detection: false do
field :account_id, type: 'long' field :account_id, type: 'long'
field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].join("\n\n") } do field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).join("\n\n") } do
field :stemmed, type: 'text', analyzer: 'content' field :stemmed, type: 'text', analyzer: 'content'
end end

View File

@ -6,6 +6,6 @@ class Api::V1::Apps::CredentialsController < Api::BaseController
respond_to :json respond_to :json
def show def show
render json: doorkeeper_token.application, serializer: REST::StatusSerializer::ApplicationSerializer render json: doorkeeper_token.application, serializer: REST::ApplicationSerializer, fields: %i(name website vapid_key)
end end
end end

View File

@ -28,6 +28,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
resource.invite_code = params[:invite_code] if resource.invite_code.blank? resource.invite_code = params[:invite_code] if resource.invite_code.blank?
resource.agreement = true resource.agreement = true
resource.current_sign_in_ip = request.remote_ip if resource.current_sign_in_ip.nil?
resource.build_account if resource.account.nil? resource.build_account if resource.account.nil?
end end

View File

@ -5,6 +5,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
before_action :store_current_location before_action :store_current_location
before_action :authenticate_resource_owner! before_action :authenticate_resource_owner!
before_action :set_body_classes
include Localized include Localized
@ -15,6 +16,10 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
private private
def set_body_classes
@body_classes = 'admin'
end
def store_current_location def store_current_location
store_location_for(:user, request.url) store_location_for(:user, request.url)
end end

View File

@ -170,7 +170,7 @@ module StreamEntriesHelper
when 'public' when 'public'
fa_icon 'globe fw' fa_icon 'globe fw'
when 'unlisted' when 'unlisted'
fa_icon 'unlock-alt fw' fa_icon 'unlock fw'
when 'private' when 'private'
fa_icon 'lock fw' fa_icon 'lock fw'
when 'direct' when 'direct'

View File

@ -88,7 +88,7 @@ class Account extends ImmutablePureComponent {
if (requested) { if (requested) {
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />; buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
} else if (blocking) { } else if (blocking) {
buttons = <IconButton active icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />; buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
} else if (muting) { } else if (muting) {
let hidingNotificationsButton; let hidingNotificationsButton;
if (account.getIn(['relationship', 'muting_notifications'])) { if (account.getIn(['relationship', 'muting_notifications'])) {

View File

@ -11,26 +11,36 @@ export default class DisplayName extends React.PureComponent {
}; };
render () { render () {
const { account, others, localDomain } = this.props; const { others, localDomain } = this.props;
const displayNameHtml = { __html: account.get('display_name_html') };
let suffix; let displayName, suffix, account;
if (others && others.size > 1) { if (others && others.size > 1) {
suffix = `+${others.size}`; displayName = others.take(2).map(a => <bdi key={a.get('id')}><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi>).reduce((prev, cur) => [prev, ', ', cur]);
if (others.size - 2 > 0) {
suffix = `+${others.size - 2}`;
}
} else { } else {
if (others) {
account = others.first();
} else {
account = this.props.account;
}
let acct = account.get('acct'); let acct = account.get('acct');
if (acct.indexOf('@') === -1 && localDomain) { if (acct.indexOf('@') === -1 && localDomain) {
acct = `${acct}@${localDomain}`; acct = `${acct}@${localDomain}`;
} }
displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
suffix = <span className='display-name__account'>@{acct}</span>; suffix = <span className='display-name__account'>@{acct}</span>;
} }
return ( return (
<span className='display-name'> <span className='display-name'>
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /></bdi> {suffix} {displayName} {suffix}
</span> </span>
); );
} }

View File

@ -32,7 +32,7 @@ class Account extends ImmutablePureComponent {
</span> </span>
<div className='domain__buttons'> <div className='domain__buttons'>
<IconButton active icon='unlock-alt' title={intl.formatMessage(messages.unblockDomain, { domain })} onClick={this.handleDomainUnblock} /> <IconButton active icon='unlock' title={intl.formatMessage(messages.unblockDomain, { domain })} onClick={this.handleDomainUnblock} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -65,7 +65,7 @@ export default class IntersectionObserverArticle extends React.Component {
} }
updateStateAfterIntersection = (prevState) => { updateStateAfterIntersection = (prevState) => {
if (prevState.isIntersecting && !this.entry.isIntersecting) { if (prevState.isIntersecting !== false && !this.entry.isIntersecting) {
scheduleIdleTask(this.hideIfNotIntersecting); scheduleIdleTask(this.hideIfNotIntersecting);
} }
return { return {

View File

@ -194,6 +194,8 @@ class MediaGallery extends React.PureComponent {
height: PropTypes.number.isRequired, height: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired, onOpenMedia: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
}; };
static defaultProps = { static defaultProps = {
@ -202,6 +204,7 @@ class MediaGallery extends React.PureComponent {
state = { state = {
visible: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all', visible: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all',
width: this.props.defaultWidth,
}; };
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
@ -221,6 +224,7 @@ 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
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
this.setState({ this.setState({
width: node.offsetWidth, width: node.offsetWidth,
}); });
@ -233,8 +237,10 @@ class MediaGallery extends React.PureComponent {
} }
render () { render () {
const { media, intl, sensitive, height } = this.props; const { media, intl, sensitive, height, defaultWidth } = this.props;
const { width, visible } = this.state; const { visible } = this.state;
const width = this.state.width || defaultWidth;
let children; let children;

View File

@ -40,6 +40,7 @@ export default class ScrollableList extends PureComponent {
state = { state = {
fullscreen: null, fullscreen: null,
cachedMediaWidth: 250, // Default media/card width using default Mastodon theme
}; };
intersectionObserverWrapper = new IntersectionObserverWrapper(); intersectionObserverWrapper = new IntersectionObserverWrapper();
@ -130,6 +131,20 @@ export default class ScrollableList extends PureComponent {
this.handleScroll(); this.handleScroll();
} }
getScrollPosition = () => {
if (this.node && (this.node.scrollTop > 0 || this.mouseMovedRecently)) {
return { height: this.node.scrollHeight, top: this.node.scrollTop };
} else {
return null;
}
}
updateScrollBottom = (snapshot) => {
const newScrollTop = this.node.scrollHeight - snapshot;
this.setScrollTop(newScrollTop);
}
getSnapshotBeforeUpdate (prevProps) { getSnapshotBeforeUpdate (prevProps) {
const someItemInserted = React.Children.count(prevProps.children) > 0 && const someItemInserted = React.Children.count(prevProps.children) > 0 &&
React.Children.count(prevProps.children) < React.Children.count(this.props.children) && React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
@ -150,6 +165,12 @@ export default class ScrollableList extends PureComponent {
} }
} }
cacheMediaWidth = (width) => {
if (width && this.state.cachedMediaWidth !== width) {
this.setState({ cachedMediaWidth: width });
}
}
componentWillUnmount () { componentWillUnmount () {
this.clearMouseIdleTimer(); this.clearMouseIdleTimer();
this.detachScrollListener(); this.detachScrollListener();
@ -239,7 +260,12 @@ export default class ScrollableList extends PureComponent {
intersectionObserverWrapper={this.intersectionObserverWrapper} intersectionObserverWrapper={this.intersectionObserverWrapper}
saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null} saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
> >
{child} {React.cloneElement(child, {
getScrollPosition: this.getScrollPosition,
updateScrollBottom: this.updateScrollBottom,
cachedMediaWidth: this.state.cachedMediaWidth,
cacheMediaWidth: this.cacheMediaWidth,
})}
</IntersectionObserverArticleContainer> </IntersectionObserverArticleContainer>
))} ))}

View File

@ -68,6 +68,10 @@ class Status extends ImmutablePureComponent {
onMoveUp: PropTypes.func, onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func, onMoveDown: PropTypes.func,
showThread: PropTypes.bool, showThread: PropTypes.bool,
getScrollPosition: PropTypes.func,
updateScrollBottom: PropTypes.func,
cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number,
}; };
// Avoid checking props that are functions (and whose equality will always // Avoid checking props that are functions (and whose equality will always
@ -79,6 +83,43 @@ class Status extends ImmutablePureComponent {
'hidden', 'hidden',
]; ];
// Track height changes we know about to compensate scrolling
componentDidMount () {
this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
}
getSnapshotBeforeUpdate () {
if (this.props.getScrollPosition) {
return this.props.getScrollPosition();
} else {
return null;
}
}
// Compensate height changes
componentDidUpdate (prevProps, prevState, snapshot) {
const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
if (doShowCard && !this.didShowCard) {
this.didShowCard = true;
if (snapshot !== null && this.props.updateScrollBottom) {
if (this.node && this.node.offsetTop < snapshot.top) {
this.props.updateScrollBottom(snapshot.height - snapshot.top);
}
}
}
}
componentWillUnmount() {
if (this.node && this.props.getScrollPosition) {
const position = this.props.getScrollPosition();
if (position !== null && this.node.offsetTop < position.top) {
requestAnimationFrame(() => {
this.props.updateScrollBottom(position.height - position.top);
});
}
}
}
handleClick = () => { handleClick = () => {
if (this.props.onClick) { if (this.props.onClick) {
this.props.onClick(); this.props.onClick();
@ -165,6 +206,10 @@ class Status extends ImmutablePureComponent {
} }
} }
handleRef = c => {
this.node = c;
}
render () { render () {
let media = null; let media = null;
let statusAvatar, prepend, rebloggedByText; let statusAvatar, prepend, rebloggedByText;
@ -179,7 +224,7 @@ class Status extends ImmutablePureComponent {
if (hidden) { if (hidden) {
return ( return (
<div> <div ref={this.handleRef}>
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
{status.get('content')} {status.get('content')}
</div> </div>
@ -194,7 +239,7 @@ class Status extends ImmutablePureComponent {
return ( return (
<HotKeys handlers={minHandlers}> <HotKeys handlers={minHandlers}>
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0'> <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' /> <FormattedMessage id='status.filtered' defaultMessage='Filtered' />
</div> </div>
</HotKeys> </HotKeys>
@ -242,11 +287,12 @@ class Status extends ImmutablePureComponent {
preview={video.get('preview_url')} preview={video.get('preview_url')}
src={video.get('url')} src={video.get('url')}
alt={video.get('description')} alt={video.get('description')}
width={239} width={this.props.cachedMediaWidth}
height={110} height={110}
inline inline
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
onOpenVideo={this.handleOpenVideo} onOpenVideo={this.handleOpenVideo}
cacheWidth={this.props.cacheMediaWidth}
/> />
)} )}
</Bundle> </Bundle>
@ -254,7 +300,16 @@ class Status extends ImmutablePureComponent {
} else { } else {
media = ( media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}> <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
{Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />} {Component => (
<Component
media={status.get('media_attachments')}
sensitive={status.get('sensitive')}
height={110}
onOpenMedia={this.props.onOpenMedia}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
/>
)}
</Bundle> </Bundle>
); );
} }
@ -264,6 +319,8 @@ class Status extends ImmutablePureComponent {
onOpenMedia={this.props.onOpenMedia} onOpenMedia={this.props.onOpenMedia}
card={status.get('card')} card={status.get('card')}
compact compact
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
/> />
); );
} }
@ -290,7 +347,7 @@ class Status extends ImmutablePureComponent {
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))}> <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))} ref={this.handleRef}>
{prepend} {prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}> <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}>

View File

@ -78,7 +78,11 @@ class StatusActionBar extends ImmutablePureComponent {
] ]
handleReplyClick = () => { handleReplyClick = () => {
if (me) {
this.props.onReply(this.props.status, this.context.router.history); this.props.onReply(this.props.status, this.context.router.history);
} else {
this._openInteractionDialog('reply');
}
} }
handleShareClick = () => { handleShareClick = () => {
@ -91,11 +95,23 @@ class StatusActionBar extends ImmutablePureComponent {
} }
handleFavouriteClick = () => { handleFavouriteClick = () => {
if (me) {
this.props.onFavourite(this.props.status); this.props.onFavourite(this.props.status);
} else {
this._openInteractionDialog('favourite');
}
} }
handleReblogClick = (e) => { handleReblogClick = e => {
if (me) {
this.props.onReblog(this.props.status, e); this.props.onReblog(this.props.status, e);
} else {
this._openInteractionDialog('reblog');
}
}
_openInteractionDialog = type => {
window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
} }
handleDeleteClick = () => { handleDeleteClick = () => {
@ -213,9 +229,9 @@ class StatusActionBar extends ImmutablePureComponent {
return ( return (
<div className='status__action-bar'> <div className='status__action-bar'>
<div className='status__action-bar__counter'><IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div> <div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
<IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> <IconButton className='status__action-bar-button' disabled={!publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{shareButton} {shareButton}
<div className='status__action-bar-dropdown'> <div className='status__action-bar-dropdown'>

View File

@ -7,6 +7,7 @@ import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales'; import { getLocale } from '../locales';
import Compose from '../features/standalone/compose'; import Compose from '../features/standalone/compose';
import initialState from '../initial_state'; import initialState from '../initial_state';
import { fetchCustomEmojis } from '../actions/custom_emojis';
const { localeData, messages } = getLocale(); const { localeData, messages } = getLocale();
addLocaleData(localeData); addLocaleData(localeData);
@ -17,6 +18,8 @@ if (initialState) {
store.dispatch(hydrateStore(initialState)); store.dispatch(hydrateStore(initialState));
} }
store.dispatch(fetchCustomEmojis());
export default class TimelineContainer extends React.PureComponent { export default class TimelineContainer extends React.PureComponent {
static propTypes = { static propTypes = {

View File

@ -132,7 +132,7 @@ class Header extends ImmutablePureComponent {
} else if (account.getIn(['relationship', 'blocking'])) { } else if (account.getIn(['relationship', 'blocking'])) {
actionBtn = ( actionBtn = (
<div className='account--action-button'> <div className='account--action-button'>
<IconButton size={26} icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} /> <IconButton size={26} icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />
</div> </div>
); );
} }

View File

@ -18,6 +18,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'blocks', 'items']), accountIds: state.getIn(['user_lists', 'blocks', 'items']),
hasMore: !!state.getIn(['user_lists', 'blocks', 'next']),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@ -29,6 +30,7 @@ class Blocks extends ImmutablePureComponent {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func, shouldUpdateScroll: PropTypes.func,
accountIds: ImmutablePropTypes.list, accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -41,7 +43,7 @@ class Blocks extends ImmutablePureComponent {
}, 300, { leading: true }); }, 300, { leading: true });
render () { render () {
const { intl, accountIds, shouldUpdateScroll } = this.props; const { intl, accountIds, shouldUpdateScroll, hasMore } = this.props;
if (!accountIds) { if (!accountIds) {
return ( return (
@ -59,6 +61,7 @@ class Blocks extends ImmutablePureComponent {
<ScrollableList <ScrollableList
scrollKey='blocks' scrollKey='blocks'
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
hasMore={hasMore}
shouldUpdateScroll={shouldUpdateScroll} shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
> >

View File

@ -181,7 +181,7 @@ class ComposeForm extends ImmutablePureComponent {
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`}> <div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`}>
<label> <label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span> <span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
<input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input' id='cw-spoiler-input' ref={this.setSpoilerText} /> <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} tabIndex={this.props.spoiler ? 0 : -1} type='text' className='spoiler-input__input' id='cw-spoiler-input' ref={this.setSpoilerText} />
</label> </label>
</div> </div>

View File

@ -214,7 +214,7 @@ class PrivacyDropdown extends React.PureComponent {
this.options = [ this.options = [
{ icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
{ icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) }, { icon: 'unlock', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
{ icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
{ icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
]; ];

View File

@ -107,9 +107,8 @@ class Upload extends ImmutablePureComponent {
<label> <label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span> <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
<input <textarea
placeholder={intl.formatMessage(messages.description)} placeholder={intl.formatMessage(messages.description)}
type='text'
value={description} value={description}
maxLength={420} maxLength={420}
onFocus={this.handleInputFocus} onFocus={this.handleInputFocus}

View File

@ -19,6 +19,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
domains: state.getIn(['domain_lists', 'blocks', 'items']), domains: state.getIn(['domain_lists', 'blocks', 'items']),
hasMore: !!state.getIn(['domain_lists', 'blocks', 'next']),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@ -29,6 +30,7 @@ class Blocks extends ImmutablePureComponent {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func, shouldUpdateScroll: PropTypes.func,
hasMore: PropTypes.bool,
domains: ImmutablePropTypes.orderedSet, domains: ImmutablePropTypes.orderedSet,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -42,7 +44,7 @@ class Blocks extends ImmutablePureComponent {
}, 300, { leading: true }); }, 300, { leading: true });
render () { render () {
const { intl, domains, shouldUpdateScroll } = this.props; const { intl, domains, shouldUpdateScroll, hasMore } = this.props;
if (!domains) { if (!domains) {
return ( return (
@ -60,6 +62,7 @@ class Blocks extends ImmutablePureComponent {
<ScrollableList <ScrollableList
scrollKey='domain_blocks' scrollKey='domain_blocks'
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
hasMore={hasMore}
shouldUpdateScroll={shouldUpdateScroll} shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
> >

View File

@ -18,6 +18,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'follow_requests', 'items']), accountIds: state.getIn(['user_lists', 'follow_requests', 'items']),
hasMore: !!state.getIn(['user_lists', 'follow_requests', 'next']),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@ -28,6 +29,7 @@ class FollowRequests extends ImmutablePureComponent {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func, shouldUpdateScroll: PropTypes.func,
hasMore: PropTypes.bool,
accountIds: ImmutablePropTypes.list, accountIds: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -41,7 +43,7 @@ class FollowRequests extends ImmutablePureComponent {
}, 300, { leading: true }); }, 300, { leading: true });
render () { render () {
const { intl, shouldUpdateScroll, accountIds } = this.props; const { intl, shouldUpdateScroll, accountIds, hasMore } = this.props;
if (!accountIds) { if (!accountIds) {
return ( return (
@ -59,6 +61,7 @@ class FollowRequests extends ImmutablePureComponent {
<ScrollableList <ScrollableList
scrollKey='follow_requests' scrollKey='follow_requests'
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
hasMore={hasMore}
shouldUpdateScroll={shouldUpdateScroll} shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
> >

View File

@ -1,10 +1,15 @@
import React from 'react'; 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 { injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle'; import Toggle from 'react-toggle';
import AsyncSelect from 'react-select/lib/Async'; import AsyncSelect from 'react-select/lib/Async';
const messages = defineMessages({
placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' },
noOptions: { id: 'hashtag.column_settings.select.no_options_message', defaultMessage: 'No suggestions found' },
});
export default @injectIntl export default @injectIntl
class ColumnSettings extends React.PureComponent { class ColumnSettings extends React.PureComponent {
@ -25,6 +30,7 @@ class ColumnSettings extends React.PureComponent {
tags (mode) { tags (mode) {
let tags = this.props.settings.getIn(['tags', mode]) || []; let tags = this.props.settings.getIn(['tags', mode]) || [];
if (tags.toJSON) { if (tags.toJSON) {
return tags.toJSON(); return tags.toJSON();
} else { } else {
@ -32,33 +38,36 @@ class ColumnSettings extends React.PureComponent {
} }
}; };
onSelect = (mode) => { onSelect = mode => value => this.props.onChange(['tags', mode], value);
return (value) => {
this.props.onChange(['tags', mode], value);
};
};
onToggle = () => { onToggle = () => {
if (this.state.open && this.hasTags()) { if (this.state.open && this.hasTags()) {
this.props.onChange('tags', {}); this.props.onChange('tags', {});
} }
this.setState({ open: !this.state.open }); this.setState({ open: !this.state.open });
}; };
noOptionsMessage = () => this.props.intl.formatMessage(messages.noOptions);
modeSelect (mode) { modeSelect (mode) {
return ( return (
<div className='column-settings__section'> <div className='column-settings__row'>
<span className='column-settings__section'>
{this.modeLabel(mode)} {this.modeLabel(mode)}
</span>
<AsyncSelect <AsyncSelect
isMulti isMulti
autoFocus autoFocus
value={this.tags(mode)} value={this.tags(mode)}
settings={this.props.settings}
settingPath={['tags', mode]}
onChange={this.onSelect(mode)} onChange={this.onSelect(mode)}
loadOptions={this.props.onLoad} loadOptions={this.props.onLoad}
classNamePrefix='column-settings__hashtag-select' className='column-select__container'
classNamePrefix='column-select'
name='tags' name='tags'
placeholder={this.props.intl.formatMessage(messages.placeholder)}
noOptionsMessage={this.noOptionsMessage}
/> />
</div> </div>
); );
@ -66,11 +75,15 @@ class ColumnSettings extends React.PureComponent {
modeLabel (mode) { modeLabel (mode) {
switch(mode) { switch(mode) {
case 'any': return <FormattedMessage id='hashtag.column_settings.tag_mode.any' defaultMessage='Any of these' />; case 'any':
case 'all': return <FormattedMessage id='hashtag.column_settings.tag_mode.all' defaultMessage='All of these' />; return <FormattedMessage id='hashtag.column_settings.tag_mode.any' defaultMessage='Any of these' />;
case 'none': return <FormattedMessage id='hashtag.column_settings.tag_mode.none' defaultMessage='None of these' />; case 'all':
} return <FormattedMessage id='hashtag.column_settings.tag_mode.all' defaultMessage='All of these' />;
case 'none':
return <FormattedMessage id='hashtag.column_settings.tag_mode.none' defaultMessage='None of these' />;
default:
return ''; return '';
}
}; };
render () { render () {
@ -78,23 +91,21 @@ class ColumnSettings extends React.PureComponent {
<div> <div>
<div className='column-settings__row'> <div className='column-settings__row'>
<div className='setting-toggle'> <div className='setting-toggle'>
<Toggle <Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} />
id='hashtag.column_settings.tag_toggle'
onChange={this.onToggle}
checked={this.state.open}
/>
<span className='setting-toggle__label'> <span className='setting-toggle__label'>
<FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' /> <FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' />
</span> </span>
</div> </div>
</div> </div>
{this.state.open &&
{this.state.open && (
<div className='column-settings__hashtags'> <div className='column-settings__hashtags'>
{this.modeSelect('any')} {this.modeSelect('any')}
{this.modeSelect('all')} {this.modeSelect('all')}
{this.modeSelect('none')} {this.modeSelect('none')}
</div> </div>
} )}
</div> </div>
); );
} }

View File

@ -41,15 +41,19 @@ class HashtagTimeline extends React.PureComponent {
title = () => { title = () => {
let title = [this.props.params.id]; let title = [this.props.params.id];
if (this.additionalFor('any')) { if (this.additionalFor('any')) {
title.push(' ', <FormattedMessage id='hashtag.column_header.tag_mode.any' values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />); title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />);
} }
if (this.additionalFor('all')) { if (this.additionalFor('all')) {
title.push(' ', <FormattedMessage id='hashtag.column_header.tag_mode.all' values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />); title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />);
} }
if (this.additionalFor('none')) { if (this.additionalFor('none')) {
title.push(' ', <FormattedMessage id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />); title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />);
} }
return title; return title;
} }
@ -77,9 +81,10 @@ class HashtagTimeline extends React.PureComponent {
let all = (tags.all || []).map(tag => tag.value); let all = (tags.all || []).map(tag => tag.value);
let none = (tags.none || []).map(tag => tag.value); let none = (tags.none || []).map(tag => tag.value);
[id, ...any].map((tag) => { [id, ...any].map(tag => {
this.disconnects.push(dispatch(connectHashtagStream(id, tag, (status) => { this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => {
let tags = status.tags.map(tag => tag.name); let tags = status.tags.map(tag => tag.name);
return all.filter(tag => tags.includes(tag)).length === all.length && return all.filter(tag => tags.includes(tag)).length === all.length &&
none.filter(tag => tags.includes(tag)).length === 0; none.filter(tag => tags.includes(tag)).length === 0;
}))); })));
@ -95,12 +100,14 @@ class HashtagTimeline extends React.PureComponent {
const { dispatch } = this.props; const { dispatch } = this.props;
const { id, tags } = this.props.params; const { id, tags } = this.props.params;
this._subscribe(dispatch, id, tags);
dispatch(expandHashtagTimeline(id, { tags })); dispatch(expandHashtagTimeline(id, { tags }));
} }
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
const { dispatch, params } = this.props; const { dispatch, params } = this.props;
const { id, tags } = nextProps.params; const { id, tags } = nextProps.params;
if (id !== params.id || !isEqual(tags, params.tags)) { if (id !== params.id || !isEqual(tags, params.tags)) {
this._unsubscribe(); this._unsubscribe();
this._subscribe(dispatch, id, tags); this._subscribe(dispatch, id, tags);

View File

@ -18,6 +18,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'mutes', 'items']), accountIds: state.getIn(['user_lists', 'mutes', 'items']),
hasMore: !!state.getIn(['user_lists', 'mutes', 'next']),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@ -28,6 +29,7 @@ class Mutes extends ImmutablePureComponent {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func, shouldUpdateScroll: PropTypes.func,
hasMore: PropTypes.bool,
accountIds: ImmutablePropTypes.list, accountIds: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -41,7 +43,7 @@ class Mutes extends ImmutablePureComponent {
}, 300, { leading: true }); }, 300, { leading: true });
render () { render () {
const { intl, shouldUpdateScroll, accountIds } = this.props; const { intl, shouldUpdateScroll, hasMore, accountIds } = this.props;
if (!accountIds) { if (!accountIds) {
return ( return (
@ -59,6 +61,7 @@ class Mutes extends ImmutablePureComponent {
<ScrollableList <ScrollableList
scrollKey='mutes' scrollKey='mutes'
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
hasMore={hasMore}
shouldUpdateScroll={shouldUpdateScroll} shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
> >

View File

@ -34,6 +34,10 @@ class Notification extends ImmutablePureComponent {
onToggleHidden: PropTypes.func.isRequired, onToggleHidden: PropTypes.func.isRequired,
status: PropTypes.option, status: PropTypes.option,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
getScrollPosition: PropTypes.func,
updateScrollBottom: PropTypes.func,
cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number,
}; };
handleMoveUp = () => { handleMoveUp = () => {
@ -128,6 +132,10 @@ class Notification extends ImmutablePureComponent {
onMoveDown={this.handleMoveDown} onMoveDown={this.handleMoveDown}
onMoveUp={this.handleMoveUp} onMoveUp={this.handleMoveUp}
contextType='notifications' contextType='notifications'
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
/> />
); );
} }
@ -148,7 +156,17 @@ class Notification extends ImmutablePureComponent {
</span> </span>
</div> </div>
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} /> <StatusContainer
id={notification.get('status')}
account={notification.get('account')}
muted
withDismiss
hidden={!!this.props.hidden}
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
/>
</div> </div>
</HotKeys> </HotKeys>
); );
@ -170,7 +188,17 @@ class Notification extends ImmutablePureComponent {
</span> </span>
</div> </div>
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} /> <StatusContainer
id={notification.get('status')}
account={notification.get('account')}
muted
withDismiss
hidden={this.props.hidden}
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
/>
</div> </div>
</HotKeys> </HotKeys>
); );

View File

@ -60,6 +60,8 @@ export default class Card extends React.PureComponent {
maxDescription: PropTypes.number, maxDescription: PropTypes.number,
onOpenMedia: PropTypes.func.isRequired, onOpenMedia: PropTypes.func.isRequired,
compact: PropTypes.bool, compact: PropTypes.bool,
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
}; };
static defaultProps = { static defaultProps = {
@ -68,7 +70,7 @@ export default class Card extends React.PureComponent {
}; };
state = { state = {
width: 280, width: this.props.defaultWidth || 280,
embedded: false, embedded: false,
}; };
@ -111,6 +113,7 @@ export default class Card extends React.PureComponent {
setRef = c => { setRef = c => {
if (c) { if (c) {
if (this.props.cacheWidth) this.props.cacheWidth(c.offsetWidth);
this.setState({ width: c.offsetWidth }); this.setState({ width: c.offsetWidth });
} }
} }

View File

@ -91,7 +91,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
} }
render () { render () {
const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status; const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
const intl = this.props.intl; const intl = this.props.intl;
const outerStyle = { boxSizing: 'border-box' }; const outerStyle = { boxSizing: 'border-box' };
const { compact } = this.props; const { compact } = this.props;

View File

@ -99,6 +99,7 @@ class Video extends React.PureComponent {
onCloseVideo: PropTypes.func, onCloseVideo: PropTypes.func,
detailed: PropTypes.bool, detailed: PropTypes.bool,
inline: PropTypes.bool, inline: PropTypes.bool,
cacheWidth: PropTypes.func,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -108,7 +109,7 @@ class Video extends React.PureComponent {
volume: 0.5, volume: 0.5,
paused: true, paused: true,
dragging: false, dragging: false,
containerWidth: false, containerWidth: this.props.width,
fullscreen: false, fullscreen: false,
hovered: false, hovered: false,
muted: false, muted: false,
@ -128,6 +129,7 @@ class Video extends React.PureComponent {
this.player = c; this.player = c;
if (c) { if (c) {
if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth);
this.setState({ this.setState({
containerWidth: c.offsetWidth, containerWidth: c.offsetWidth,
}); });

View File

@ -0,0 +1,13 @@
import ready from '../mastodon/ready';
ready(() => {
const image = document.querySelector('img');
image.addEventListener('mouseenter', () => {
image.src = '/oops.gif';
});
image.addEventListener('mouseleave', () => {
image.src = '/oops.png';
});
});

View File

@ -12,3 +12,58 @@
} }
} }
} }
.rich-formatting a,
.rich-formatting p a,
.rich-formatting li a,
.landing-page__short-description p a,
.status__content a,
.reply-indicator__content a {
color: lighten($ui-highlight-color, 12%);
text-decoration: underline;
&.mention {
text-decoration: none;
}
&.mention span {
text-decoration: underline;
&:hover,
&:focus,
&:active {
text-decoration: none;
}
}
&:hover,
&:focus,
&:active {
text-decoration: none;
}
&.status__content__spoiler-link {
color: $secondary-text-color;
text-decoration: none;
}
}
.status__content__read-more-button {
text-decoration: underline;
&:hover,
&:focus,
&:active {
text-decoration: none;
}
}
.getting-started__footer a {
text-decoration: underline;
&:hover,
&:focus,
&:active {
text-decoration: none;
}
}

View File

@ -41,3 +41,34 @@
font-size: 16px; font-size: 16px;
} }
} }
@mixin search-popout() {
background: $simple-background-color;
border-radius: 4px;
padding: 10px 14px;
padding-bottom: 14px;
margin-top: 10px;
color: $light-text-color;
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
h4 {
text-transform: uppercase;
color: $light-text-color;
font-size: 13px;
font-weight: 500;
margin-bottom: 10px;
}
li {
padding: 4px 0;
}
ul {
margin-bottom: 10px;
}
em {
font-weight: 500;
color: $inverted-text-color;
}
}

View File

@ -49,15 +49,9 @@ $small-breakpoint: 960px;
} }
} }
strong,
em { em {
display: inline;
margin: 0;
padding: 0;
font-weight: 700; font-weight: 700;
background: transparent;
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: lighten($darker-text-color, 10%); color: lighten($darker-text-color, 10%);
} }
@ -796,7 +790,7 @@ $small-breakpoint: 960px;
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: row-reverse; flex-direction: row-reverse;
flex-wrap: wrap; flex-wrap: nowrap;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
@ -846,14 +840,7 @@ $small-breakpoint: 960px;
} }
strong { strong {
display: inline; font-weight: 500;
margin: 0;
padding: 0;
font-weight: 700;
background: transparent;
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: lighten($darker-text-color, 10%); color: lighten($darker-text-color, 10%);
} }

View File

@ -100,6 +100,7 @@ body {
vertical-align: middle; vertical-align: middle;
margin: 20px; margin: 20px;
&__illustration {
img { img {
display: block; display: block;
max-width: 470px; max-width: 470px;
@ -107,6 +108,7 @@ body {
height: auto; height: auto;
margin-top: -120px; margin-top: -120px;
} }
}
h1 { h1 {
font-size: 20px; font-size: 20px;

View File

@ -476,7 +476,7 @@
opacity: 0; opacity: 0;
transition: opacity .1s ease; transition: opacity .1s ease;
input { textarea {
background: transparent; background: transparent;
color: $secondary-text-color; color: $secondary-text-color;
border: 0; border: 0;
@ -638,7 +638,6 @@
font-weight: 400; font-weight: 400;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: pre-wrap;
padding-top: 2px; padding-top: 2px;
color: $primary-text-color; color: $primary-text-color;
@ -662,6 +661,7 @@
p { p {
margin-bottom: 20px; margin-bottom: 20px;
white-space: pre-wrap;
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
@ -3056,14 +3056,41 @@ a.status-card.compact:hover {
display: block; display: block;
font-weight: 500; font-weight: 500;
margin-bottom: 10px; margin-bottom: 10px;
}
.column-settings__hashtag-select { .column-settings__hashtags {
.column-settings__row {
margin-bottom: 15px;
}
.column-select {
&__control { &__control {
@include search-input(); @include search-input();
} }
&__placeholder {
color: $dark-text-color;
padding-left: 2px;
font-size: 12px;
}
&__value-container {
padding-left: 6px;
}
&__multi-value { &__multi-value {
background: lighten($ui-base-color, 8%); background: lighten($ui-base-color, 8%);
&__remove {
cursor: pointer;
&:hover,
&:active,
&:focus {
background: lighten($ui-base-color, 12%);
color: lighten($darker-text-color, 4%);
}
}
} }
&__multi-value__label, &__multi-value__label,
@ -3071,9 +3098,42 @@ a.status-card.compact:hover {
color: $darker-text-color; color: $darker-text-color;
} }
&__indicator-separator, &__clear-indicator,
&__dropdown-indicator { &__dropdown-indicator {
display: none; cursor: pointer;
transition: none;
color: $dark-text-color;
&:hover,
&:active,
&:focus {
color: lighten($dark-text-color, 4%);
}
}
&__indicator-separator {
background-color: lighten($ui-base-color, 8%);
}
&__menu {
@include search-popout();
padding: 0;
background: $ui-secondary-color;
}
&__menu-list {
padding: 6px;
}
&__option {
color: $inverted-text-color;
border-radius: 4px;
font-size: 14px;
&--is-focused,
&--is-selected {
background: darken($ui-secondary-color, 10%);
}
} }
} }
} }
@ -4867,34 +4927,7 @@ a.status-card.compact:hover {
} }
.search-popout { .search-popout {
background: $simple-background-color; @include search-popout();
border-radius: 4px;
padding: 10px 14px;
padding-bottom: 14px;
margin-top: 10px;
color: $light-text-color;
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
h4 {
text-transform: uppercase;
color: $light-text-color;
font-size: 13px;
font-weight: 500;
margin-bottom: 10px;
}
li {
padding: 4px 0;
}
ul {
margin-bottom: 10px;
}
em {
font-weight: 500;
color: $inverted-text-color;
}
} }
noscript { noscript {

View File

@ -4,6 +4,8 @@ class ActivityTracker
EXPIRE_AFTER = 90.days.seconds EXPIRE_AFTER = 90.days.seconds
class << self class << self
include Redisable
def increment(prefix) def increment(prefix)
key = [prefix, current_week].join(':') key = [prefix, current_week].join(':')
@ -20,10 +22,6 @@ class ActivityTracker
private private
def redis
Redis.current
end
def current_week def current_week
Time.zone.today.cweek Time.zone.today.cweek
end end

View File

@ -2,6 +2,10 @@
class ActivityPub::Activity class ActivityPub::Activity
include JsonLdHelper include JsonLdHelper
include Redisable
SUPPORTED_TYPES = %w(Note).freeze
CONVERTED_TYPES = %w(Image Video Article Page).freeze
def initialize(json, account, **options) def initialize(json, account, **options)
@json = json @json = json
@ -70,8 +74,16 @@ class ActivityPub::Activity
@object_uri ||= value_or_id(@object) @object_uri ||= value_or_id(@object)
end end
def redis def unsupported_object_type?
Redis.current @object.is_a?(String) || !(supported_object_type? || converted_object_type?)
end
def supported_object_type?
equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
end
def converted_object_type?
equals_or_includes_any?(@object['type'], CONVERTED_TYPES)
end end
def distribute(status) def distribute(status)
@ -123,6 +135,24 @@ class ActivityPub::Activity
redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri) redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri)
end end
def status_from_object
# If the status is already known, return it
status = status_from_uri(object_uri)
return status unless status.nil?
# If the boosted toot is embedded and it is a self-boost, handle it like a Create
unless unsupported_object_type?
actor_id = value_or_id(first_of_value(@object['attributedTo'])) || @account.uri
if actor_id == @account.uri
return ActivityPub::Activity.factory({ 'type' => 'Create', 'actor' => actor_id, 'object' => @object }, @account).perform
end
end
fetch_remote_original_status
end
def fetch_remote_original_status def fetch_remote_original_status
if object_uri.start_with?('http') if object_uri.start_with?('http')
return if ActivityPub::TagManager.instance.local_uri?(object_uri) return if ActivityPub::TagManager.instance.local_uri?(object_uri)
@ -137,4 +167,21 @@ class ActivityPub::Activity
ensure ensure
redis.del(key) redis.del(key)
end end
def fetch?
!@options[:delivery]
end
def followed_by_local_accounts?
@account.passive_relationships.exists?
end
def requested_through_relay?
@options[:relayed_through_account] && Relay.find_by(inbox_url: @options[:relayed_through_account].inbox_url)&.enabled?
end
def reject_payload!
Rails.logger.info("Rejected #{@json['type']} activity #{@json['id']} from #{@account.uri}#{@options[:relayed_through_account] && "via #{@options[:relayed_through_account].uri}"}")
nil
end
end end

View File

@ -2,10 +2,11 @@
class ActivityPub::Activity::Announce < ActivityPub::Activity class ActivityPub::Activity::Announce < ActivityPub::Activity
def perform def perform
original_status = status_from_uri(object_uri) return reject_payload! if delete_arrived_first?(@json['id']) || !related_to_local_activity?
original_status ||= fetch_remote_original_status
return if original_status.nil? || delete_arrived_first?(@json['id']) || !announceable?(original_status) original_status = status_from_object
return reject_payload! if original_status.nil? || !announceable?(original_status)
status = Status.find_by(account: @account, reblog: original_status) status = Status.find_by(account: @account, reblog: original_status)
@ -41,4 +42,12 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
def announceable?(status) def announceable?(status)
status.account_id == @account.id || status.public_visibility? || status.unlisted_visibility? status.account_id == @account.id || status.public_visibility? || status.unlisted_visibility?
end end
def related_to_local_activity?
followed_by_local_accounts? || requested_through_relay? || reblog_of_local_status?
end
def reblog_of_local_status?
status_from_uri(object_uri)&.account&.local?
end
end end

View File

@ -1,12 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::Activity::Create < ActivityPub::Activity class ActivityPub::Activity::Create < ActivityPub::Activity
SUPPORTED_TYPES = %w(Note).freeze
CONVERTED_TYPES = %w(Image Video Article Page).freeze
def perform def perform
return if unsupported_object_type? || invalid_origin?(@object['id']) return reject_payload! if unsupported_object_type? || invalid_origin?(@object['id']) || Tombstone.exists?(uri: @object['id']) || !related_to_local_activity?
return if Tombstone.exists?(uri: @object['id'])
RedisLock.acquire(lock_options) do |lock| RedisLock.acquire(lock_options) do |lock|
if lock.acquired? if lock.acquired?
@ -318,22 +314,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty? @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty?
end end
def unsupported_object_type?
@object.is_a?(String) || !(supported_object_type? || converted_object_type?)
end
def unsupported_media_type?(mime_type) def unsupported_media_type?(mime_type)
mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type) mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
end end
def supported_object_type?
equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
end
def converted_object_type?
equals_or_includes_any?(@object['type'], CONVERTED_TYPES)
end
def skip_download? def skip_download?
return @skip_download if defined?(@skip_download) return @skip_download if defined?(@skip_download)
@skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media? @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
@ -352,6 +336,25 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
!replied_to_status.nil? && replied_to_status.account.local? !replied_to_status.nil? && replied_to_status.account.local?
end end
def related_to_local_activity?
fetch? || followed_by_local_accounts? || requested_through_relay? ||
responds_to_followed_account? || addresses_local_accounts?
end
def responds_to_followed_account?
!replied_to_status.nil? && (replied_to_status.account.local? || replied_to_status.account.passive_relationships.exists?)
end
def addresses_local_accounts?
return true if @options[:delivered_to_account_id]
local_usernames = (as_array(@object['to']) + as_array(@object['cc'])).uniq.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }.map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) }
return false if local_usernames.empty?
Account.local.where(username: local_usernames).exists?
end
def forward_for_reply def forward_for_reply
return unless @json['signature'].present? && reply_to_local? return unless @json['signature'].present? && reply_to_local?
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url]) ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url])

View File

@ -4,6 +4,7 @@ require 'singleton'
class FeedManager class FeedManager
include Singleton include Singleton
include Redisable
MAX_ITEMS = 400 MAX_ITEMS = 400
@ -35,7 +36,7 @@ class FeedManager
def unpush_from_home(account, status) def unpush_from_home(account, status)
return false unless remove_from_feed(:home, account.id, status) return false unless remove_from_feed(:home, account.id, status)
Redis.current.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
true true
end end
@ -53,7 +54,7 @@ class FeedManager
def unpush_from_list(list, status) def unpush_from_list(list, status)
return false unless remove_from_feed(:list, list.id, status) return false unless remove_from_feed(:list, list.id, status)
Redis.current.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
true true
end end
@ -142,10 +143,6 @@ class FeedManager
private private
def redis
Redis.current
end
def push_update_required?(timeline_id) def push_update_required?(timeline_id)
redis.exists("subscribed:#{timeline_id}") redis.exists("subscribed:#{timeline_id}")
end end

View File

@ -99,7 +99,7 @@ class Formatter
end end
def encode_and_link_urls(html, accounts = nil, options = {}) def encode_and_link_urls(html, accounts = nil, options = {})
entities = Extractor.extract_entities_with_indices(html, extract_url_without_protocol: false) entities = utf8_friendly_extractor(html, extract_url_without_protocol: false)
if accounts.is_a?(Hash) if accounts.is_a?(Hash)
options = accounts options = accounts
@ -199,6 +199,53 @@ class Formatter
result.flatten.join result.flatten.join
end end
UNICODE_ESCAPE_BLACKLIST_RE = /\p{Z}|\p{P}/
def utf8_friendly_extractor(text, options = {})
old_to_new_index = [0]
escaped = text.chars.map do |c|
output = begin
if c.ord.to_s(16).length > 2 && UNICODE_ESCAPE_BLACKLIST_RE.match(c).nil?
CGI.escape(c)
else
c
end
end
old_to_new_index << old_to_new_index.last + output.length
output
end.join
# Note: I couldn't obtain list_slug with @user/list-name format
# for mention so this requires additional check
special = Extractor.extract_urls_with_indices(escaped, options).map do |extract|
# exactly one of :url, :hashtag, :screen_name, :cashtag keys is present
key = (extract.keys & [:url, :hashtag, :screen_name, :cashtag]).first
new_indices = [
old_to_new_index.find_index(extract[:indices].first),
old_to_new_index.find_index(extract[:indices].last),
]
has_prefix_char = [:hashtag, :screen_name, :cashtag].include?(key)
value_indices = [
new_indices.first + (has_prefix_char ? 1 : 0), # account for #, @ or $
new_indices.last - 1,
]
next extract.merge(
:indices => new_indices,
key => text[value_indices.first..value_indices.last]
)
end
standard = Extractor.extract_entities_with_indices(text, options)
Extractor.remove_overlapping_entities(special + standard)
end
def link_to_url(entity, options = {}) def link_to_url(entity, options = {})
url = Addressable::URI.parse(entity[:url]) url = Addressable::URI.parse(entity[:url])
html_attrs = { target: '_blank', rel: 'nofollow noopener' } html_attrs = { target: '_blank', rel: 'nofollow noopener' }

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class OStatus::Activity::Base class OStatus::Activity::Base
include Redisable
def initialize(xml, account = nil, **options) def initialize(xml, account = nil, **options)
@xml = xml @xml = xml
@account = account @account = account
@ -66,8 +68,4 @@ class OStatus::Activity::Base
Status.find_by(uri: uri) Status.find_by(uri: uri)
end end
end end
def redis
Redis.current
end
end end

View File

@ -11,6 +11,8 @@ class PotentialFriendshipTracker
}.freeze }.freeze
class << self class << self
include Redisable
def record(account_id, target_account_id, action) def record(account_id, target_account_id, action)
return if account_id == target_account_id return if account_id == target_account_id
@ -31,11 +33,5 @@ class PotentialFriendshipTracker
return [] if account_ids.empty? return [] if account_ids.empty?
Account.searchable.where(id: account_ids) Account.searchable.where(id: account_ids)
end end
private
def redis
Redis.current
end
end end
end end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
module Redisable
extend ActiveSupport::Concern
private
def redis
Redis.current
end
end

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Feed class Feed
include Redisable
def initialize(type, id) def initialize(type, id)
@type = type @type = type
@id = id @id = id
@ -27,8 +29,4 @@ class Feed
def key def key
FeedManager.instance.key(@type, @id) FeedManager.instance.key(@type, @id)
end end
def redis
Redis.current
end
end end

View File

@ -29,6 +29,7 @@ class Relay < ApplicationRecord
payload = Oj.dump(follow_activity(activity_id)) payload = Oj.dump(follow_activity(activity_id))
update!(state: :pending, follow_activity_id: activity_id) update!(state: :pending, follow_activity_id: activity_id)
DeliveryFailureTracker.new(inbox_url).track_success!
ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url) ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url)
end end
@ -37,6 +38,7 @@ class Relay < ApplicationRecord
payload = Oj.dump(unfollow_activity(activity_id)) payload = Oj.dump(unfollow_activity(activity_id))
update!(state: :idle, follow_activity_id: nil) update!(state: :idle, follow_activity_id: nil)
DeliveryFailureTracker.new(inbox_url).track_success!
ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url) ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url)
end end

View File

@ -7,6 +7,8 @@ class TrendingTags
THRESHOLD = 5 THRESHOLD = 5
class << self class << self
include Redisable
def record_use!(tag, account, at_time = Time.now.utc) def record_use!(tag, account, at_time = Time.now.utc)
return if disallowed_hashtags.include?(tag.name) || account.silenced? || account.bot? return if disallowed_hashtags.include?(tag.name) || account.silenced? || account.bot?
@ -59,9 +61,5 @@ class TrendingTags
@disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String @disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String
@disallowed_hashtags = @disallowed_hashtags.map(&:downcase) @disallowed_hashtags = @disallowed_hashtags.map(&:downcase)
end end
def redis
Redis.current
end
end end
end end

View File

@ -3,8 +3,8 @@
class ActivityPub::ActivitySerializer < ActiveModel::Serializer class ActivityPub::ActivitySerializer < ActiveModel::Serializer
attributes :id, :type, :actor, :published, :to, :cc attributes :id, :type, :actor, :published, :to, :cc
has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, unless: :announce? has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, unless: :owned_announce?
attribute :proper_uri, key: :object, if: :announce? attribute :proper_uri, key: :object, if: :owned_announce?
attribute :atom_uri, if: :announce? attribute :atom_uri, if: :announce?
def id def id
@ -42,4 +42,8 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer
def announce? def announce?
object.reblog? object.reblog?
end end
def owned_announce?
announce? && object.account == object.proper.account && object.proper.private_visibility?
end
end end

View File

@ -2,7 +2,7 @@
class REST::ApplicationSerializer < ActiveModel::Serializer class REST::ApplicationSerializer < ActiveModel::Serializer
attributes :id, :name, :website, :redirect_uri, attributes :id, :name, :website, :redirect_uri,
:client_id, :client_secret :client_id, :client_secret, :vapid_key
def id def id
object.id.to_s object.id.to_s
@ -19,4 +19,8 @@ class REST::ApplicationSerializer < ActiveModel::Serializer
def website def website
object.website.presence object.website.presence
end end
def vapid_key
Rails.configuration.x.vapid_public_key
end
end end

View File

@ -5,7 +5,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
attributes :uri, :title, :description, :email, attributes :uri, :title, :description, :email,
:version, :urls, :stats, :thumbnail, :version, :urls, :stats, :thumbnail,
:languages :languages, :registrations
has_one :contact_account, serializer: REST::AccountSerializer has_one :contact_account, serializer: REST::AccountSerializer
@ -51,6 +51,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
[I18n.default_locale] [I18n.default_locale]
end end
def registrations
Setting.open_registrations && !Rails.configuration.x.single_user_mode
end
private private
def instance_presenter def instance_presenter

View File

@ -212,7 +212,7 @@ class ActivityPub::ProcessAccountService < BaseService
end end
def clear_tombstones! def clear_tombstones!
Tombstone.delete_all(account_id: @account.id) Tombstone.where(account_id: @account.id).delete_all
end end
def protocol_changed? def protocol_changed?

View File

@ -44,6 +44,7 @@ class ActivityPub::ProcessCollectionService < BaseService
end end
def verify_account! def verify_account!
@options[:relayed_through_account] = @account
@account = ActivityPub::LinkedDataSignature.new(@json).verify_account! @account = ActivityPub::LinkedDataSignature.new(@json).verify_account!
rescue JSON::LD::JsonLdError => e rescue JSON::LD::JsonLdError => e
Rails.logger.debug "Could not verify LD-Signature for #{value_or_id(@json['actor'])}: #{e.message}" Rails.logger.debug "Could not verify LD-Signature for #{value_or_id(@json['actor'])}: #{e.message}"

View File

@ -2,6 +2,7 @@
class BatchedRemoveStatusService < BaseService class BatchedRemoveStatusService < BaseService
include StreamEntryRenderer include StreamEntryRenderer
include Redisable
# Delete given statuses and reblogs of them # Delete given statuses and reblogs of them
# Dispatch PuSH updates of the deleted statuses, but only local ones # Dispatch PuSH updates of the deleted statuses, but only local ones
@ -109,10 +110,6 @@ class BatchedRemoveStatusService < BaseService
end end
end end
def redis
Redis.current
end
def build_xml(stream_entry) def build_xml(stream_entry)
return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id) return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id)

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class FollowService < BaseService class FollowService < BaseService
include Redisable
# Follow a remote user, notify remote user about the follow # Follow a remote user, notify remote user about the follow
# @param [Account] source_account From which to follow # @param [Account] source_account From which to follow
# @param [String, Account] uri User URI to follow in the form of username@domain (or account record) # @param [String, Account] uri User URI to follow in the form of username@domain (or account record)
@ -67,10 +69,6 @@ class FollowService < BaseService
follow follow
end end
def redis
Redis.current
end
def build_follow_request_xml(follow_request) def build_follow_request_xml(follow_request)
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_request_salmon(follow_request)) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_request_salmon(follow_request))
end end

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class PostStatusService < BaseService class PostStatusService < BaseService
include Redisable
MIN_SCHEDULE_OFFSET = 5.minutes.freeze MIN_SCHEDULE_OFFSET = 5.minutes.freeze
# Post a text status update, fetch and notify remote users mentioned # Post a text status update, fetch and notify remote users mentioned
@ -119,10 +121,6 @@ class PostStatusService < BaseService
ProcessHashtagsService.new ProcessHashtagsService.new
end end
def redis
Redis.current
end
def scheduled? def scheduled?
@scheduled_at.present? @scheduled_at.present?
end end

View File

@ -2,6 +2,7 @@
class RemoveStatusService < BaseService class RemoveStatusService < BaseService
include StreamEntryRenderer include StreamEntryRenderer
include Redisable
def call(status, **options) def call(status, **options)
@payload = Oj.dump(event: :delete, payload: status.id.to_s) @payload = Oj.dump(event: :delete, payload: status.id.to_s)
@ -55,7 +56,7 @@ class RemoveStatusService < BaseService
def remove_from_affected def remove_from_affected
@mentions.map(&:account).select(&:local?).each do |account| @mentions.map(&:account).select(&:local?).each do |account|
Redis.current.publish("timeline:#{account.id}", @payload) redis.publish("timeline:#{account.id}", @payload)
end end
end end
@ -133,26 +134,22 @@ class RemoveStatusService < BaseService
return unless @status.public_visibility? return unless @status.public_visibility?
@tags.each do |hashtag| @tags.each do |hashtag|
Redis.current.publish("timeline:hashtag:#{hashtag}", @payload) redis.publish("timeline:hashtag:#{hashtag}", @payload)
Redis.current.publish("timeline:hashtag:#{hashtag}:local", @payload) if @status.local? redis.publish("timeline:hashtag:#{hashtag}:local", @payload) if @status.local?
end end
end end
def remove_from_public def remove_from_public
return unless @status.public_visibility? return unless @status.public_visibility?
Redis.current.publish('timeline:public', @payload) redis.publish('timeline:public', @payload)
Redis.current.publish('timeline:public:local', @payload) if @status.local? redis.publish('timeline:public:local', @payload) if @status.local?
end end
def remove_from_media def remove_from_media
return unless @status.public_visibility? return unless @status.public_visibility?
Redis.current.publish('timeline:public:media', @payload) redis.publish('timeline:public:media', @payload)
Redis.current.publish('timeline:public:local:media', @payload) if @status.local? redis.publish('timeline:public:local:media', @payload) if @status.local?
end
def redis
Redis.current
end end
end end

View File

@ -102,6 +102,10 @@ class SuspendAccountService < BaseService
ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url| ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url|
[delete_actor_json, @account.id, inbox_url] [delete_actor_json, @account.id, inbox_url]
end end
ActivityPub::LowPriorityDeliveryWorker.push_bulk(low_priority_delivery_inboxes) do |inbox_url|
[delete_actor_json, @account.id, inbox_url]
end
end end
def delete_actor_json def delete_actor_json
@ -117,7 +121,11 @@ class SuspendAccountService < BaseService
end end
def delivery_inboxes def delivery_inboxes
Account.inboxes + Relay.enabled.pluck(:inbox_url) @delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url)
end
def low_priority_delivery_inboxes
Account.inboxes - delivery_inboxes
end end
def associations_for_destruction def associations_for_destruction

View File

@ -24,6 +24,7 @@ class EmailMxValidator < ActiveModel::Validator
([domain] + hostnames).uniq.each do |hostname| ([domain] + hostnames).uniq.each do |hostname|
ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::A).to_a.map { |e| e.address.to_s }) ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::A).to_a.map { |e| e.address.to_s })
ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::AAAA).to_a.map { |e| e.address.to_s })
end end
end end

View File

@ -3,7 +3,7 @@
= simple_form_for @user, url: admin_account_change_email_path(@account.id) do |f| = simple_form_for @user, url: admin_account_change_email_path(@account.id) do |f|
.fields-group .fields-group
= f.input :email, wrapper: :with_label, disabled: true, label: t('admin.accounts.change_email.current_email') = f.input :email, wrapper: :with_label, hint: false, disabled: true, label: t('admin.accounts.change_email.current_email')
.fields-group .fields-group
= f.input :unconfirmed_email, wrapper: :with_label, label: t('admin.accounts.change_email.new_email') = f.input :unconfirmed_email, wrapper: :with_label, label: t('admin.accounts.change_email.new_email')

View File

@ -7,8 +7,11 @@
%meta{ content: 'width=device-width,initial-scale=1', name: 'viewport' }/ %meta{ content: 'width=device-width,initial-scale=1', name: 'viewport' }/
= stylesheet_pack_tag 'common', media: 'all' = stylesheet_pack_tag 'common', media: 'all'
= stylesheet_pack_tag Setting.default_settings['theme'], media: 'all' = stylesheet_pack_tag Setting.default_settings['theme'], media: 'all'
= javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous'
= javascript_pack_tag 'error', integrity: true, crossorigin: 'anonymous'
%body.error %body.error
.dialog .dialog
%img{ alt: Setting.default_settings['site_title'], src: '/oops.gif' }/ .dialog__illustration
%div %img{ alt: Setting.default_settings['site_title'], src: '/oops.png' }/
.dialog__message
%h1= yield :content %h1= yield :content

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class ActivityPub::LowPriorityDeliveryWorker < ActivityPub::DeliveryWorker
sidekiq_options queue: 'pull', retry: 8, dead: false
end

View File

@ -6,6 +6,6 @@ class ActivityPub::ProcessingWorker
sidekiq_options backtrace: true sidekiq_options backtrace: true
def perform(account_id, body, delivered_to_account_id = nil) def perform(account_id, body, delivered_to_account_id = nil)
ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true, delivered_to_account_id: delivered_to_account_id) ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true, delivered_to_account_id: delivered_to_account_id, delivery: true)
end end
end end

View File

@ -2,6 +2,7 @@
class Scheduler::FeedCleanupScheduler class Scheduler::FeedCleanupScheduler
include Sidekiq::Worker include Sidekiq::Worker
include Redisable
sidekiq_options unique: :until_executed, retry: 0 sidekiq_options unique: :until_executed, retry: 0
@ -57,8 +58,4 @@ class Scheduler::FeedCleanupScheduler
def feed_manager def feed_manager
FeedManager.instance FeedManager.instance
end end
def redis
Redis.current
end
end end

View File

@ -46,14 +46,14 @@ class Rack::Attack
end end
throttle('throttle_authenticated_api', limit: 300, period: 5.minutes) do |req| throttle('throttle_authenticated_api', limit: 300, period: 5.minutes) do |req|
req.api_request? && req.authenticated_user_id req.authenticated_user_id if req.api_request?
end end
throttle('throttle_unauthenticated_api', limit: 7_500, period: 5.minutes) do |req| throttle('throttle_unauthenticated_api', limit: 7_500, period: 5.minutes) do |req|
req.ip if req.api_request? req.ip if req.api_request?
end end
throttle('throttle_media', limit: 30, period: 30.minutes) do |req| throttle('throttle_api_media', limit: 30, period: 30.minutes) do |req|
req.authenticated_user_id if req.post? && req.path.start_with?('/api/v1/media') req.authenticated_user_id if req.post? && req.path.start_with?('/api/v1/media')
end end
@ -61,6 +61,13 @@ class Rack::Attack
req.ip if req.post? && req.path == '/api/v1/accounts' req.ip if req.post? && req.path == '/api/v1/accounts'
end end
API_DELETE_REBLOG_REGEX = /\A\/api\/v1\/statuses\/[\d]+\/unreblog/.freeze
API_DELETE_STATUS_REGEX = /\A\/api\/v1\/statuses\/[\d]+/.freeze
throttle('throttle_api_delete', limit: 30, period: 30.minutes) do |req|
req.authenticated_user_id if (req.post? && req.path =~ API_DELETE_REBLOG_REGEX) || (req.delete? && req.path =~ API_DELETE_STATUS_REGEX)
end
throttle('protected_paths', limit: 25, period: 5.minutes) do |req| throttle('protected_paths', limit: 25, period: 5.minutes) do |req|
req.ip if req.post? && req.path =~ PROTECTED_PATHS_REGEX req.ip if req.post? && req.path =~ PROTECTED_PATHS_REGEX
end end

View File

@ -1,7 +1,7 @@
module Twitter module Twitter
class Regex class Regex
REGEXEN[:valid_general_url_path_chars] = /[^\p{White_Space}\(\)\?]/iou REGEXEN[:valid_general_url_path_chars] = /[^\p{White_Space}<>\(\)\?]/iou
REGEXEN[:valid_url_path_ending_chars] = /[^\p{White_Space}\(\)\?!\*';:=\,\.\$%\[\]~&\|@]|(?:#{REGEXEN[:valid_url_balanced_parens]})/iou REGEXEN[:valid_url_path_ending_chars] = /[^\p{White_Space}\(\)\?!\*"'「」<>;:=\,\.\$%\[\]~&\|@]|(?:#{REGEXEN[:valid_url_balanced_parens]})/iou
REGEXEN[:valid_url_balanced_parens] = / REGEXEN[:valid_url_balanced_parens] = /
\( \(
(?: (?:

View File

@ -930,9 +930,9 @@ en:
<p>Originally adapted from the <a href="https://github.com/discourse/discourse">Discourse privacy policy</a>.</p> <p>Originally adapted from the <a href="https://github.com/discourse/discourse">Discourse privacy policy</a>.</p>
title: "%{instance} Terms of Service and Privacy Policy" title: "%{instance} Terms of Service and Privacy Policy"
themes: themes:
contrast: High contrast contrast: Mastodon (High contrast)
default: Mastodon default: Mastodon (Dark)
mastodon-light: Mastodon (light) mastodon-light: Mastodon (Light)
time: time:
formats: formats:
default: "%b %d, %Y, %H:%M" default: "%b %d, %Y, %H:%M"

View File

@ -9,7 +9,7 @@ WorkingDirectory=/home/mastodon/live
Environment="NODE_ENV=production" Environment="NODE_ENV=production"
Environment="PORT=4000" Environment="PORT=4000"
Environment="STREAMING_CLUSTER_NUM=1" Environment="STREAMING_CLUSTER_NUM=1"
ExecStart=/usr/bin/npm run start ExecStart=/usr/bin/node ./streaming
TimeoutSec=15 TimeoutSec=15
Restart=always Restart=always

View File

@ -13,7 +13,7 @@ module Mastodon
end end
def patch def patch
1 2
end end
def pre def pre

BIN
public/oops.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -1,5 +1,4 @@
# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines: User-agent: *
# User-agent: * Disallow: /media_proxy/
# Disallow: /

View File

@ -1,7 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe ActivityPub::Activity::Announce do RSpec.describe ActivityPub::Activity::Announce do
let(:sender) { Fabricate(:account) } let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers', uri: 'https://example.com/actor') }
let(:recipient) { Fabricate(:account) } let(:recipient) { Fabricate(:account) }
let(:status) { Fabricate(:status, account: recipient) } let(:status) { Fabricate(:status, account: recipient) }
@ -10,20 +10,162 @@ RSpec.describe ActivityPub::Activity::Announce do
'@context': 'https://www.w3.org/ns/activitystreams', '@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo', id: 'foo',
type: 'Announce', type: 'Announce',
actor: ActivityPub::TagManager.instance.uri_for(sender), actor: 'https://example.com/actor',
object: ActivityPub::TagManager.instance.uri_for(status), object: object_json,
}.with_indifferent_access }.with_indifferent_access
end end
describe '#perform' do let(:unknown_object_json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'https://example.com/actor/hello-world',
type: 'Note',
attributedTo: 'https://example.com/actor',
content: 'Hello world',
to: 'http://example.com/followers',
}
end
subject { described_class.new(json, sender) } subject { described_class.new(json, sender) }
describe '#perform' do
context 'when sender is followed by a local account' do
before do before do
Fabricate(:account).follow!(sender)
stub_request(:get, 'https://example.com/actor/hello-world').to_return(body: Oj.dump(unknown_object_json))
subject.perform subject.perform
end end
context 'a known status' do
let(:object_json) do
ActivityPub::TagManager.instance.uri_for(status)
end
it 'creates a reblog by sender of status' do it 'creates a reblog by sender of status' do
expect(sender.reblogged?(status)).to be true expect(sender.reblogged?(status)).to be true
end end
end end
context 'an unknown status' do
let(:object_json) { 'https://example.com/actor/hello-world' }
it 'creates a reblog by sender of status' do
reblog = sender.statuses.first
expect(reblog).to_not be_nil
expect(reblog.reblog.text).to eq 'Hello world'
end
end
context 'self-boost of a previously unknown status with missing attributedTo' do
let(:object_json) do
{
id: 'https://example.com/actor#bar',
type: 'Note',
content: 'Lorem ipsum',
to: 'http://example.com/followers',
}
end
it 'creates a reblog by sender of status' do
expect(sender.reblogged?(sender.statuses.first)).to be true
end
end
context 'self-boost of a previously unknown status with correct attributedTo' do
let(:object_json) do
{
id: 'https://example.com/actor#bar',
type: 'Note',
content: 'Lorem ipsum',
attributedTo: 'https://example.com/actor',
to: 'http://example.com/followers',
}
end
it 'creates a reblog by sender of status' do
expect(sender.reblogged?(sender.statuses.first)).to be true
end
end
end
context 'when the status belongs to a local user' do
before do
subject.perform
end
let(:object_json) do
ActivityPub::TagManager.instance.uri_for(status)
end
it 'creates a reblog by sender of status' do
expect(sender.reblogged?(status)).to be true
end
end
context 'when the sender is relayed' do
let!(:relay_account) { Fabricate(:account, inbox_url: 'https://relay.example.com/inbox') }
let!(:relay) { Fabricate(:relay, inbox_url: 'https://relay.example.com/inbox') }
subject { described_class.new(json, sender, relayed_through_account: relay_account) }
context 'and the relay is enabled' do
before do
relay.update(state: :accepted)
subject.perform
end
let(:object_json) do
{
id: 'https://example.com/actor#bar',
type: 'Note',
content: 'Lorem ipsum',
to: 'http://example.com/followers',
}
end
it 'creates a reblog by sender of status' do
expect(sender.statuses.count).to eq 2
end
end
context 'and the relay is disabled' do
before do
subject.perform
end
let(:object_json) do
{
id: 'https://example.com/actor#bar',
type: 'Note',
content: 'Lorem ipsum',
to: 'http://example.com/followers',
}
end
it 'does not create anything' do
expect(sender.statuses.count).to eq 0
end
end
end
context 'when the sender has no relevance to local activity' do
before do
subject.perform
end
let(:object_json) do
{
id: 'https://example.com/actor#bar',
type: 'Note',
content: 'Lorem ipsum',
to: 'http://example.com/followers',
}
end
it 'does not create anything' do
expect(sender.statuses.count).to eq 0
end
end
end
end end

View File

@ -13,8 +13,6 @@ RSpec.describe ActivityPub::Activity::Create do
}.with_indifferent_access }.with_indifferent_access
end end
subject { described_class.new(json, sender) }
before do before do
sender.update(uri: ActivityPub::TagManager.instance.uri_for(sender)) sender.update(uri: ActivityPub::TagManager.instance.uri_for(sender))
@ -23,6 +21,9 @@ RSpec.describe ActivityPub::Activity::Create do
end end
describe '#perform' do describe '#perform' do
context 'when fetching' do
subject { described_class.new(json, sender) }
before do before do
subject.perform subject.perform
end end
@ -407,4 +408,127 @@ RSpec.describe ActivityPub::Activity::Create do
end end
end end
end end
context 'when sender is followed by local users' do
subject { described_class.new(json, sender, delivery: true) }
before do
Fabricate(:account).follow!(sender)
subject.perform
end
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.text).to eq 'Lorem ipsum'
end
end
context 'when sender replies to local status' do
let!(:local_status) { Fabricate(:status) }
subject { described_class.new(json, sender, delivery: true) }
before do
subject.perform
end
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
inReplyTo: ActivityPub::TagManager.instance.uri_for(local_status),
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.text).to eq 'Lorem ipsum'
end
end
context 'when sender targets a local user' do
let!(:local_account) { Fabricate(:account) }
subject { described_class.new(json, sender, delivery: true) }
before do
subject.perform
end
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
to: ActivityPub::TagManager.instance.uri_for(local_account),
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.text).to eq 'Lorem ipsum'
end
end
context 'when sender cc\'s a local user' do
let!(:local_account) { Fabricate(:account) }
subject { described_class.new(json, sender, delivery: true) }
before do
subject.perform
end
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
cc: ActivityPub::TagManager.instance.uri_for(local_account),
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.text).to eq 'Lorem ipsum'
end
end
context 'when the sender has no relevance to local activity' do
subject { described_class.new(json, sender, delivery: true) }
before do
subject.perform
end
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
}
end
it 'does not create anything' do
expect(sender.statuses.count).to eq 0
end
end
end
end end

View File

@ -74,6 +74,7 @@ RSpec.describe Formatter do
end end
context 'given a URL with a query string' do context 'given a URL with a query string' do
context 'with escaped unicode character' do
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' } let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' }
it 'matches the full URL' do it 'matches the full URL' do
@ -81,6 +82,31 @@ RSpec.describe Formatter do
end end
end end
context 'with unicode character' do
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓&q=autolink' }
it 'matches the full URL' do
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓&amp;q=autolink"'
end
end
context 'with unicode character at the end' do
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓' }
it 'matches the full URL' do
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓"'
end
end
context 'with escaped and not escaped unicode characters' do
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&utf81=✓&q=autolink' }
it 'preserves escaped unicode characters' do
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&amp;utf81=✓&amp;q=autolink"'
end
end
end
context 'given a URL with parentheses in it' do context 'given a URL with parentheses in it' do
let(:text) { 'https://en.wikipedia.org/wiki/Diaspora_(software)' } let(:text) { 'https://en.wikipedia.org/wiki/Diaspora_(software)' }
@ -89,6 +115,22 @@ RSpec.describe Formatter do
end end
end end
context 'given a URL in quotation marks' do
let(:text) { '"https://example.com/"' }
it 'does not match the quotation marks' do
is_expected.to include 'href="https://example.com/"'
end
end
context 'given a URL in angle brackets' do
let(:text) { '<https://example.com/>' }
it 'does not match the angle brackets' do
is_expected.to include 'href="https://example.com/"'
end
end
context 'given a URL with Japanese path string' do context 'given a URL with Japanese path string' do
let(:text) { 'https://ja.wikipedia.org/wiki/日本' } let(:text) { 'https://ja.wikipedia.org/wiki/日本' }
@ -105,6 +147,22 @@ RSpec.describe Formatter do
end end
end end
context 'given a URL with a full-width space' do
let(:text) { 'https://example.com/ abc123' }
it 'does not match the full-width space' do
is_expected.to include 'href="https://example.com/"'
end
end
context 'given a URL in Japanese quotation marks' do
let(:text) { '「[https://example.org/」' }
it 'does not match the quotation marks' do
is_expected.to include 'href="https://example.org/"'
end
end
context 'given a URL with Simplified Chinese path string' do context 'given a URL with Simplified Chinese path string' do
let(:text) { 'https://baike.baidu.com/item/中华人民共和国' } let(:text) { 'https://baike.baidu.com/item/中华人民共和国' }
@ -124,7 +182,11 @@ RSpec.describe Formatter do
context 'given a URL containing unsafe code (XSS attack, visible part)' do context 'given a URL containing unsafe code (XSS attack, visible part)' do
let(:text) { %q{http://example.com/b<del>b</del>} } let(:text) { %q{http://example.com/b<del>b</del>} }
it 'escapes the HTML in the URL' do it 'does not include the HTML in the URL' do
is_expected.to include '"http://example.com/b"'
end
it 'escapes the HTML' do
is_expected.to include '&lt;del&gt;b&lt;/del&gt;' is_expected.to include '&lt;del&gt;b&lt;/del&gt;'
end end
end end
@ -132,7 +194,11 @@ RSpec.describe Formatter do
context 'given a URL containing unsafe code (XSS attack, invisible part)' do context 'given a URL containing unsafe code (XSS attack, invisible part)' do
let(:text) { %q{http://example.com/blahblahblahblah/a<script>alert("Hello")</script>} } let(:text) { %q{http://example.com/blahblahblahblah/a<script>alert("Hello")</script>} }
it 'escapes the HTML in the URL' do it 'does not include the HTML in the URL' do
is_expected.to include '"http://example.com/blahblahblahblah/a"'
end
it 'escapes the HTML' do
is_expected.to include '&lt;script&gt;alert(&quot;Hello&quot;)&lt;/script&gt;' is_expected.to include '&lt;script&gt;alert(&quot;Hello&quot;)&lt;/script&gt;'
end end
end end
@ -168,6 +234,14 @@ RSpec.describe Formatter do
is_expected.to include '/tags/hashtag" class="mention hashtag" rel="tag">#<span>hashtag</span></a>' is_expected.to include '/tags/hashtag" class="mention hashtag" rel="tag">#<span>hashtag</span></a>'
end end
end end
context 'given text containing a hashtag with Unicode chars' do
let(:text) { '#hashtagタグ' }
it 'creates a hashtag link' do
is_expected.to include '/tags/hashtag%E3%82%BF%E3%82%B0" class="mention hashtag" rel="tag">#<span>hashtagタグ</span></a>'
end
end
end end
describe '#format_spoiler' do describe '#format_spoiler' do

View File

@ -11,6 +11,7 @@ describe EmailMxValidator do
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([])
allow(resolver).to receive(:timeouts=).and_return(nil) allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver) allow(Resolv::DNS).to receive(:open).and_yield(resolver)
@ -23,7 +24,9 @@ describe EmailMxValidator do
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([])
allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([]) allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([])
allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([])
allow(resolver).to receive(:timeouts=).and_return(nil) allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver) allow(Resolv::DNS).to receive(:open).and_yield(resolver)
@ -37,6 +40,21 @@ describe EmailMxValidator do
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([double(address: '1.2.3.4')]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([double(address: '1.2.3.4')])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([])
allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver)
subject.validate(user)
expect(user.errors).to have_received(:add)
end
it 'adds an error if the AAAA record is blacklisted' do
EmailDomainBlock.create!(domain: 'fd00::1')
resolver = double
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([double(address: 'fd00::1')])
allow(resolver).to receive(:timeouts=).and_return(nil) allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver) allow(Resolv::DNS).to receive(:open).and_yield(resolver)
@ -50,7 +68,25 @@ describe EmailMxValidator do
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([])
allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([double(address: '2.3.4.5')]) allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([double(address: '2.3.4.5')])
allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([])
allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver)
subject.validate(user)
expect(user.errors).to have_received(:add)
end
it 'adds an error if the MX IPv6 record is blacklisted' do
EmailDomainBlock.create!(domain: 'fd00::2')
resolver = double
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([])
allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([])
allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([double(address: 'fd00::2')])
allow(resolver).to receive(:timeouts=).and_return(nil) allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver) allow(Resolv::DNS).to receive(:open).and_yield(resolver)
@ -64,7 +100,9 @@ describe EmailMxValidator do
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([])
allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([double(address: '2.3.4.5')]) allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([double(address: '2.3.4.5')])
allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([double(address: 'fd00::2')])
allow(resolver).to receive(:timeouts=).and_return(nil) allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver) allow(Resolv::DNS).to receive(:open).and_yield(resolver)