Merge tag 'v2.8.0' into instance_only_statuses

This commit is contained in:
Renato "Lond" Cerqueira 2019-04-13 23:47:24 +02:00
commit 546581d2ba
689 changed files with 25483 additions and 9047 deletions

View File

@ -41,6 +41,11 @@ module.exports = {
'node_modules', 'node_modules',
'\\.(css|scss|json)$', '\\.(css|scss|json)$',
], ],
'import/resolver': {
node: {
paths: ['app/javascript'],
},
},
}, },
rules: { rules: {

View File

@ -80,7 +80,7 @@ Rails/HttpStatus:
Rails/Exit: Rails/Exit:
Exclude: Exclude:
- 'lib/mastodon/*' - 'lib/mastodon/*'
- 'lib/cli' - 'lib/cli.rb'
Style/ClassAndModuleChildren: Style/ClassAndModuleChildren:
Enabled: false Enabled: false

View File

@ -1 +1 @@
2.6.0 2.6.1

View File

@ -6,8 +6,8 @@ and provided thanks to the work of the following contributors:
* [Gargron](https://github.com/Gargron) * [Gargron](https://github.com/Gargron)
* [ykzts](https://github.com/ykzts) * [ykzts](https://github.com/ykzts)
* [akihikodaki](https://github.com/akihikodaki)
* [ThibG](https://github.com/ThibG) * [ThibG](https://github.com/ThibG)
* [akihikodaki](https://github.com/akihikodaki)
* [mjankowski](https://github.com/mjankowski) * [mjankowski](https://github.com/mjankowski)
* [dependabot[bot]](https://github.com/apps/dependabot) * [dependabot[bot]](https://github.com/apps/dependabot)
* [unarist](https://github.com/unarist) * [unarist](https://github.com/unarist)
@ -27,14 +27,14 @@ and provided thanks to the work of the following contributors:
* [blackle](https://github.com/blackle) * [blackle](https://github.com/blackle)
* [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)
* [Kjwon15](https://github.com/Kjwon15) * [Kjwon15](https://github.com/Kjwon15)
* [mabkenar](https://github.com/mabkenar)
* [nullkal](https://github.com/nullkal) * [nullkal](https://github.com/nullkal)
* [yookoala](https://github.com/yookoala) * [yookoala](https://github.com/yookoala)
* [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)
* [zunda](https://github.com/zunda) * [zunda](https://github.com/zunda)
* [Quenty31](https://github.com/Quenty31)
* [eramdam](https://github.com/eramdam) * [eramdam](https://github.com/eramdam)
* [takayamaki](https://github.com/takayamaki) * [takayamaki](https://github.com/takayamaki)
* [masarakki](https://github.com/masarakki) * [masarakki](https://github.com/masarakki)
@ -45,8 +45,8 @@ and provided thanks to the work of the following contributors:
* [stephenburgess8](https://github.com/stephenburgess8) * [stephenburgess8](https://github.com/stephenburgess8)
* [Wonderfall](https://github.com/Wonderfall) * [Wonderfall](https://github.com/Wonderfall)
* [matteoaquila](https://github.com/matteoaquila) * [matteoaquila](https://github.com/matteoaquila)
* [rkarabut](https://github.com/rkarabut)
* [yukimochi](https://github.com/yukimochi) * [yukimochi](https://github.com/yukimochi)
* [rkarabut](https://github.com/rkarabut)
* [Artoria2e5](https://github.com/Artoria2e5) * [Artoria2e5](https://github.com/Artoria2e5)
* [nightpool](https://github.com/nightpool) * [nightpool](https://github.com/nightpool)
* [marrus-sh](https://github.com/marrus-sh) * [marrus-sh](https://github.com/marrus-sh)
@ -64,11 +64,14 @@ and provided thanks to the work of the following contributors:
* [MaciekBaron](https://github.com/MaciekBaron) * [MaciekBaron](https://github.com/MaciekBaron)
* [MitarashiDango](mailto:mitarashidango@users.noreply.github.com) * [MitarashiDango](mailto:mitarashidango@users.noreply.github.com)
* [beatrix-bitrot](https://github.com/beatrix-bitrot) * [beatrix-bitrot](https://github.com/beatrix-bitrot)
* [Aditoo17](https://github.com/Aditoo17)
* [adbelle](https://github.com/adbelle) * [adbelle](https://github.com/adbelle)
* [evanminto](https://github.com/evanminto) * [evanminto](https://github.com/evanminto)
* [MightyPork](https://github.com/MightyPork) * [MightyPork](https://github.com/MightyPork)
* [yhirano55](https://github.com/yhirano55) * [yhirano55](https://github.com/yhirano55)
* [rinsuki](https://github.com/rinsuki)
* [camponez](https://github.com/camponez) * [camponez](https://github.com/camponez)
* [hinaloe](https://github.com/hinaloe)
* [SerCom-KC](https://github.com/SerCom-KC) * [SerCom-KC](https://github.com/SerCom-KC)
* [aschmitz](https://github.com/aschmitz) * [aschmitz](https://github.com/aschmitz)
* [devkral](https://github.com/devkral) * [devkral](https://github.com/devkral)
@ -81,10 +84,8 @@ and provided thanks to the work of the following contributors:
* [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)
@ -102,9 +103,10 @@ and provided thanks to the work of the following contributors:
* [victorhck](https://github.com/victorhck) * [victorhck](https://github.com/victorhck)
* [kedamaDQ](https://github.com/kedamaDQ) * [kedamaDQ](https://github.com/kedamaDQ)
* [puckipedia](https://github.com/puckipedia) * [puckipedia](https://github.com/puckipedia)
* [trwnh](https://github.com/trwnh)
* [fvh-P](https://github.com/fvh-P) * [fvh-P](https://github.com/fvh-P)
* [contraexemplo](https://github.com/contraexemplo) * [Anna e só](mailto:contraexemplos@gmail.com)
* [Aditoo17](https://github.com/Aditoo17) * [BenLubar](https://github.com/BenLubar)
* [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)
@ -117,7 +119,6 @@ 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)
* [trwnh](https://github.com/trwnh)
* [JMendyk](https://github.com/JMendyk) * [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)
@ -133,6 +134,7 @@ and provided thanks to the work of the following contributors:
* [Reverite](https://github.com/Reverite) * [Reverite](https://github.com/Reverite)
* [JohnD28](https://github.com/JohnD28) * [JohnD28](https://github.com/JohnD28)
* [znz](https://github.com/znz) * [znz](https://github.com/znz)
* [marek-lach](https://github.com/marek-lach)
* [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) * [rtucker](https://github.com/rtucker)
@ -150,7 +152,6 @@ and provided thanks to the work of the following contributors:
* [178inaba](https://github.com/178inaba) * [178inaba](https://github.com/178inaba)
* [alyssais](https://github.com/alyssais) * [alyssais](https://github.com/alyssais)
* [hiphref](https://github.com/hiphref) * [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)
@ -161,16 +162,16 @@ 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)
* [qguv](https://github.com/qguv) * [qguv](https://github.com/qguv)
* [Ram Lmn](mailto:ramlmn@users.noreply.github.com) * [Ram Lmn](mailto:ramlmn@users.noreply.github.com)
* [sascha-sl](https://github.com/sascha-sl)
* [harukasan](https://github.com/harukasan) * [harukasan](https://github.com/harukasan)
* [stamak](https://github.com/stamak) * [stamak](https://github.com/stamak)
* [Technowix](mailto:technowix@users.noreply.github.com) * [Technowix](mailto:technowix@users.noreply.github.com)
* [Eychics](https://github.com/Eychics) * [Zoeille](https://github.com/Zoeille)
* [Thor Harald Johansen](mailto:thj@thj.no) * [Thor Harald Johansen](mailto:thj@thj.no)
* [0x70b1a5](https://github.com/0x70b1a5) * [0x70b1a5](https://github.com/0x70b1a5)
* [gled-rs](https://github.com/gled-rs) * [gled-rs](https://github.com/gled-rs)
@ -244,9 +245,9 @@ and provided thanks to the work of the following contributors:
* [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) * [sts10](https://github.com/sts10)
* [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)
* [Sir-Boops](https://github.com/Sir-Boops)
* [stemid](https://github.com/stemid) * [stemid](https://github.com/stemid)
* [sumdog](https://github.com/sumdog) * [sumdog](https://github.com/sumdog)
* [ThomasLeister](https://github.com/ThomasLeister) * [ThomasLeister](https://github.com/ThomasLeister)
@ -316,8 +317,11 @@ and provided thanks to the work of the following contributors:
* [Andreas Drop](mailto:andy@remline.de) * [Andreas Drop](mailto:andy@remline.de)
* [andi1984](https://github.com/andi1984) * [andi1984](https://github.com/andi1984)
* [schas002](https://github.com/schas002) * [schas002](https://github.com/schas002)
* [contraexemplo](https://github.com/contraexemplo)
* [abackstrom](https://github.com/abackstrom) * [abackstrom](https://github.com/abackstrom)
* [armandfardeau](https://github.com/armandfardeau)
* [jumbosushi](https://github.com/jumbosushi) * [jumbosushi](https://github.com/jumbosushi)
* [aurelien-reeves](https://github.com/aurelien-reeves)
* [ayumin](https://github.com/ayumin) * [ayumin](https://github.com/ayumin)
* [BaptisteGelez](https://github.com/BaptisteGelez) * [BaptisteGelez](https://github.com/BaptisteGelez)
* [bzg](https://github.com/bzg) * [bzg](https://github.com/bzg)
@ -335,7 +339,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) * [clarfon](https://github.com/clarfon)
* [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)
@ -358,6 +362,7 @@ and provided thanks to the work of the following contributors:
* [eai04191](https://github.com/eai04191) * [eai04191](https://github.com/eai04191)
* [d3vgru](https://github.com/d3vgru) * [d3vgru](https://github.com/d3vgru)
* [Elizafox](https://github.com/Elizafox) * [Elizafox](https://github.com/Elizafox)
* [enewhuis](https://github.com/enewhuis)
* [ericblade](https://github.com/ericblade) * [ericblade](https://github.com/ericblade)
* [mikoim](https://github.com/mikoim) * [mikoim](https://github.com/mikoim)
* [espenronnevik](https://github.com/espenronnevik) * [espenronnevik](https://github.com/espenronnevik)
@ -446,6 +451,7 @@ and provided thanks to the work of the following contributors:
* [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)
* [nosada](https://github.com/nosada)
* [Nanamachi](https://github.com/Nanamachi) * [Nanamachi](https://github.com/Nanamachi)
* [orinthe](https://github.com/orinthe) * [orinthe](https://github.com/orinthe)
* [NecroTechno](https://github.com/NecroTechno) * [NecroTechno](https://github.com/NecroTechno)
@ -462,10 +468,11 @@ and provided thanks to the work of the following contributors:
* [noppa](https://github.com/noppa) * [noppa](https://github.com/noppa)
* [Otakan951](https://github.com/Otakan951) * [Otakan951](https://github.com/Otakan951)
* [fahy](https://github.com/fahy) * [fahy](https://github.com/fahy)
* [PatrickRWells](https://github.com/PatrickRWells) * [PatrickRWells](mailto:32802366+patrickrwells@users.noreply.github.com)
* [Pangoraw](https://github.com/Pangoraw) * [Paul](mailto:naydex.mc+github@gmail.com)
* [peterkeen](https://github.com/peterkeen) * [Pete Keen](mailto:pete@petekeen.net)
* [pgate](https://github.com/pgate) * [Pierre-Morgan Gate](mailto:pgate@users.noreply.github.com)
* [Ratmir Karabut](mailto:rkarabut@sfmodern.ru)
* [Reto Kromer](mailto:retokromer@users.noreply.github.com) * [Reto Kromer](mailto:retokromer@users.noreply.github.com)
* [Rey Tucker](mailto:git@reytucker.us) * [Rey Tucker](mailto:git@reytucker.us)
* [Rob Watson](mailto:rfwatson@users.noreply.github.com) * [Rob Watson](mailto:rfwatson@users.noreply.github.com)
@ -488,7 +495,6 @@ and provided thanks to the work of the following contributors:
* [Sho Kusano](mailto:rosylilly@aduca.org) * [Sho Kusano](mailto:rosylilly@aduca.org)
* [Shouko Yu](mailto:imshouko@gmail.com) * [Shouko Yu](mailto:imshouko@gmail.com)
* [Sina Mashek](mailto:sina@mashek.xyz) * [Sina Mashek](mailto:sina@mashek.xyz)
* [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) * [Stanislas](mailto:angristan@pm.me)
@ -555,12 +561,14 @@ and provided thanks to the work of the following contributors:
* [karlyeurl](mailto:karl.yeurl@gmail.com) * [karlyeurl](mailto:karl.yeurl@gmail.com)
* [kedama](mailto:32974885+kedamadq@users.noreply.github.com) * [kedama](mailto:32974885+kedamadq@users.noreply.github.com)
* [kodai](mailto:shirafuta.kodai@gmail.com) * [kodai](mailto:shirafuta.kodai@gmail.com)
* [koyu](mailto:me@koyu.space)
* [kuro5hin](mailto:rusty@kuro5hin.org) * [kuro5hin](mailto:rusty@kuro5hin.org)
* [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) * [mike castleman](mailto:m@mlcastle.net)
* [mimikun](mailto:dzdzble_effort_311@outlook.jp) * [mimikun](mailto:dzdzble_effort_311@outlook.jp)
* [mohemohe](mailto:mohemohe@users.noreply.github.com)
* [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)
@ -599,243 +607,338 @@ This document is provided for informational purposes only. Since it is only upda
Following people have contributed to translation of Mastodon: Following people have contributed to translation of Mastodon:
- **Albanian**
- Besnik Bleta
- Aditoo
- **Arabic** - **Arabic**
- ButterflyOfFire - ButterflyOfFire
- Aditoo
- Amrz0
- **Asturian** - **Asturian**
- ButterflyOfFire - ButterflyOfFire
- Enol P. - Enol P.
- Aditoo
- **Basque** - **Basque**
- Osoitz
- Aditoo
- Aitzol - Aitzol
- ButterflyOfFire - ButterflyOfFire
- Gorka Azkarate
- Osoitz
- Peru Iparragirre - Peru Iparragirre
- Gorka Azkarate
- **Bengali**
- dxwc
- **Bulgarian** - **Bulgarian**
- ButterflyOfFire - ButterflyOfFire
- Aditoo
- **Catalan** - **Catalan**
- spla
- Aditoo
- ButterflyOfFire - ButterflyOfFire
- Joan Montané - Joan Montané
- Jose Luis - Jose Luis
- spla
- **Chinese (Hong Kong)** - **Chinese (Hong Kong)**
- ButterflyOfFire - ButterflyOfFire
- Luzi Leung - Luzi Leung
- Aditoo
- **Chinese (Simplified)** - **Chinese (Simplified)**
- Allen Zhong - Allen Zhong
- ButterflyOfFire - ButterflyOfFire
- SerCom_KC - SerCom_KC
- martialarts
- Kaitian Xie
- Aditoo
- pan93412
- **Chinese (Traditional)** - **Chinese (Traditional)**
- Aditoo
- ButterflyOfFire - ButterflyOfFire
- James58899 - James58899
- Jeff Huang - pan93412
- S1ttidoe477 - S1ttidoe477
- SHA265 - SHA265
- Jeff Huang
- **Corsican** - **Corsican**
- Alix D. R. - Alix D. R.
- Aditoo
- ButterflyOfFire - ButterflyOfFire
- **Croatian** - **Croatian**
- ButterflyOfFire - ButterflyOfFire
- Aditoo
- **Czech** - **Czech**
- ButterflyOfFire - Aditoo
- Lorem Ipsum
- Marek Ľach - Marek Ľach
- ButterflyOfFire
- **Danish** - **Danish**
- ButterflyOfFire - Einhjeriar
- Rasmus Sæderup - Rasmus Sæderup
- **Dutch** - Aditoo
- ButterflyOfFire
- **Dutch**
- Albakham
- ButterflyOfFire - ButterflyOfFire
- Jelv
- jeroenpraat - jeroenpraat
- rscmbbng - rscmbbng
- Aditoo
- Jelv
- **English** - **English**
- ButterflyOfFire - ButterflyOfFire
- Renato "Lond" Cerqueira - Renato "Lond" Cerqueira
- **English (United Kingdom)**
- Albakham
- **Esperanto** - **Esperanto**
- Aditoo
- ButterflyOfFire - ButterflyOfFire
- Becci Cat
- Jeong Arm - Jeong Arm
- Martin Bodin
- Mélanie Chauvel - Mélanie Chauvel
- Vanege - Vanege
- Martin Bodin
- tuxayo/Victor Grousset - tuxayo/Victor Grousset
- **Finnish** - **Finnish**
- ButterflyOfFire - ButterflyOfFire
- Jonne Arjoranta - Mikko Poussu
- S Heija
- Taru Luojola - Taru Luojola
- S Heija
- Aditoo
- Jonne Arjoranta
- **French** - **French**
- Alda Marteau-Hardi - Albakham
- Alix D. R. - Alix D. R.
- Baptiste Jonglez
- ButterflyOfFire - ButterflyOfFire
- Franck Paul - codl
- Jean-Baptiste Holcroft - Leia
- Alda Marteau-Hardi
- Mélanie Chauvel
- Paul Marques Mota
- azenet
- Olivier Humbert
- Aditoo
- Jonathan Chan - Jonathan Chan
- Letiteuf55 - Letiteuf55
- Martin Bodin - Baptiste Jonglez
- Mélanie Chauvel - goofy-mdn
- Olivier Humbert - Jean-Baptiste Holcroft
- Paul Marques Mota
- Sylvhem
- Technowix - Technowix
- Thibaut Girka - Martin Bodin
- Théodore - Théodore
- azenet - Thibaut Girka
- codl - Franck Paul
- Sylvhem
- **Galician** - **Galician**
- ButterflyOfFire - ButterflyOfFire
- Xose M. - Xose M.
- Aditoo
- manequim - manequim
- **Georgian** - **Georgian**
- ButterflyOfFire - ButterflyOfFire
- Aditoo
- **German** - **German**
- Benedikt Geißler - Aditoo
- ButterflyOfFire - ButterflyOfFire
- Daniel - Daniel
- Eugen Rochko
- Koyu Berteon
- Patrick Figel
- Weblate Admin
- averageunicorn - averageunicorn
- ePirat - Koyu Berteon
- koyu
- larsreineke - larsreineke
- koyu
- Austin Jones
- lilo - lilo
- Benedikt Geißler
- ePirat
- Eugen Rochko
- Weblate Admin
- Patrick Figel
- **Greek** - **Greek**
- Antonis
- ButterflyOfFire
- Dimitris Maroulidis - Dimitris Maroulidis
- Antonis
- Aditoo
- ButterflyOfFire
- Konstantinos Grevenitis - Konstantinos Grevenitis
- **Hebrew** - **Hebrew**
- ButterflyOfFire - ButterflyOfFire
- Aditoo
- Ira - Ira
- Yaron Shahrabani - Yaron Shahrabani
- **Hungarian** - **Hungarian**
- Adam Paszternak
- ButterflyOfFire - ButterflyOfFire
- Adam Paszternak
- Aditoo
- Tibike Miklós - Tibike Miklós
- **Ido** - **Ido**
- ButterflyOfFire - ButterflyOfFire
- Aditoo
- **Indonesian** - **Indonesian**
- Alfiana Sibuea - afachri
- ButterflyOfFire - ButterflyOfFire
- Dito Kurnia Pratama - Dito Kurnia Pratama
- Eirworks - Eirworks
- afachri - Aditoo
- Alfiana Sibuea
- se7entime - se7entime
- **Irish**
- Albakham
- Kevin Houlihan
- **Italian** - **Italian**
- Alessandro Levati - Alessandro Levati
- Albakham
- ButterflyOfFire - ButterflyOfFire
- Marcin Mikołajczak
- Aditoo
- Giuseppe Pignataro - Giuseppe Pignataro
- Stefano - Stefano
- **Japanese** - **Japanese**
- ButterflyOfFire - Hinaloe
- Kumasun Morino - 小鳥遊まりあ
- Yamagishi Kazutoshi
- mayaeh - mayaeh
- osapon - osapon
- unarist
- 小鳥遊まりあ
- 森の子リスのミーコの大冒険 - 森の子リスのミーコの大冒険
- **Korean** - Kumasun Morino
- Yamagishi Kazutoshi
- Aditoo
- ButterflyOfFire - ButterflyOfFire
- Jeong Arm - Jeong Arm
- unarist
- **Kazakh**
- arshat
- Aditoo
- **Korean**
- Aditoo
- Jeong Arm
- ButterflyOfFire
- Minori Hiraoka - Minori Hiraoka
- Yamagishi Kazutoshi - Yamagishi Kazutoshi
- **Lithuanian**
- Sarunas Medeikis
- **Malay** - **Malay**
- ButterflyOfFire
- Muhammad Nur Hidayat (MNH48) - Muhammad Nur Hidayat (MNH48)
- Aditoo
- ButterflyOfFire
- **Norwegian (old code)** - **Norwegian (old code)**
- ButterflyOfFire - ButterflyOfFire
- Espen Rønnevik - Espen Rønnevik
- Aditoo
- Tale - Tale
- **Occitan** - **Occitan**
- Aditoo
- ButterflyOfFire - ButterflyOfFire
- Maxenç
- Quenti2 - Quenti2
- Quentí - Quentí
- Maxenç
- **Persian** - **Persian**
- ButterflyOfFire
- Masoud Abkenar - Masoud Abkenar
- **Polish** - Aditoo
- ButterflyOfFire
- **Polish**
- Aditoo
- Albakham
- ButterflyOfFire - ButterflyOfFire
- Jakub Mendyk
- Marcin Mikołajczak
- Marek Ľach
- Stasiek Michalski - Stasiek Michalski
- Marcin Mikołajczak
- Jakub Mendyk
- Marek Ľach
- krkk - krkk
- **Portuguese** - **Portuguese**
- Albakham
- João Pinheiro
- manequim
- Aditoo
- ButterflyOfFire - ButterflyOfFire
- Hugo Gameiro - Hugo Gameiro
- manequim
- **Portuguese (Brazil)** - **Portuguese (Brazil)**
- André Andrade - Aditoo
- Albakham
- Anna e só - Anna e só
- ButterflyOfFire
- Renato "Lond" Cerqueira - Renato "Lond" Cerqueira
- **Romanian** - André Andrade
- ButterflyOfFire - ButterflyOfFire
- **Romanian**
- adrianbblk - adrianbblk
- ButterflyOfFire
- Aditoo
- **Russian** - **Russian**
- Andrew Zyabin - Albakham
- ButterflyOfFire - ButterflyOfFire
- Evgeny Petrov - Evgeny Petrov
- Aditoo
- Павел Гастелло
- Andrew Zyabin
- Yaron Shahrabani - Yaron Shahrabani
- **Serbian** - **Serbian**
- Branko Kokanovic - Branko Kokanovic
- Burekz Finezt - Burekz Finezt
- Aditoo
- ButterflyOfFire - ButterflyOfFire
- **Serbian (latin)** - **Serbian (latin)**
- ButterflyOfFire - ButterflyOfFire
- Aditoo
- **Slovak** - **Slovak**
- Aditoo
- ButterflyOfFire - ButterflyOfFire
- Ivan Pleva - Ivan Pleva
- Lorem Ipsum
- Marek Ľach - Marek Ľach
- Peter - Peter
- **Slovenian** - **Slovenian**
- ButterflyOfFire
- Kristijan Tkalec - Kristijan Tkalec
- Aditoo
- ButterflyOfFire
- **Spanish** - **Spanish**
- Angeles Broullón - Albakham
- Antón López
- ButterflyOfFire - ButterflyOfFire
- Carlos Mondragon - Carlos Mondragon
- Antón López
- Max Winkler
- Pablo de la Concepción Sanz
- Sergio Soriano
- Angeles Broullón
- Lothar Wolf
- Aditoo
- David Charte - David Charte
- Emmanuel - Emmanuel
- Lothar Wolf
- Pablo de la Concepción Sanz
- **Swedish** - **Swedish**
- ButterflyOfFire - ButterflyOfFire
- Elias Mårtenson
- Isak Holmström - Isak Holmström
- Shellkr - Shellkr
- Aditoo
- Elias Mårtenson
- Stefan Midjich - Stefan Midjich
- Tim Stahel - Tim Stahel
- Jonas Hultén
- **Telugu** - **Telugu**
- avndp
- Ranjith Tellakula
- Aditoo
- ButterflyOfFire - ButterflyOfFire
- Joseph Nuthalapati - Joseph Nuthalapati
- Ranjith Tellakula
- avndp
- **Thai** - **Thai**
- ButterflyOfFire - ButterflyOfFire
- parnikkapore
- Thai Localization
- Aditoo
- **Turkish** - **Turkish**
- Ali Demirtas
- ButterflyOfFire - ButterflyOfFire
- Aditoo
- **Ukrainian** - **Ukrainian**
- ButterflyOfFire
- Ivan Verchenko
- alexcleac - alexcleac
- **Welsh**
- ButterflyOfFire - ButterflyOfFire
- Jaz-Michael King - Aditoo
- Kevin Beynon - Ivan Verchenko
- Owain Rhys Lewis - **Welsh**
- Renato "Lond" Cerqueira
- Rhoslyn Prys
- carl morris - carl morris
- Jaz-Michael King
- Owain Rhys Lewis
- Rhoslyn Prys
- Aditoo
- ButterflyOfFire
- Renato "Lond" Cerqueira
- Albakham
- Kevin Beynon
- **Armenian** - **Armenian**
- Aditoo
- ButterflyOfFire - ButterflyOfFire
- **Latvian** - **Latvian**
- Aditoo
- ButterflyOfFire - ButterflyOfFire
- Maigonis
- **Tamil** - **Tamil**
- Aditoo
- ButterflyOfFire - ButterflyOfFire
- Prasanna Venkadesh - Prasanna Venkadesh

View File

@ -3,6 +3,120 @@ 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.8.0] - 2019-04-10
### Added
- Add polls ([Gargron](https://github.com/tootsuite/mastodon/pull/10111), [ThibG](https://github.com/tootsuite/mastodon/pull/10155), [Gargron](https://github.com/tootsuite/mastodon/pull/10184), [ThibG](https://github.com/tootsuite/mastodon/pull/10196), [Gargron](https://github.com/tootsuite/mastodon/pull/10248), [ThibG](https://github.com/tootsuite/mastodon/pull/10255), [ThibG](https://github.com/tootsuite/mastodon/pull/10322), [Gargron](https://github.com/tootsuite/mastodon/pull/10138), [Gargron](https://github.com/tootsuite/mastodon/pull/10139), [Gargron](https://github.com/tootsuite/mastodon/pull/10144), [Gargron](https://github.com/tootsuite/mastodon/pull/10145),[Gargron](https://github.com/tootsuite/mastodon/pull/10146), [Gargron](https://github.com/tootsuite/mastodon/pull/10148), [Gargron](https://github.com/tootsuite/mastodon/pull/10151), [ThibG](https://github.com/tootsuite/mastodon/pull/10150), [Gargron](https://github.com/tootsuite/mastodon/pull/10168), [Gargron](https://github.com/tootsuite/mastodon/pull/10165), [Gargron](https://github.com/tootsuite/mastodon/pull/10172), [Gargron](https://github.com/tootsuite/mastodon/pull/10170), [Gargron](https://github.com/tootsuite/mastodon/pull/10171), [Gargron](https://github.com/tootsuite/mastodon/pull/10186), [Gargron](https://github.com/tootsuite/mastodon/pull/10189), [ThibG](https://github.com/tootsuite/mastodon/pull/10200), [rinsuki](https://github.com/tootsuite/mastodon/pull/10203), [Gargron](https://github.com/tootsuite/mastodon/pull/10213), [Gargron](https://github.com/tootsuite/mastodon/pull/10246), [Gargron](https://github.com/tootsuite/mastodon/pull/10265), [Gargron](https://github.com/tootsuite/mastodon/pull/10261), [ThibG](https://github.com/tootsuite/mastodon/pull/10333), [Gargron](https://github.com/tootsuite/mastodon/pull/10352), [ThibG](https://github.com/tootsuite/mastodon/pull/10140), [ThibG](https://github.com/tootsuite/mastodon/pull/10142), [ThibG](https://github.com/tootsuite/mastodon/pull/10141), [ThibG](https://github.com/tootsuite/mastodon/pull/10162), [ThibG](https://github.com/tootsuite/mastodon/pull/10161), [ThibG](https://github.com/tootsuite/mastodon/pull/10158), [ThibG](https://github.com/tootsuite/mastodon/pull/10156), [ThibG](https://github.com/tootsuite/mastodon/pull/10160), [Gargron](https://github.com/tootsuite/mastodon/pull/10185), [Gargron](https://github.com/tootsuite/mastodon/pull/10188), [ThibG](https://github.com/tootsuite/mastodon/pull/10195), [ThibG](https://github.com/tootsuite/mastodon/pull/10208), [Gargron](https://github.com/tootsuite/mastodon/pull/10187), [ThibG](https://github.com/tootsuite/mastodon/pull/10214), [ThibG](https://github.com/tootsuite/mastodon/pull/10209))
- Add follows & followers managing UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10268), [Gargron](https://github.com/tootsuite/mastodon/pull/10308), [Gargron](https://github.com/tootsuite/mastodon/pull/10404), [Gargron](https://github.com/tootsuite/mastodon/pull/10293))
- Add identity proof integration with Keybase ([Gargron](https://github.com/tootsuite/mastodon/pull/10297), [xgess](https://github.com/tootsuite/mastodon/pull/10375), [Gargron](https://github.com/tootsuite/mastodon/pull/10338), [Gargron](https://github.com/tootsuite/mastodon/pull/10350), [Gargron](https://github.com/tootsuite/mastodon/pull/10414))
- Add option to overwrite imported data instead of merging ([Gargron](https://github.com/tootsuite/mastodon/pull/9962))
- Add featured hashtags to profiles ([Gargron](https://github.com/tootsuite/mastodon/pull/9755), [Gargron](https://github.com/tootsuite/mastodon/pull/10167), [Gargron](https://github.com/tootsuite/mastodon/pull/10249), [ThibG](https://github.com/tootsuite/mastodon/pull/10034))
- Add admission-based registrations mode ([Gargron](https://github.com/tootsuite/mastodon/pull/10250), [ThibG](https://github.com/tootsuite/mastodon/pull/10269), [Gargron](https://github.com/tootsuite/mastodon/pull/10264), [ThibG](https://github.com/tootsuite/mastodon/pull/10321), [Gargron](https://github.com/tootsuite/mastodon/pull/10349), [Gargron](https://github.com/tootsuite/mastodon/pull/10469))
- Add support for WebP uploads ([acid-chicken](https://github.com/tootsuite/mastodon/pull/9879))
- Add "copy link" item to status action bars in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/9983))
- Add list title editing in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/9748))
- Add a "Block & Report" button to the block confirmation dialog in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10360))
- Add disappointed elephant when the page crashes in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10275))
- Add ability to upload multiple files at once in web UI ([tmm576](https://github.com/tootsuite/mastodon/pull/9856))
- Add indication when you are not allowed to follow an account in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10420), [Gargron](https://github.com/tootsuite/mastodon/pull/10491))
- Add validations to admin settings to catch common mistakes ([Gargron](https://github.com/tootsuite/mastodon/pull/10348), [ThibG](https://github.com/tootsuite/mastodon/pull/10354))
- Add `type`, `limit`, `offset`, `min_id`, `max_id`, `account_id` to search API ([Gargron](https://github.com/tootsuite/mastodon/pull/10091))
- Add a preferences API so apps can share basic behaviours ([Gargron](https://github.com/tootsuite/mastodon/pull/10109))
- Add `visibility` param to reblog REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/9851), [ThibG](https://github.com/tootsuite/mastodon/pull/10302))
- Add `allowfullscreen` attribute to OEmbed iframe ([rinsuki](https://github.com/tootsuite/mastodon/pull/10370))
- Add `blocked_by` relationship to the REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/10373))
- Add `tootctl statuses remove` to sweep unreferenced statuses ([Gargron](https://github.com/tootsuite/mastodon/pull/10063))
- Add `tootctl search deploy` to avoid ugly rake task syntax ([Gargron](https://github.com/tootsuite/mastodon/pull/10403))
- Add `tootctl self-destruct` to shut down server gracefully ([Gargron](https://github.com/tootsuite/mastodon/pull/10367))
- Add option to hide application used to toot ([ThibG](https://github.com/tootsuite/mastodon/pull/9897), [rinsuki](https://github.com/tootsuite/mastodon/pull/9994), [hinaloe](https://github.com/tootsuite/mastodon/pull/10086))
- Add `DB_SSLMODE` configuration variable ([sascha-sl](https://github.com/tootsuite/mastodon/pull/10210))
- Add click-to-copy UI to invites page ([Gargron](https://github.com/tootsuite/mastodon/pull/10259))
- Add self-replies fetching ([ThibG](https://github.com/tootsuite/mastodon/pull/10106), [ThibG](https://github.com/tootsuite/mastodon/pull/10128), [ThibG](https://github.com/tootsuite/mastodon/pull/10175), [ThibG](https://github.com/tootsuite/mastodon/pull/10201))
- Add rate limit for media proxy requests ([Gargron](https://github.com/tootsuite/mastodon/pull/10490))
- Add `tootctl emoji purge` ([Gargron](https://github.com/tootsuite/mastodon/pull/10481))
- Add `tootctl accounts approve` ([Gargron](https://github.com/tootsuite/mastodon/pull/10480))
- Add `tootctl accounts reset-relationships` ([noellabo](https://github.com/tootsuite/mastodon/pull/10483))
### Changed
- Change design of landing page ([Gargron](https://github.com/tootsuite/mastodon/pull/10232), [Gargron](https://github.com/tootsuite/mastodon/pull/10260), [ThibG](https://github.com/tootsuite/mastodon/pull/10284), [ThibG](https://github.com/tootsuite/mastodon/pull/10291), [koyuawsmbrtn](https://github.com/tootsuite/mastodon/pull/10356), [Gargron](https://github.com/tootsuite/mastodon/pull/10245))
- Change design of profile column in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10337), [Aditoo17](https://github.com/tootsuite/mastodon/pull/10387), [ThibG](https://github.com/tootsuite/mastodon/pull/10390), [mayaeh](https://github.com/tootsuite/mastodon/pull/10379), [ThibG](https://github.com/tootsuite/mastodon/pull/10411))
- Change language detector threshold from 140 characters to 4 words ([Gargron](https://github.com/tootsuite/mastodon/pull/10376))
- Change language detector to always kick in for non-latin alphabets ([Gargron](https://github.com/tootsuite/mastodon/pull/10276))
- Change icons of features on admin dashboard ([Gargron](https://github.com/tootsuite/mastodon/pull/10366))
- Change DNS timeouts from 1s to 5s ([ThibG](https://github.com/tootsuite/mastodon/pull/10238))
- Change Docker image to use Ubuntu with jemalloc ([Sir-Boops](https://github.com/tootsuite/mastodon/pull/10100), [BenLubar](https://github.com/tootsuite/mastodon/pull/10212))
- Change public pages to be cacheable by proxies ([BenLubar](https://github.com/tootsuite/mastodon/pull/9059))
- Change the 410 gone response for suspended accounts to be cacheable by proxies ([ThibG](https://github.com/tootsuite/mastodon/pull/10339))
- Change web UI to not not empty timeline of blocked users on block ([ThibG](https://github.com/tootsuite/mastodon/pull/10359))
- Change JSON serializer to remove unused `@context` values ([Gargron](https://github.com/tootsuite/mastodon/pull/10378))
- Change GIFV file size limit to be the same as for other videos ([rinsuki](https://github.com/tootsuite/mastodon/pull/9924))
- Change Webpack to not use @babel/preset-env to compile node_modules ([ykzts](https://github.com/tootsuite/mastodon/pull/10289))
- Change web UI to use new Web Share Target API ([gol-cha](https://github.com/tootsuite/mastodon/pull/9963))
- Change ActivityPub reports to have persistent URIs ([ThibG](https://github.com/tootsuite/mastodon/pull/10303))
- Change `tootctl accounts cull --dry-run` to list accounts that would be deleted ([BenLubar](https://github.com/tootsuite/mastodon/pull/10460))
- Change format of CSV exports of follows and mutes to include extra settings ([ThibG](https://github.com/tootsuite/mastodon/pull/10495), [ThibG](https://github.com/tootsuite/mastodon/pull/10335))
- Change ActivityPub collections to be cacheable by proxies ([ThibG](https://github.com/tootsuite/mastodon/pull/10467))
- Change REST API and public profiles to not return follows/followers for users that have blocked you ([Gargron](https://github.com/tootsuite/mastodon/pull/10491))
- Change the groupings of menu items in settings navigation ([Gargron](https://github.com/tootsuite/mastodon/pull/10533))
### Removed
- Remove zopfli compression to speed up Webpack from 6min to 1min ([nolanlawson](https://github.com/tootsuite/mastodon/pull/10288))
- Remove stats.json generation to speed up Webpack ([nolanlawson](https://github.com/tootsuite/mastodon/pull/10290))
### Fixed
- Fix public timelines being broken by new toots when they are not mounted in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10131))
- Fix quick filter settings not being saved when selecting a different filter in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10296))
- Fix remote interaction dialogs being indexed by search engines ([Gargron](https://github.com/tootsuite/mastodon/pull/10240))
- Fix maxed-out invites not showing up as expired in UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10274))
- Fix scrollbar styles on compose textarea ([Gargron](https://github.com/tootsuite/mastodon/pull/10292))
- Fix timeline merge workers being queued for remote users ([Gargron](https://github.com/tootsuite/mastodon/pull/10355))
- Fix alternative relay support regression ([Gargron](https://github.com/tootsuite/mastodon/pull/10398))
- Fix trying to fetch keys of unknown accounts on a self-delete from them ([ThibG](https://github.com/tootsuite/mastodon/pull/10326))
- Fix CAS `:service_validate_url` option ([enewhuis](https://github.com/tootsuite/mastodon/pull/10328))
- Fix race conditions when creating backups ([ThibG](https://github.com/tootsuite/mastodon/pull/10234))
- Fix whitespace not being stripped out of username before validation ([aurelien-reeves](https://github.com/tootsuite/mastodon/pull/10239))
- Fix n+1 query when deleting status ([Gargron](https://github.com/tootsuite/mastodon/pull/10247))
- Fix exiting follows not being rejected when suspending a remote account ([ThibG](https://github.com/tootsuite/mastodon/pull/10230))
- Fix the underlying button element in a disabled icon button not being disabled ([ThibG](https://github.com/tootsuite/mastodon/pull/10194))
- Fix race condition when streaming out deleted statuses ([ThibG](https://github.com/tootsuite/mastodon/pull/10280))
- Fix performance of admin federation UI by caching account counts ([Gargron](https://github.com/tootsuite/mastodon/pull/10374))
- Fix JS error on pages that don't define a CSRF token ([hinaloe](https://github.com/tootsuite/mastodon/pull/10383))
- Fix `tootctl accounts cull` sometimes removing accounts that are temporarily unreachable ([BenLubar](https://github.com/tootsuite/mastodon/pull/10460))
## [2.7.4] - 2019-03-05
### Fixed
- Fix web UI not cleaning up notifications after block ([Gargron](https://github.com/tootsuite/mastodon/pull/10108))
- Fix redundant HTTP requests when resolving private statuses ([ThibG](https://github.com/tootsuite/mastodon/pull/10115))
- Fix performance of account media query ([abcang](https://github.com/tootsuite/mastodon/pull/10121))
- Fix mention processing for unknown accounts ([ThibG](https://github.com/tootsuite/mastodon/pull/10125))
- Fix getting started column not scrolling on short screens ([trwnh](https://github.com/tootsuite/mastodon/pull/10075))
- Fix direct messages pagination in the web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10126))
- Fix serialization of Announce activities ([ThibG](https://github.com/tootsuite/mastodon/pull/10129))
- Fix home timeline perpetually reloading when empty in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10130))
- Fix lists export ([ThibG](https://github.com/tootsuite/mastodon/pull/10136))
- Fix edit profile page crash for suspended-then-unsuspended users ([ThibG](https://github.com/tootsuite/mastodon/pull/10178))
## [2.7.3] - 2019-02-23
### Added
- Add domain filter to the admin federation page ([ThibG](https://github.com/tootsuite/mastodon/pull/10071))
- Add quick link from admin account view to block/unblock instance ([ThibG](https://github.com/tootsuite/mastodon/pull/10073))
### Fixed
- Fix video player width not being updated to fit container width ([ThibG](https://github.com/tootsuite/mastodon/pull/10069))
- Fix domain filter being shown in admin page when local filter is active ([ThibG](https://github.com/tootsuite/mastodon/pull/10074))
- Fix crash when conversations have no valid participants ([ThibG](https://github.com/tootsuite/mastodon/pull/10078))
- Fix error when performing admin actions on no statuses ([ThibG](https://github.com/tootsuite/mastodon/pull/10094))
### Changed
- Change custom emojis to randomize stored file name ([hinaloe](https://github.com/tootsuite/mastodon/pull/10090))
## [2.7.2] - 2019-02-17 ## [2.7.2] - 2019-02-17
### Added ### Added

View File

@ -1,90 +1,128 @@
FROM node:8.15-alpine as node FROM ubuntu:18.04 as build-dep
FROM ruby:2.6-alpine3.8
LABEL maintainer="https://github.com/tootsuite/mastodon" \ # Use bash for the shell
description="Your self-hosted, globally interconnected microblogging community" SHELL ["bash", "-c"]
# Install Node
ENV NODE_VER="8.15.0"
RUN echo "Etc/UTC" > /etc/localtime && \
apt update && \
apt -y dist-upgrade && \
apt -y install wget make gcc g++ python && \
cd ~ && \
wget https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER.tar.gz && \
tar xf node-v$NODE_VER.tar.gz && \
cd node-v$NODE_VER && \
./configure --prefix=/opt/node && \
make -j$(nproc) > /dev/null && \
make install
# Install jemalloc
ENV JE_VER="5.1.0"
RUN apt update && \
apt -y install autoconf && \
cd ~ && \
wget https://github.com/jemalloc/jemalloc/archive/$JE_VER.tar.gz && \
tar xf $JE_VER.tar.gz && \
cd jemalloc-$JE_VER && \
./autogen.sh && \
./configure --prefix=/opt/jemalloc && \
make -j$(nproc) > /dev/null && \
make install_bin install_include install_lib
# Install ruby
ENV RUBY_VER="2.6.1"
ENV CPPFLAGS="-I/opt/jemalloc/include"
ENV LDFLAGS="-L/opt/jemalloc/lib/"
RUN apt update && \
apt -y install build-essential \
bison libyaml-dev libgdbm-dev libreadline-dev \
libncurses5-dev libffi-dev zlib1g-dev libssl-dev && \
cd ~ && \
wget https://cache.ruby-lang.org/pub/ruby/${RUBY_VER%.*}/ruby-$RUBY_VER.tar.gz && \
tar xf ruby-$RUBY_VER.tar.gz && \
cd ruby-$RUBY_VER && \
./configure --prefix=/opt/ruby \
--with-jemalloc \
--with-shared \
--disable-install-doc && \
ln -s /opt/jemalloc/lib/* /usr/lib/ && \
make -j$(nproc) > /dev/null && \
make install
ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin"
RUN npm install -g yarn && \
gem install bundler && \
apt update && \
apt -y install git libicu-dev libidn11-dev \
libpq-dev libprotobuf-dev protobuf-compiler
COPY Gemfile* package.json yarn.lock /opt/mastodon/
RUN cd /opt/mastodon && \
bundle install -j$(nproc) --deployment --without development test && \
yarn install --pure-lockfile
FROM ubuntu:18.04
# Copy over all the langs needed for runtime
COPY --from=build-dep /opt/node /opt/node
COPY --from=build-dep /opt/ruby /opt/ruby
COPY --from=build-dep /opt/jemalloc /opt/jemalloc
# Add more PATHs to the PATH
ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin:/opt/mastodon/bin"
# Create the mastodon user
ARG UID=991 ARG UID=991
ARG GID=991 ARG GID=991
RUN apt update && \
echo "Etc/UTC" > /etc/localtime && \
ln -s /opt/jemalloc/lib/* /usr/lib/ && \
apt -y dist-upgrade && \
apt install -y whois wget && \
addgroup --gid $GID mastodon && \
useradd -m -u $UID -g $GID -d /opt/mastodon mastodon && \
echo "mastodon:`head /dev/urandom | tr -dc A-Za-z0-9 | head -c 24 | mkpasswd -s -m sha-256`" | chpasswd
ENV PATH=/mastodon/bin:$PATH \ # Install masto runtime deps
RAILS_SERVE_STATIC_FILES=true \ RUN apt -y --no-install-recommends install \
RAILS_ENV=production \ libssl1.1 libpq5 imagemagick ffmpeg \
NODE_ENV=production libicu60 libprotobuf10 libidn11 libyaml-0-2 \
file ca-certificates tzdata libreadline7 && \
apt -y install gcc && \
ln -s /opt/mastodon /mastodon && \
gem install bundler && \
rm -rf /var/cache && \
rm -rf /var/lib/apt
ARG LIBICONV_VERSION=1.15 # Add tini
ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178 ENV TINI_VERSION="0.18.0"
ENV TINI_SUM="12d20136605531b09a2c2dac02ccee85e1b874eb322ef6baf7561cd93f93c855"
ADD https://github.com/krallin/tini/releases/download/v${TINI_VERSION}/tini /tini
RUN echo "$TINI_SUM tini" | sha256sum -c -
RUN chmod +x /tini
EXPOSE 3000 4000 # Copy over masto source, and dependencies from building, and set permissions
COPY --chown=mastodon:mastodon . /opt/mastodon
COPY --from=build-dep --chown=mastodon:mastodon /opt/mastodon /opt/mastodon
WORKDIR /mastodon # Run masto services in prod mode
ENV RAILS_ENV="production"
ENV NODE_ENV="production"
COPY --from=node /usr/local/bin/node /usr/local/bin/node # Tell rails to serve static files
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules ENV RAILS_SERVE_STATIC_FILES="true"
COPY --from=node /usr/local/bin/npm /usr/local/bin/npm
COPY --from=node /opt/yarn-* /opt/yarn
RUN apk -U upgrade \
&& apk add -t build-dependencies \
build-base \
icu-dev \
libidn-dev \
libressl \
libtool \
libxml2-dev \
libxslt-dev \
postgresql-dev \
protobuf-dev \
python \
&& apk add \
ca-certificates \
ffmpeg \
file \
git \
icu-libs \
imagemagick \
libidn \
libpq \
libxml2 \
libxslt \
protobuf \
tini \
tzdata \
&& update-ca-certificates \
&& ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn \
&& ln -s /opt/yarn/bin/yarnpkg /usr/local/bin/yarnpkg \
&& mkdir -p /tmp/src /opt \
&& wget -O libiconv.tar.gz "https://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \
&& echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \
&& tar -xzf libiconv.tar.gz -C /tmp/src \
&& rm libiconv.tar.gz \
&& cd /tmp/src/libiconv-$LIBICONV_VERSION \
&& ./configure --prefix=/usr/local \
&& make -j$(getconf _NPROCESSORS_ONLN)\
&& make install \
&& libtool --finish /usr/local/lib \
&& cd /mastodon \
&& rm -rf /tmp/* /var/cache/apk/*
COPY Gemfile Gemfile.lock package.json yarn.lock .yarnclean /mastodon/
RUN bundle config build.nokogiri --use-system-libraries --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \
&& bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \
&& yarn install --pure-lockfile --ignore-engines \
&& yarn cache clean
RUN addgroup -g ${GID} mastodon && adduser -h /mastodon -s /bin/sh -D -G mastodon -u ${UID} mastodon \
&& mkdir -p /mastodon/public/system /mastodon/public/assets /mastodon/public/packs \
&& chown -R mastodon:mastodon /mastodon/public
COPY . /mastodon
RUN chown -R mastodon:mastodon /mastodon
VOLUME /mastodon/public/system
# Set the run user
USER mastodon USER mastodon
RUN OTP_SECRET=precompile_placeholder SECRET_KEY_BASE=precompile_placeholder bundle exec rails assets:precompile # Precompile assets
RUN cd ~ && \
OTP_SECRET=precompile_placeholder SECRET_KEY_BASE=precompile_placeholder rails assets:precompile && \
yarn cache clean
ENTRYPOINT ["/sbin/tini", "--"] # Set the work dir and the container entry point
WORKDIR /opt/mastodon
ENTRYPOINT ["/tini", "--"]

28
Gemfile
View File

@ -6,16 +6,16 @@ ruby '>= 2.4.0', '< 2.7.0'
gem 'pkg-config', '~> 1.3' gem 'pkg-config', '~> 1.3'
gem 'puma', '~> 3.12' gem 'puma', '~> 3.12'
gem 'rails', '~> 5.2.2' gem 'rails', '~> 5.2.3'
gem 'thor', '~> 0.20' gem 'thor', '~> 0.20'
gem 'hamlit-rails', '~> 0.2' gem 'hamlit-rails', '~> 0.2'
gem 'pg', '~> 1.1' gem 'pg', '~> 1.1'
gem 'makara', '~> 0.4' gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.2' gem 'pghero', '~> 2.2'
gem 'dotenv-rails', '~> 2.6' gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.30', require: false gem 'aws-sdk-s3', '~> 1.36', require: false
gem 'fog-core', '<= 2.1.0' gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0' gem 'paperclip', '~> 6.0'
@ -24,13 +24,13 @@ gem 'streamio-ffmpeg', '~> 3.0'
gem 'active_model_serializers', '~> 0.10' gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.6' gem 'addressable', '~> 2.6'
gem 'bootsnap', '~> 1.3', require: false gem 'bootsnap', '~> 1.4', require: false
gem 'browser' gem 'browser'
gem 'charlock_holmes', '~> 0.7.6' gem 'charlock_holmes', '~> 0.7.6'
gem 'iso-639' gem 'iso-639'
gem 'chewy', '~> 5.0' gem 'chewy', '~> 5.0'
gem 'cld3', '~> 3.2.3' gem 'cld3', '~> 3.2.3'
gem 'devise', '~> 4.5' gem 'devise', '~> 4.6'
gem 'devise-two-factor', '~> 3.0' gem 'devise-two-factor', '~> 3.0'
group :pam_authentication, optional: true do group :pam_authentication, optional: true do
@ -85,8 +85,8 @@ gem 'strong_migrations', '~> 0.3'
gem 'tty-command', '~> 0.8', require: false gem 'tty-command', '~> 0.8', require: false
gem 'tty-prompt', '~> 0.18', require: false gem 'tty-prompt', '~> 0.18', require: false
gem 'twitter-text', '~> 1.14' gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2018' gem 'tzinfo-data', '~> 1.2019'
gem 'webpacker', '~> 3.5' gem 'webpacker', '~> 4.0'
gem 'webpush' gem 'webpush'
gem 'json-ld', '~> 3.0' gem 'json-ld', '~> 3.0'
@ -97,7 +97,7 @@ group :development, :test do
gem 'fabrication', '~> 2.20' gem 'fabrication', '~> 2.20'
gem 'fuubar', '~> 2.3' gem 'fuubar', '~> 2.3'
gem 'i18n-tasks', '~> 0.9', require: false gem 'i18n-tasks', '~> 0.9', require: false
gem 'pry-byebug', '~> 3.6' gem 'pry-byebug', '~> 3.7'
gem 'pry-rails', '~> 0.3' gem 'pry-rails', '~> 0.3'
gem 'rspec-rails', '~> 3.8' gem 'rspec-rails', '~> 3.8'
end end
@ -107,19 +107,19 @@ group :production, :test do
end end
group :test do group :test do
gem 'capybara', '~> 3.12' gem 'capybara', '~> 3.16'
gem 'climate_control', '~> 0.2' gem 'climate_control', '~> 0.2'
gem 'faker', '~> 1.9' gem 'faker', '~> 1.9'
gem 'microformats', '~> 4.0' gem 'microformats', '~> 4.1'
gem 'rails-controller-testing', '~> 1.0' gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.0' gem 'rspec-sidekiq', '~> 3.0'
gem 'simplecov', '~> 0.16', require: false gem 'simplecov', '~> 0.16', require: false
gem 'webmock', '~> 3.5' gem 'webmock', '~> 3.5'
gem 'parallel_tests', '~> 2.27' gem 'parallel_tests', '~> 2.28'
end end
group :development do group :development do
gem 'active_record_query_trace', '~> 1.5' gem 'active_record_query_trace', '~> 1.6'
gem 'annotate', '~> 2.7' gem 'annotate', '~> 2.7'
gem 'better_errors', '~> 2.5' gem 'better_errors', '~> 2.5'
gem 'binding_of_caller', '~> 0.7' gem 'binding_of_caller', '~> 0.7'
@ -127,8 +127,8 @@ group :development do
gem 'letter_opener', '~> 1.7' gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.3' gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler' gem 'memory_profiler'
gem 'rubocop', '~> 0.63', require: false gem 'rubocop', '~> 0.67', require: false
gem 'brakeman', '~> 4.4', require: false gem 'brakeman', '~> 4.5', require: false
gem 'bundler-audit', '~> 0.6', require: false gem 'bundler-audit', '~> 0.6', require: false
gem 'scss_lint', '~> 0.57', require: false gem 'scss_lint', '~> 0.57', require: false

View File

@ -15,49 +15,49 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (5.2.2) actioncable (5.2.3)
actionpack (= 5.2.2) actionpack (= 5.2.3)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailer (5.2.2) actionmailer (5.2.3)
actionpack (= 5.2.2) actionpack (= 5.2.3)
actionview (= 5.2.2) actionview (= 5.2.3)
activejob (= 5.2.2) activejob (= 5.2.3)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (5.2.2) actionpack (5.2.3)
actionview (= 5.2.2) actionview (= 5.2.3)
activesupport (= 5.2.2) activesupport (= 5.2.3)
rack (~> 2.0) rack (~> 2.0)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2) rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionview (5.2.2) actionview (5.2.3)
activesupport (= 5.2.2) activesupport (= 5.2.3)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.3) rails-html-sanitizer (~> 1.0, >= 1.0.3)
active_model_serializers (0.10.8) active_model_serializers (0.10.9)
actionpack (>= 4.1, < 6) actionpack (>= 4.1, < 6)
activemodel (>= 4.1, < 6) activemodel (>= 4.1, < 6)
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3) jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
active_record_query_trace (1.5.4) active_record_query_trace (1.6.2)
activejob (5.2.2) activejob (5.2.3)
activesupport (= 5.2.2) activesupport (= 5.2.3)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (5.2.2) activemodel (5.2.3)
activesupport (= 5.2.2) activesupport (= 5.2.3)
activerecord (5.2.2) activerecord (5.2.3)
activemodel (= 5.2.2) activemodel (= 5.2.3)
activesupport (= 5.2.2) activesupport (= 5.2.3)
arel (>= 9.0) arel (>= 9.0)
activestorage (5.2.2) activestorage (5.2.3)
actionpack (= 5.2.2) actionpack (= 5.2.3)
activerecord (= 5.2.2) activerecord (= 5.2.3)
marcel (~> 0.3.1) marcel (~> 0.3.1)
activesupport (5.2.2) activesupport (5.2.3)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
minitest (~> 5.1) minitest (~> 5.1)
@ -75,32 +75,33 @@ GEM
encryptor (~> 3.0.0) encryptor (~> 3.0.0)
av (0.9.0) av (0.9.0)
cocaine (~> 0.5.3) cocaine (~> 0.5.3)
aws-eventstream (1.0.1) aws-eventstream (1.0.2)
aws-partitions (1.131.0) aws-partitions (1.147.0)
aws-sdk-core (3.45.0) aws-sdk-core (3.48.3)
aws-eventstream (~> 1.0) aws-eventstream (~> 1.0, >= 1.0.2)
aws-partitions (~> 1.0) aws-partitions (~> 1.0)
aws-sigv4 (~> 1.0) aws-sigv4 (~> 1.1)
jmespath (~> 1.0) jmespath (~> 1.0)
aws-sdk-kms (1.13.0) aws-sdk-kms (1.16.0)
aws-sdk-core (~> 3, >= 3.39.0) aws-sdk-core (~> 3, >= 3.48.2)
aws-sigv4 (~> 1.0) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.30.1) aws-sdk-s3 (1.36.0)
aws-sdk-core (~> 3, >= 3.39.0) aws-sdk-core (~> 3, >= 3.48.2)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.0) aws-sigv4 (~> 1.0)
aws-sigv4 (1.0.3) aws-sigv4 (1.1.0)
aws-eventstream (~> 1.0, >= 1.0.2)
bcrypt (3.1.12) bcrypt (3.1.12)
benchmark-ips (2.7.2) benchmark-ips (2.7.2)
better_errors (2.5.0) better_errors (2.5.1)
coderay (>= 1.0.0) coderay (>= 1.0.0)
erubi (>= 1.0.0) erubi (>= 1.0.0)
rack (>= 0.9.0) rack (>= 0.9.0)
binding_of_caller (0.8.0) binding_of_caller (0.8.0)
debug_inspector (>= 0.0.1) debug_inspector (>= 0.0.1)
bootsnap (1.3.2) bootsnap (1.4.3)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (4.4.0) brakeman (4.5.0)
browser (2.5.3) browser (2.5.3)
builder (3.2.3) builder (3.2.3)
bullet (5.9.0) bullet (5.9.0)
@ -109,7 +110,7 @@ GEM
bundler-audit (0.6.1) bundler-audit (0.6.1)
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (~> 0.18) thor (~> 0.18)
byebug (10.0.2) byebug (11.0.0)
capistrano (3.11.0) capistrano (3.11.0)
airbrussh (>= 1.0.0) airbrussh (>= 1.0.0)
i18n i18n
@ -126,7 +127,7 @@ GEM
sshkit (~> 1.3) sshkit (~> 1.3)
capistrano-yarn (2.0.2) capistrano-yarn (2.0.2)
capistrano (~> 3.0) capistrano (~> 3.0)
capybara (3.12.0) capybara (3.16.1)
addressable addressable
mini_mime (>= 0.1.3) mini_mime (>= 0.1.3)
nokogiri (~> 1.8) nokogiri (~> 1.8)
@ -148,7 +149,7 @@ GEM
cocaine (0.5.8) cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0) climate_control (>= 0.0.3, < 1.0)
coderay (1.1.2) coderay (1.1.2)
concurrent-ruby (1.1.4) concurrent-ruby (1.1.5)
connection_pool (2.2.2) connection_pool (2.2.2)
crack (0.4.3) crack (0.4.3)
safe_yaml (~> 1.0.0) safe_yaml (~> 1.0.0)
@ -164,7 +165,7 @@ GEM
rack (>= 1) rack (>= 1)
rake (> 10, < 13) rake (> 10, < 13)
thor (~> 0.19) thor (~> 0.19)
devise (4.5.0) devise (4.6.2)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0, < 6.0) railties (>= 4.1.0, < 6.0)
@ -185,10 +186,10 @@ GEM
unf (>= 0.0.5, < 1.0.0) unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.0.2) doorkeeper (5.0.2)
railties (>= 4.2) railties (>= 4.2)
dotenv (2.6.0) dotenv (2.7.2)
dotenv-rails (2.6.0) dotenv-rails (2.7.2)
dotenv (= 2.6.0) dotenv (= 2.7.2)
railties (>= 3.2, < 6.0) railties (>= 3.2, < 6.1)
elasticsearch (6.0.2) elasticsearch (6.0.2)
elasticsearch-api (= 6.0.2) elasticsearch-api (= 6.0.2)
elasticsearch-transport (= 6.0.2) elasticsearch-transport (= 6.0.2)
@ -205,7 +206,7 @@ GEM
tzinfo tzinfo
excon (0.62.0) excon (0.62.0)
fabrication (2.20.1) fabrication (2.20.1)
faker (1.9.1) faker (1.9.3)
i18n (>= 0.7) i18n (>= 0.7)
faraday (0.15.0) faraday (0.15.0)
multipart-post (>= 1.2, < 3) multipart-post (>= 1.2, < 3)
@ -232,18 +233,18 @@ GEM
rspec-core (~> 3.0) rspec-core (~> 3.0)
ruby-progressbar (~> 1.4) ruby-progressbar (~> 1.4)
get_process_mem (0.2.3) get_process_mem (0.2.3)
globalid (0.4.1) globalid (0.4.2)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
goldfinger (2.1.0) goldfinger (2.1.0)
addressable (~> 2.5) addressable (~> 2.5)
http (~> 3.0) http (~> 3.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
oj (~> 3.0) oj (~> 3.0)
hamlit (2.8.8) hamlit (2.9.3)
temple (>= 0.8.0) temple (>= 0.8.0)
thor thor
tilt tilt
hamlit-rails (0.2.0) hamlit-rails (0.2.3)
actionpack (>= 4.0.1) actionpack (>= 4.0.1)
activesupport (>= 4.0.1) activesupport (>= 4.0.1)
hamlit (>= 1.2.0) hamlit (>= 1.2.0)
@ -253,7 +254,7 @@ GEM
hashdiff (0.3.7) hashdiff (0.3.7)
hashie (3.6.0) hashie (3.6.0)
heapy (0.1.4) heapy (0.1.4)
highline (2.0.0) highline (2.0.1)
hiredis (0.6.3) hiredis (0.6.3)
hkdf (0.3.0) hkdf (0.3.0)
htmlentities (4.3.4) htmlentities (4.3.4)
@ -266,12 +267,12 @@ GEM
domain_name (~> 0.5) domain_name (~> 0.5)
http-form_data (2.1.1) http-form_data (2.1.1)
http_accept_language (2.1.1) http_accept_language (2.1.1)
httplog (1.2.0) httplog (1.2.2)
rack (>= 1.0) rack (>= 1.0)
rainbow (>= 2.0.0) rainbow (>= 2.0.0)
i18n (1.5.2) i18n (1.6.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
i18n-tasks (0.9.28) i18n-tasks (0.9.29)
activesupport (>= 4.0.2) activesupport (>= 4.0.2)
ast (>= 2.1.0) ast (>= 2.1.0)
erubi erubi
@ -327,25 +328,25 @@ GEM
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
mail (2.7.1) mail (2.7.1)
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
makara (0.4.0) makara (0.4.1)
activerecord (>= 3.0.0) activerecord (>= 3.0.0)
marcel (0.3.3) marcel (0.3.3)
mimemagic (~> 0.3.2) mimemagic (~> 0.3.2)
mario-redis-lock (1.2.1) mario-redis-lock (1.2.1)
redis (>= 3.0.5) redis (>= 3.0.5)
memory_profiler (0.9.12) memory_profiler (0.9.13)
method_source (0.9.2) method_source (0.9.2)
microformats (4.0.7) microformats (4.1.0)
json json (~> 2.1)
nokogiri nokogiri (~> 1.8, >= 1.8.3)
mime-types (3.2.2) mime-types (3.2.2)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2018.0812) mime-types-data (3.2018.0812)
mimemagic (0.3.2) mimemagic (0.3.3)
mini_mime (1.0.1) mini_mime (1.0.1)
mini_portile2 (2.4.0) mini_portile2 (2.4.0)
minitest (5.11.3) minitest (5.11.3)
msgpack (1.2.4) msgpack (1.2.9)
multi_json (1.13.1) multi_json (1.13.1)
multipart-post (2.0.0) multipart-post (2.0.0)
necromancer (0.4.0) necromancer (0.4.0)
@ -354,7 +355,7 @@ GEM
net-ssh (>= 2.6.5) net-ssh (>= 2.6.5)
net-ssh (5.0.2) net-ssh (5.0.2)
nio4r (2.3.1) nio4r (2.3.1)
nokogiri (1.10.1) nokogiri (1.10.2)
mini_portile2 (~> 2.4.0) mini_portile2 (~> 2.4.0)
nokogumbo (2.0.0) nokogumbo (2.0.0)
nokogiri (~> 1.8, >= 1.8.4) nokogiri (~> 1.8, >= 1.8.4)
@ -363,7 +364,7 @@ GEM
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5) sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0) statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.7.8) oj (3.7.11)
omniauth (1.9.0) omniauth (1.9.0)
hashie (>= 3.4.6, < 3.7.0) hashie (>= 3.4.6, < 3.7.0)
rack (>= 1.6.2, < 3) rack (>= 1.6.2, < 3)
@ -389,10 +390,10 @@ GEM
paperclip-av-transcoder (0.6.4) paperclip-av-transcoder (0.6.4)
av (~> 0.9.0) av (~> 0.9.0)
paperclip (>= 2.5.2) paperclip (>= 2.5.2)
parallel (1.13.0) parallel (1.17.0)
parallel_tests (2.27.1) parallel_tests (2.28.0)
parallel parallel
parser (2.6.0.0) parser (2.6.2.0)
ast (~> 2.4.0) ast (~> 2.4.0)
pastel (0.7.2) pastel (0.7.2)
equatable (~> 0.5.0) equatable (~> 0.5.0)
@ -400,8 +401,7 @@ GEM
pg (1.1.4) pg (1.1.4)
pghero (2.2.0) pghero (2.2.0)
activerecord activerecord
pkg-config (1.3.2) pkg-config (1.3.7)
powerpack (0.1.2)
premailer (1.11.1) premailer (1.11.1)
addressable addressable
css_parser (>= 1.6.0) css_parser (>= 1.6.0)
@ -413,38 +413,39 @@ GEM
pry (0.12.2) pry (0.12.2)
coderay (~> 1.1.0) coderay (~> 1.1.0)
method_source (~> 0.9.0) method_source (~> 0.9.0)
pry-byebug (3.6.0) pry-byebug (3.7.0)
byebug (~> 10.0) byebug (~> 11.0)
pry (~> 0.10) pry (~> 0.10)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
psych (3.1.0)
public_suffix (3.0.3) public_suffix (3.0.3)
puma (3.12.0) puma (3.12.1)
pundit (2.0.1) pundit (2.0.1)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.1.6) raabro (1.1.6)
rack (2.0.6) rack (2.0.7)
rack-attack (5.4.2) rack-attack (5.4.2)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rack-cors (1.0.2) rack-cors (1.0.3)
rack-protection (2.0.5) rack-protection (2.0.5)
rack rack
rack-proxy (0.6.4) rack-proxy (0.6.5)
rack rack
rack-test (1.1.0) rack-test (1.1.0)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rails (5.2.2) rails (5.2.3)
actioncable (= 5.2.2) actioncable (= 5.2.3)
actionmailer (= 5.2.2) actionmailer (= 5.2.3)
actionpack (= 5.2.2) actionpack (= 5.2.3)
actionview (= 5.2.2) actionview (= 5.2.3)
activejob (= 5.2.2) activejob (= 5.2.3)
activemodel (= 5.2.2) activemodel (= 5.2.3)
activerecord (= 5.2.2) activerecord (= 5.2.3)
activestorage (= 5.2.2) activestorage (= 5.2.3)
activesupport (= 5.2.2) activesupport (= 5.2.3)
bundler (>= 1.3.0) bundler (>= 1.3.0)
railties (= 5.2.2) railties (= 5.2.3)
sprockets-rails (>= 2.0.0) sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.4) rails-controller-testing (1.0.4)
actionpack (>= 5.0.1.x) actionpack (>= 5.0.1.x)
@ -455,14 +456,14 @@ GEM
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.0.4) rails-html-sanitizer (1.0.4)
loofah (~> 2.2, >= 2.2.2) loofah (~> 2.2, >= 2.2.2)
rails-i18n (5.1.2) rails-i18n (5.1.3)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 5.0, < 6) railties (>= 5.0, < 6)
rails-settings-cached (0.6.6) rails-settings-cached (0.6.6)
rails (>= 4.2.0) rails (>= 4.2.0)
railties (5.2.2) railties (5.2.3)
actionpack (= 5.2.2) actionpack (= 5.2.3)
activesupport (= 5.2.2) activesupport (= 5.2.3)
method_source method_source
rake (>= 0.8.7) rake (>= 0.8.7)
thor (>= 0.19.0, < 2.0) thor (>= 0.19.0, < 2.0)
@ -498,9 +499,9 @@ GEM
regexp_parser (1.3.0) regexp_parser (1.3.0)
request_store (1.4.1) request_store (1.4.1)
rack (>= 1.4) rack (>= 1.4)
responders (2.4.0) responders (2.4.1)
actionpack (>= 4.2.0, < 5.3) actionpack (>= 4.2.0, < 6.0)
railties (>= 4.2.0, < 5.3) railties (>= 4.2.0, < 6.0)
rotp (2.1.2) rotp (2.1.2)
rpam2 (4.0.2) rpam2 (4.0.2)
rqrcode (0.10.1) rqrcode (0.10.1)
@ -525,14 +526,14 @@ GEM
rspec-core (~> 3.0, >= 3.0.0) rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0) sidekiq (>= 2.4.0)
rspec-support (3.8.0) rspec-support (3.8.0)
rubocop (0.63.1) rubocop (0.67.1)
jaro_winkler (~> 1.5.1) jaro_winkler (~> 1.5.1)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 2.5, != 2.5.1.1) parser (>= 2.5, != 2.5.1.1)
powerpack (~> 0.1) psych (>= 3.1.0)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.4.0) unicode-display_width (>= 1.4.0, < 1.6)
ruby-progressbar (1.10.0) ruby-progressbar (1.10.0)
ruby-saml (1.9.0) ruby-saml (1.9.0)
nokogiri (>= 1.5.10) nokogiri (>= 1.5.10)
@ -563,9 +564,9 @@ GEM
rufus-scheduler (~> 3.2) rufus-scheduler (~> 3.2)
sidekiq (>= 3) sidekiq (>= 3)
tilt (>= 1.4.0) tilt (>= 1.4.0)
sidekiq-unique-jobs (6.0.8) sidekiq-unique-jobs (6.0.12)
concurrent-ruby (~> 1.0, >= 1.0.5) concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 4.0, < 6.0) sidekiq (>= 4.0, < 7.0)
thor (~> 0) thor (~> 0)
simple-navigation (4.0.5) simple-navigation (4.0.5)
activesupport (>= 2.3.2) activesupport (>= 2.3.2)
@ -594,14 +595,14 @@ GEM
multi_json (~> 1.8) multi_json (~> 1.8)
strong_migrations (0.3.1) strong_migrations (0.3.1)
activerecord (>= 3.2.0) activerecord (>= 3.2.0)
temple (0.8.0) temple (0.8.1)
terminal-table (1.8.0) terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1) unicode-display_width (~> 1.1, >= 1.1.1)
terrapin (0.6.0) terrapin (0.6.0)
climate_control (>= 0.0.3, < 1.0) climate_control (>= 0.0.3, < 1.0)
thor (0.20.3) thor (0.20.3)
thread_safe (0.3.6) thread_safe (0.3.6)
tilt (2.0.8) tilt (2.0.9)
timers (4.2.0) timers (4.2.0)
tty-color (0.4.3) tty-color (0.4.3)
tty-command (0.8.2) tty-command (0.8.2)
@ -622,24 +623,24 @@ GEM
unf (~> 0.1.0) unf (~> 0.1.0)
tzinfo (1.2.5) tzinfo (1.2.5)
thread_safe (~> 0.1) thread_safe (~> 0.1)
tzinfo-data (1.2018.9) tzinfo-data (1.2019.1)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.7.5) unf_ext (0.0.7.5)
unicode-display_width (1.4.1) unicode-display_width (1.5.0)
uniform_notifier (1.12.1) uniform_notifier (1.12.1)
warden (1.2.7) warden (1.2.8)
rack (>= 1.0) rack (>= 2.0.6)
webmock (3.5.1) webmock (3.5.1)
addressable (>= 2.3.6) addressable (>= 2.3.6)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff hashdiff
webpacker (3.5.5) webpacker (4.0.2)
activesupport (>= 4.2) activesupport (>= 4.2)
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 4.2) railties (>= 4.2)
webpush (0.3.6) webpush (0.3.7)
hkdf (~> 0.2) hkdf (~> 0.2)
jwt (~> 2.0) jwt (~> 2.0)
websocket-driver (0.7.0) websocket-driver (0.7.0)
@ -654,14 +655,14 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
active_model_serializers (~> 0.10) active_model_serializers (~> 0.10)
active_record_query_trace (~> 1.5) active_record_query_trace (~> 1.6)
addressable (~> 2.6) addressable (~> 2.6)
annotate (~> 2.7) annotate (~> 2.7)
aws-sdk-s3 (~> 1.30) aws-sdk-s3 (~> 1.36)
better_errors (~> 2.5) better_errors (~> 2.5)
binding_of_caller (~> 0.7) binding_of_caller (~> 0.7)
bootsnap (~> 1.3) bootsnap (~> 1.4)
brakeman (~> 4.4) brakeman (~> 4.5)
browser browser
bullet (~> 5.9) bullet (~> 5.9)
bundler-audit (~> 0.6) bundler-audit (~> 0.6)
@ -669,18 +670,18 @@ DEPENDENCIES
capistrano-rails (~> 1.4) capistrano-rails (~> 1.4)
capistrano-rbenv (~> 2.1) capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0) capistrano-yarn (~> 2.0)
capybara (~> 3.12) capybara (~> 3.16)
charlock_holmes (~> 0.7.6) charlock_holmes (~> 0.7.6)
chewy (~> 5.0) chewy (~> 5.0)
cld3 (~> 3.2.3) cld3 (~> 3.2.3)
climate_control (~> 0.2) climate_control (~> 0.2)
concurrent-ruby concurrent-ruby
derailed_benchmarks derailed_benchmarks
devise (~> 4.5) devise (~> 4.6)
devise-two-factor (~> 3.0) devise-two-factor (~> 3.0)
devise_pam_authenticatable2 (~> 9.2) devise_pam_authenticatable2 (~> 9.2)
doorkeeper (~> 5.0) doorkeeper (~> 5.0)
dotenv-rails (~> 2.6) dotenv-rails (~> 2.7)
fabrication (~> 2.20) fabrication (~> 2.20)
faker (~> 1.9) faker (~> 1.9)
fast_blank (~> 1.0) fast_blank (~> 1.0)
@ -709,7 +710,7 @@ DEPENDENCIES
makara (~> 0.4) makara (~> 0.4)
mario-redis-lock (~> 1.2) mario-redis-lock (~> 1.2)
memory_profiler memory_profiler
microformats (~> 4.0) microformats (~> 4.1)
mime-types (~> 3.2) mime-types (~> 3.2)
net-ldap (~> 0.10) net-ldap (~> 0.10)
nokogiri (~> 1.10) nokogiri (~> 1.10)
@ -722,20 +723,20 @@ DEPENDENCIES
ox (~> 2.10) ox (~> 2.10)
paperclip (~> 6.0) paperclip (~> 6.0)
paperclip-av-transcoder (~> 0.6) paperclip-av-transcoder (~> 0.6)
parallel_tests (~> 2.27) parallel_tests (~> 2.28)
pg (~> 1.1) pg (~> 1.1)
pghero (~> 2.2) pghero (~> 2.2)
pkg-config (~> 1.3) pkg-config (~> 1.3)
posix-spawn! posix-spawn!
premailer-rails premailer-rails
private_address_check (~> 0.5) private_address_check (~> 0.5)
pry-byebug (~> 3.6) pry-byebug (~> 3.7)
pry-rails (~> 0.3) pry-rails (~> 0.3)
puma (~> 3.12) puma (~> 3.12)
pundit (~> 2.0) pundit (~> 2.0)
rack-attack (~> 5.4) rack-attack (~> 5.4)
rack-cors (~> 1.0) rack-cors (~> 1.0)
rails (~> 5.2.2) rails (~> 5.2.3)
rails-controller-testing (~> 1.0) rails-controller-testing (~> 1.0)
rails-i18n (~> 5.1) rails-i18n (~> 5.1)
rails-settings-cached (~> 0.6) rails-settings-cached (~> 0.6)
@ -746,7 +747,7 @@ DEPENDENCIES
rqrcode (~> 0.10) rqrcode (~> 0.10)
rspec-rails (~> 3.8) rspec-rails (~> 3.8)
rspec-sidekiq (~> 3.0) rspec-sidekiq (~> 3.0)
rubocop (~> 0.63) rubocop (~> 0.67)
sanitize (~> 5.0) sanitize (~> 5.0)
scss_lint (~> 0.57) scss_lint (~> 0.57)
sidekiq (~> 5.2) sidekiq (~> 5.2)
@ -765,13 +766,13 @@ DEPENDENCIES
tty-command (~> 0.8) tty-command (~> 0.8)
tty-prompt (~> 0.18) tty-prompt (~> 0.18)
twitter-text (~> 1.14) twitter-text (~> 1.14)
tzinfo-data (~> 1.2018) tzinfo-data (~> 1.2019)
webmock (~> 3.5) webmock (~> 3.5)
webpacker (~> 3.5) webpacker (~> 4.0)
webpush webpush
RUBY VERSION RUBY VERSION
ruby 2.6.0p0 ruby 2.6.1p33
BUNDLED WITH BUNDLED WITH
1.17.3 1.17.3

13
Vagrantfile vendored
View File

@ -44,7 +44,18 @@ sudo apt-get install \
# Install rvm # Install rvm
read RUBY_VERSION < .ruby-version read RUBY_VERSION < .ruby-version
gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
gpg_command="gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB"
$($gpg_command)
if [ $? -ne 0 ];then
echo "GPG command failed, This prevented RVM from installing."
echo "Retrying once..." && $($gpg_command)
if [ $? -ne 0 ];then
echo "GPG failed for the second time, please ensure network connectivity."
echo "Exiting..." && exit 1
fi
fi
curl -sSL https://raw.githubusercontent.com/rvm/rvm/stable/binscripts/rvm-installer | bash -s stable --ruby=$RUBY_VERSION curl -sSL https://raw.githubusercontent.com/rvm/rvm/stable/binscripts/rvm-installer | bash -s stable --ruby=$RUBY_VERSION
source /home/vagrant/.rvm/scripts/rvm source /home/vagrant/.rvm/scripts/rvm

View File

@ -48,6 +48,7 @@ class StatusesIndex < Chewy::Index
end end
root date_detection: false do root date_detection: false do
field :id, type: 'long'
field :account_id, type: 'long' field :account_id, type: 'long'
field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).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
@ -55,7 +56,6 @@ class StatusesIndex < Chewy::Index
end end
field :searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) } field :searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) }
field :created_at, type: 'date'
end end
end end
end end

View File

@ -1,26 +1,25 @@
# frozen_string_literal: true # frozen_string_literal: true
class AboutController < ApplicationController class AboutController < ApplicationController
before_action :set_body_classes layout 'public'
before_action :set_instance_presenter, only: [:show, :more, :terms] before_action :set_instance_presenter, only: [:show, :more, :terms]
def show def show
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) @hide_navbar = true
@initial_state_json = serializable_resource.to_json
end end
def more def more; end
render layout: 'public'
end
def terms def terms; end
render layout: 'public'
end
private private
def new_user def new_user
User.new.tap(&:build_account) User.new.tap do |user|
user.build_account
user.build_invite_request
end
end end
helper_method :new_user helper_method :new_user
@ -28,15 +27,4 @@ class AboutController < ApplicationController
def set_instance_presenter def set_instance_presenter
@instance_presenter = InstancePresenter.new @instance_presenter = InstancePresenter.new
end end
def set_body_classes
@body_classes = 'with-modals'
end
def initial_state_params
{
settings: { known_fediverse: Setting.show_known_fediverse_at_about_page },
token: current_session&.token,
}
end
end end

View File

@ -10,6 +10,8 @@ class AccountsController < ApplicationController
def show def show
respond_to do |format| respond_to do |format|
format.html do format.html do
mark_cacheable! unless user_signed_in?
@body_classes = 'with-modals' @body_classes = 'with-modals'
@pinned_statuses = [] @pinned_statuses = []
@endorsed_accounts = @account.endorsed_accounts.to_a.sample(4) @endorsed_accounts = @account.endorsed_accounts.to_a.sample(4)
@ -30,17 +32,21 @@ class AccountsController < ApplicationController
end end
format.atom do format.atom do
mark_cacheable!
@entries = @account.stream_entries.where(hidden: false).with_includes.without_local_only.paginate_by_max_id(PAGE_SIZE, params[:max_id], params[:since_id]) @entries = @account.stream_entries.where(hidden: false).with_includes.without_local_only.paginate_by_max_id(PAGE_SIZE, params[:max_id], params[:since_id])
render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? })) render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? }))
end end
format.rss do format.rss do
mark_cacheable!
@statuses = cache_collection(default_statuses.without_local_only.without_reblogs.without_replies.limit(PAGE_SIZE), Status) @statuses = cache_collection(default_statuses.without_local_only.without_reblogs.without_replies.limit(PAGE_SIZE), Status)
render xml: RSS::AccountSerializer.render(@account, @statuses) render xml: RSS::AccountSerializer.render(@account, @statuses)
end end
format.json do format.json do
skip_session! mark_cacheable!
render_cached_json(['activitypub', 'actor', @account], content_type: 'application/activity+json') do render_cached_json(['activitypub', 'actor', @account], content_type: 'application/activity+json') do
ActiveModelSerializers::SerializableResource.new(@account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter) ActiveModelSerializers::SerializableResource.new(@account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter)
@ -52,11 +58,12 @@ class AccountsController < ApplicationController
private private
def show_pinned_statuses? def show_pinned_statuses?
[replies_requested?, media_requested?, params[:max_id].present?, params[:min_id].present?].none? [replies_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none?
end end
def filtered_statuses def filtered_statuses
default_statuses.tap do |statuses| default_statuses.tap do |statuses|
statuses.merge!(hashtag_scope) if tag_requested?
statuses.merge!(only_media_scope) if media_requested? statuses.merge!(only_media_scope) if media_requested?
statuses.merge!(no_replies_scope) unless replies_requested? statuses.merge!(no_replies_scope) unless replies_requested?
end end
@ -82,12 +89,21 @@ class AccountsController < ApplicationController
Status.without_replies Status.without_replies
end end
def set_account def hashtag_scope
@account = Account.find_local!(params[:username]) tag = Tag.find_normalized(params[:tag])
if tag
Status.tagged_with(tag.id)
else
Status.none
end
end
def username_param
params[:username]
end end
def older_url def older_url
::Rails.logger.info("older: max_id #{@statuses.last.id}, url #{pagination_url(max_id: @statuses.last.id)}")
pagination_url(max_id: @statuses.last.id) pagination_url(max_id: @statuses.last.id)
end end
@ -96,7 +112,9 @@ class AccountsController < ApplicationController
end end
def pagination_url(max_id: nil, min_id: nil) def pagination_url(max_id: nil, min_id: nil)
if media_requested? if tag_requested?
short_account_tag_url(@account, params[:tag], max_id: max_id, min_id: min_id)
elsif media_requested?
short_account_media_url(@account, max_id: max_id, min_id: min_id) short_account_media_url(@account, max_id: max_id, min_id: min_id)
elsif replies_requested? elsif replies_requested?
short_account_with_replies_url(@account, max_id: max_id, min_id: min_id) short_account_with_replies_url(@account, max_id: max_id, min_id: min_id)
@ -113,6 +131,10 @@ class AccountsController < ApplicationController
request.path.ends_with?('/with_replies') request.path.ends_with?('/with_replies')
end end
def tag_requested?
request.path.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
end
def filtered_status_page(params) def filtered_status_page(params)
if params[:min_id].present? if params[:min_id].present?
filtered_statuses.paginate_by_min_id(PAGE_SIZE, params[:min_id]).reverse filtered_statuses.paginate_by_min_id(PAGE_SIZE, params[:min_id]).reverse

View File

@ -6,13 +6,19 @@ class ActivityPub::CollectionsController < Api::BaseController
before_action :set_account before_action :set_account
before_action :set_size before_action :set_size
before_action :set_statuses before_action :set_statuses
before_action :set_cache_headers
def show def show
render json: collection_presenter, skip_session!
serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter, render_cached_json(['activitypub', 'collection', @account, params[:id]], content_type: 'application/activity+json') do
content_type: 'application/activity+json', ActiveModelSerializers::SerializableResource.new(
skip_activities: true collection_presenter,
serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter,
skip_activities: true
)
end
end end
private private

View File

@ -2,11 +2,14 @@
class ActivityPub::InboxesController < Api::BaseController class ActivityPub::InboxesController < Api::BaseController
include SignatureVerification include SignatureVerification
include JsonLdHelper
before_action :set_account before_action :set_account
def create def create
if signed_request_account if unknown_deleted_account?
head 202
elsif signed_request_account
upgrade_account upgrade_account
process_payload process_payload
head 202 head 202
@ -17,12 +20,22 @@ class ActivityPub::InboxesController < Api::BaseController
private private
def unknown_deleted_account?
json = Oj.load(body, mode: :strict)
json['type'] == 'Delete' && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists?
rescue Oj::ParseError
false
end
def set_account def set_account
@account = Account.find_local!(params[:account_username]) if params[:account_username] @account = Account.find_local!(params[:account_username]) if params[:account_username]
end end
def body def body
@body ||= request.body.read return @body if defined?(@body)
@body = request.body.read.force_encoding('UTF-8')
request.body.rewind if request.body.respond_to?(:rewind)
@body
end end
def upgrade_account def upgrade_account
@ -36,6 +49,6 @@ class ActivityPub::InboxesController < Api::BaseController
end end
def process_payload def process_payload
ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8'), @account&.id) ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body, @account&.id)
end end
end end

View File

@ -7,8 +7,14 @@ class ActivityPub::OutboxesController < Api::BaseController
before_action :set_account before_action :set_account
before_action :set_statuses before_action :set_statuses
before_action :set_cache_headers
def show def show
unless page_requested?
skip_session!
expires_in 1.minute, public: true
end
render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end end

View File

@ -2,9 +2,9 @@
module Admin module Admin
class AccountsController < BaseController class AccountsController < BaseController
before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize] before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize, :approve, :reject]
before_action :require_remote_account!, only: [:subscribe, :unsubscribe, :redownload] before_action :require_remote_account!, only: [:subscribe, :unsubscribe, :redownload]
before_action :require_local_account!, only: [:enable, :memorialize] before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject]
def index def index
authorize :account, :index? authorize :account, :index?
@ -45,6 +45,18 @@ module Admin
redirect_to admin_account_path(@account.id) redirect_to admin_account_path(@account.id)
end end
def approve
authorize @account.user, :approve?
@account.user.approve!
redirect_to admin_accounts_path(pending: '1')
end
def reject
authorize @account.user, :reject?
SuspendAccountService.new.call(@account, including_user: true, destroy: true, skip_distribution: true)
redirect_to admin_accounts_path(pending: '1')
end
def unsilence def unsilence
authorize @account, :unsilence? authorize @account, :unsilence?
@account.unsilence! @account.unsilence!
@ -114,6 +126,7 @@ module Admin
:remote, :remote,
:by_domain, :by_domain,
:active, :active,
:pending,
:silenced, :silenced,
:suspended, :suspended,
:username, :username,

View File

@ -5,6 +5,9 @@ module Admin
before_action :set_custom_emoji, except: [:index, :new, :create] before_action :set_custom_emoji, except: [:index, :new, :create]
before_action :set_filter_params before_action :set_filter_params
include ObfuscateFilename
obfuscate_filename [:custom_emoji, :image]
def index def index
authorize :custom_emoji, :index? authorize :custom_emoji, :index?
@custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]) @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page])

View File

@ -10,7 +10,7 @@ module Admin
@interactions_week = Redis.current.get("activity:interactions:#{current_week}") || 0 @interactions_week = Redis.current.get("activity:interactions:#{current_week}") || 0
@relay_enabled = Relay.enabled.exists? @relay_enabled = Relay.enabled.exists?
@single_user_mode = Rails.configuration.x.single_user_mode @single_user_mode = Rails.configuration.x.single_user_mode
@registrations_enabled = Setting.open_registrations @registrations_enabled = Setting.registrations_mode != 'none'
@deletions_enabled = Setting.open_deletion @deletions_enabled = Setting.open_deletion
@invites_enabled = Setting.min_invite_role == 'user' @invites_enabled = Setting.min_invite_role == 'user'
@search_enabled = Chewy.enabled? @search_enabled = Chewy.enabled?
@ -29,6 +29,7 @@ module Admin
@hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true' @hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true'
@trending_hashtags = TrendingTags.get(7) @trending_hashtags = TrendingTags.get(7)
@profile_directory = Setting.profile_directory @profile_directory = Setting.profile_directory
@timeline_preview = Setting.timeline_preview
end end
private private

View File

@ -38,7 +38,7 @@ module Admin
end end
def filter_params def filter_params
params.permit(:limited) params.permit(:limited, :by_domain)
end end
end end
end end

View File

@ -0,0 +1,52 @@
# frozen_string_literal: true
module Admin
class PendingAccountsController < BaseController
before_action :set_accounts, only: :index
def index
@form = Form::AccountBatch.new
end
def batch
@form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
ensure
redirect_to admin_pending_accounts_path(current_params)
end
def approve_all
Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.pluck(:account_id), action: 'approve').save
redirect_to admin_pending_accounts_path(current_params)
end
def reject_all
Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.pluck(:account_id), action: 'reject').save
redirect_to admin_pending_accounts_path(current_params)
end
private
def set_accounts
@accounts = Account.joins(:user).merge(User.pending.recent).includes(user: :invite_request).page(params[:page])
end
def form_account_batch_params
params.require(:form_account_batch).permit(:action, account_ids: [])
end
def action_from_button
if params[:approve]
'approve'
elsif params[:reject]
'reject'
end
end
def current_params
params.slice(:page).permit(:page)
end
end
end

View File

@ -10,6 +10,10 @@ module Admin
@form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button)) @form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button))
flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
redirect_to admin_report_path(@report)
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.statuses.no_status_selected')
redirect_to admin_report_path(@report) redirect_to admin_report_path(@report)
end end

View File

@ -2,85 +2,29 @@
module Admin module Admin
class SettingsController < BaseController class SettingsController < BaseController
ADMIN_SETTINGS = %w(
site_contact_username
site_contact_email
site_title
site_short_description
site_description
site_extended_description
site_terms
open_registrations
closed_registrations_message
open_deletion
timeline_preview
show_staff_badge
bootstrap_timeline_accounts
theme
thumbnail
hero
mascot
min_invite_role
activity_api_enabled
peers_api_enabled
show_known_fediverse_at_about_page
preview_sensitive_media
custom_css
profile_directory
).freeze
BOOLEAN_SETTINGS = %w(
open_registrations
open_deletion
timeline_preview
show_staff_badge
activity_api_enabled
peers_api_enabled
show_known_fediverse_at_about_page
preview_sensitive_media
profile_directory
).freeze
UPLOAD_SETTINGS = %w(
thumbnail
hero
mascot
).freeze
def edit def edit
authorize :settings, :show? authorize :settings, :show?
@admin_settings = Form::AdminSettings.new @admin_settings = Form::AdminSettings.new
end end
def update def update
authorize :settings, :update? authorize :settings, :update?
settings_params.each do |key, value| @admin_settings = Form::AdminSettings.new(settings_params)
if UPLOAD_SETTINGS.include?(key)
upload = SiteUpload.where(var: key).first_or_initialize(var: key)
upload.update(file: value)
else
setting = Setting.where(var: key).first_or_initialize(var: key)
setting.update(value: value_for_update(key, value))
end
end
flash[:notice] = I18n.t('generic.changes_saved_msg') if @admin_settings.save
redirect_to edit_admin_settings_path flash[:notice] = I18n.t('generic.changes_saved_msg')
redirect_to edit_admin_settings_path
else
render :edit
end
end end
private private
def settings_params def settings_params
params.require(:form_admin_settings).permit(ADMIN_SETTINGS) params.require(:form_admin_settings).permit(*Form::AdminSettings::KEYS)
end
def value_for_update(key, value)
if BOOLEAN_SETTINGS.include?(key)
value == '1'
else
value
end
end end
end end
end end

View File

@ -73,7 +73,9 @@ class Api::BaseController < ApplicationController
elsif current_user.disabled? elsif current_user.disabled?
render json: { error: 'Your login is currently disabled' }, status: 403 render json: { error: 'Your login is currently disabled' }, status: 403
elsif !current_user.confirmed? elsif !current_user.confirmed?
render json: { error: 'Email confirmation is not completed' }, status: 403 render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403
elsif !current_user.approved?
render json: { error: 'Your login is currently pending approval' }, status: 403
else else
set_user_activity set_user_activity
end end

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
class Api::ProofsController < Api::BaseController
before_action :set_account
before_action :set_provider
before_action :check_account_approval
before_action :check_account_suspension
def index
render json: @account, serializer: @provider.serializer_class
end
private
def set_provider
@provider = ProofProvider.find(params[:provider]) || raise(ActiveRecord::RecordNotFound)
end
def set_account
@account = Account.find_local!(params[:username])
end
def check_account_approval
not_found if @account.user_pending?
end
def check_account_suspension
gone if @account.suspended?
end
end

View File

@ -19,11 +19,15 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
end end
def load_accounts def load_accounts
return [] if @account.user_hides_network? && current_account.id != @account.id return [] if hide_results?
default_accounts.merge(paginated_follows).to_a default_accounts.merge(paginated_follows).to_a
end end
def hide_results?
(@account.user_hides_network? && current_account.id != @account.id) || (current_account && @account.blocking?(current_account))
end
def default_accounts def default_accounts
Account.includes(:active_relationships, :account_stat).references(:active_relationships) Account.includes(:active_relationships, :account_stat).references(:active_relationships)
end end

View File

@ -19,11 +19,15 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
end end
def load_accounts def load_accounts
return [] if @account.user_hides_network? && current_account.id != @account.id return [] if hide_results?
default_accounts.merge(paginated_follows).to_a default_accounts.merge(paginated_follows).to_a
end end
def hide_results?
(@account.user_hides_network? && current_account.id != @account.id) || (current_account && @account.blocking?(current_account))
end
def default_accounts def default_accounts
Account.includes(:passive_relationships, :account_stat).references(:passive_relationships) Account.includes(:passive_relationships, :account_stat).references(:passive_relationships)
end end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
class Api::V1::Accounts::IdentityProofsController < Api::BaseController
before_action :require_user!
before_action :set_account
respond_to :json
def index
@proofs = @account.identity_proofs.active
render json: @proofs, each_serializer: REST::IdentityProofSerializer
end
private
def set_account
@account = Account.find(params[:account_id])
end
end

View File

@ -16,10 +16,11 @@ class Api::V1::Accounts::SearchController < Api::BaseController
def account_search def account_search
AccountSearchService.new.call( AccountSearchService.new.call(
params[:q], params[:q],
limit_param(DEFAULT_ACCOUNTS_LIMIT),
current_account, current_account,
limit: limit_param(DEFAULT_ACCOUNTS_LIMIT),
resolve: truthy_param?(:resolve), resolve: truthy_param?(:resolve),
following: truthy_param?(:following) following: truthy_param?(:following),
offset: params[:offset]
) )
end end
end end

View File

@ -33,6 +33,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
statuses.merge!(only_media_scope) if truthy_param?(:only_media) statuses.merge!(only_media_scope) if truthy_param?(:only_media)
statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies) statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs) statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
statuses.merge!(hashtag_scope) if params[:tagged].present?
statuses statuses
end end
@ -50,9 +51,9 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
# Also, Avoid getting slow by not narrowing down by `statuses.account_id`. # Also, Avoid getting slow by not narrowing down by `statuses.account_id`.
# When narrowing down by `statuses.account_id`, `index_statuses_20180106` will be used # When narrowing down by `statuses.account_id`, `index_statuses_20180106` will be used
# and the table will be joined by `Merge Semi Join`, so the query will be slow. # and the table will be joined by `Merge Semi Join`, so the query will be slow.
Status.joins(:media_attachments).merge(@account.media_attachments).permitted_for(@account, current_account) @account.statuses.joins(:media_attachments).merge(@account.media_attachments).permitted_for(@account, current_account)
.paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) .paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
.reorder(id: :desc).distinct(:id).pluck(:id) .reorder(id: :desc).distinct(:id).pluck(:id)
end end
def pinned_scope def pinned_scope
@ -67,6 +68,16 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
Status.without_reblogs Status.without_reblogs
end end
def hashtag_scope
tag = Tag.find_normalized(params[:tagged])
if tag
Status.tagged_with(tag.id)
else
Status.none
end
end
def pagination_params(core_params) def pagination_params(core_params)
params.slice(:limit, :only_media, :exclude_replies).permit(:limit, :only_media, :exclude_replies).merge(core_params) params.slice(:limit, :only_media, :exclude_replies).permit(:limit, :only_media, :exclude_replies).merge(core_params)
end end

View File

@ -80,6 +80,10 @@ class Api::V1::AccountsController < Api::BaseController
end end
def check_enabled_registrations def check_enabled_registrations
forbidden if single_user_mode? || !Setting.open_registrations forbidden if single_user_mode? || !allowed_registrations?
end
def allowed_registrations?
Setting.registrations_mode != 'none'
end end
end end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
class Api::V1::Polls::VotesController < Api::BaseController
include Authorization
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
before_action :require_user!
before_action :set_poll
respond_to :json
def create
VoteService.new.call(current_account, @poll, vote_params[:choices])
render json: @poll, serializer: REST::PollSerializer
end
private
def set_poll
@poll = Poll.attached.find(params[:poll_id])
authorize @poll.status, :show?
rescue Mastodon::NotPermittedError
raise ActiveRecord::RecordNotFound
end
def vote_params
params.permit(choices: [])
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class Api::V1::PollsController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, only: :show
respond_to :json
def show
@poll = Poll.attached.find(params[:id])
ActivityPub::FetchRemotePollService.new.call(@poll, current_account) if user_signed_in? && @poll.possibly_stale?
render json: @poll, serializer: REST::PollSerializer, include_results: true
end
end

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
class Api::V1::PreferencesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }
before_action :require_user!
respond_to :json
def index
render json: current_account, serializer: REST::PreferencesSerializer
end
end

View File

@ -3,7 +3,7 @@
class Api::V1::SearchController < Api::BaseController class Api::V1::SearchController < Api::BaseController
include Authorization include Authorization
RESULTS_LIMIT = 5 RESULTS_LIMIT = 20
before_action -> { doorkeeper_authorize! :read, :'read:search' } before_action -> { doorkeeper_authorize! :read, :'read:search' }
before_action :require_user! before_action :require_user!
@ -11,30 +11,22 @@ class Api::V1::SearchController < Api::BaseController
respond_to :json respond_to :json
def index def index
@search = Search.new(search) @search = Search.new(search_results)
render json: @search, serializer: REST::SearchSerializer render json: @search, serializer: REST::SearchSerializer
end end
private private
def search
search_results.tap do |search|
search[:statuses].keep_if do |status|
begin
authorize status, :show?
rescue Mastodon::NotPermittedError
false
end
end
end
end
def search_results def search_results
SearchService.new.call( SearchService.new.call(
params[:q], params[:q],
RESULTS_LIMIT, current_account,
truthy_param?(:resolve), limit_param(RESULTS_LIMIT),
current_account search_params.merge(resolve: truthy_param?(:resolve))
) )
end end
def search_params
params.permit(:type, :offset, :min_id, :max_id, :account_id)
end
end end

View File

@ -9,7 +9,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
respond_to :json respond_to :json
def create def create
@status = ReblogService.new.call(current_user.account, status_for_reblog) @status = ReblogService.new.call(current_user.account, status_for_reblog, reblog_params)
render json: @status, serializer: REST::StatusSerializer render json: @status, serializer: REST::StatusSerializer
end end
@ -32,4 +32,8 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
def status_for_destroy def status_for_destroy
current_user.account.statuses.where(reblog_of_id: params[:status_id]).first! current_user.account.statuses.where(reblog_of_id: params[:status_id]).first!
end end
def reblog_params
params.permit(:visibility)
end
end end

View File

@ -53,6 +53,7 @@ class Api::V1::StatusesController < Api::BaseController
visibility: status_params[:visibility], visibility: status_params[:visibility],
scheduled_at: status_params[:scheduled_at], scheduled_at: status_params[:scheduled_at],
application: doorkeeper_token.application, application: doorkeeper_token.application,
poll: status_params[:poll],
idempotency: request.headers['Idempotency-Key'], idempotency: request.headers['Idempotency-Key'],
local_only: status_params[:local_only]) local_only: status_params[:local_only])
@ -74,12 +75,26 @@ class Api::V1::StatusesController < Api::BaseController
@status = Status.find(params[:id]) @status = Status.find(params[:id])
authorize @status, :show? authorize @status, :show?
rescue Mastodon::NotPermittedError rescue Mastodon::NotPermittedError
# Reraise in order to get a 404 instead of a 403 error code
raise ActiveRecord::RecordNotFound raise ActiveRecord::RecordNotFound
end end
def status_params def status_params
params.permit(:status, :in_reply_to_id, :sensitive, :spoiler_text, :visibility, :scheduled_at, :local_only, media_ids: []) params.permit(
:status,
:in_reply_to_id,
:sensitive,
:spoiler_text,
:visibility,
:scheduled_at,
:local_only,
media_ids: [],
poll: [
:multiple,
:hide_totals,
:expires_in,
options: [],
]
)
end end
def pagination_params(core_params) def pagination_params(core_params)

View File

@ -14,7 +14,7 @@ class Api::V1::Timelines::TagController < Api::BaseController
private private
def load_tag def load_tag
@tag = Tag.find_by(name: params[:id].downcase) @tag = Tag.find_normalized(params[:id])
end end
def load_statuses def load_statuses

View File

@ -2,7 +2,7 @@
class Api::V2::SearchController < Api::V1::SearchController class Api::V2::SearchController < Api::V1::SearchController
def index def index
@search = Search.new(search) @search = Search.new(search_results)
render json: @search, serializer: REST::V2::SearchSerializer render json: @search, serializer: REST::V2::SearchSerializer
end end
end end

View File

@ -151,6 +151,11 @@ class ApplicationController < ActionController::Base
response.headers['Vary'] = 'Accept' response.headers['Vary'] = 'Accept'
end end
def mark_cacheable!
skip_session!
expires_in 0, public: true
end
def skip_session! def skip_session!
request.session_options[:skip] = true request.session_options[:skip] = true
end end

View File

@ -10,6 +10,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :set_instance_presenter, only: [:new, :create, :update] before_action :set_instance_presenter, only: [:new, :create, :update]
before_action :set_body_classes, only: [:new, :create, :edit, :update] before_action :set_body_classes, only: [:new, :create, :edit, :update]
def new
super(&:build_invite_request)
end
def destroy def destroy
not_found not_found
end end
@ -24,17 +28,17 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def build_resource(hash = nil) def build_resource(hash = nil)
super(hash) super(hash)
resource.locale = I18n.locale resource.locale = I18n.locale
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
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
def configure_sign_up_params def configure_sign_up_params
devise_parameter_sanitizer.permit(:sign_up) do |u| devise_parameter_sanitizer.permit(:sign_up) do |u|
u.permit({ account_attributes: [:username] }, :email, :password, :password_confirmation, :invite_code) u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code)
end end
end end
@ -65,7 +69,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end end
def allowed_registrations? def allowed_registrations?
Setting.open_registrations || @invite&.valid_for_use? Setting.registrations_mode != 'none' || @invite&.valid_for_use?
end end
def invite_code def invite_code

View File

@ -7,16 +7,18 @@ module AccountControllerConcern
included do included do
layout 'public' layout 'public'
before_action :set_account before_action :set_account
before_action :check_account_approval
before_action :check_account_suspension
before_action :set_instance_presenter before_action :set_instance_presenter
before_action :set_link_headers before_action :set_link_headers
before_action :check_account_suspension
end end
private private
def set_account def set_account
@account = Account.find_local!(params[:account_username]) @account = Account.find_local!(username_param)
end end
def set_instance_presenter def set_instance_presenter
@ -33,6 +35,10 @@ module AccountControllerConcern
) )
end end
def username_param
params[:account_username]
end
def webfinger_account_link def webfinger_account_link
[ [
webfinger_account_url, webfinger_account_url,
@ -58,7 +64,15 @@ module AccountControllerConcern
webfinger_url(resource: @account.to_webfinger_s) webfinger_url(resource: @account.to_webfinger_s)
end end
def check_account_approval
not_found if @account.user_pending?
end
def check_account_suspension def check_account_suspension
gone if @account.suspended? if @account.suspended?
skip_session!
expires_in(3.minutes, public: true)
gone
end
end end
end end

View File

@ -32,7 +32,7 @@ class DirectoriesController < ApplicationController
end end
def set_accounts def set_accounts
@accounts = Account.discoverable.page(params[:page]).per(40).tap do |query| @accounts = Account.discoverable.by_recent_status.page(params[:page]).per(40).tap do |query|
query.merge!(Account.tagged_with(@tag.id)) if @tag query.merge!(Account.tagged_with(@tag.id)) if @tag
end end
end end

View File

@ -3,9 +3,13 @@
class FollowerAccountsController < ApplicationController class FollowerAccountsController < ApplicationController
include AccountControllerConcern include AccountControllerConcern
before_action :set_cache_headers
def index def index
respond_to do |format| respond_to do |format|
format.html do format.html do
mark_cacheable! unless user_signed_in?
next if @account.user_hides_network? next if @account.user_hides_network?
follows follows
@ -15,6 +19,11 @@ class FollowerAccountsController < ApplicationController
format.json do format.json do
raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network? raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network?
if params[:page].blank?
skip_session!
expires_in 3.minutes, public: true
end
render json: collection_presenter, render json: collection_presenter,
serializer: ActivityPub::CollectionSerializer, serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter, adapter: ActivityPub::Adapter,

View File

@ -3,9 +3,13 @@
class FollowingAccountsController < ApplicationController class FollowingAccountsController < ApplicationController
include AccountControllerConcern include AccountControllerConcern
before_action :set_cache_headers
def index def index
respond_to do |format| respond_to do |format|
format.html do format.html do
mark_cacheable! unless user_signed_in?
next if @account.user_hides_network? next if @account.user_hides_network?
follows follows
@ -15,6 +19,11 @@ class FollowingAccountsController < ApplicationController
format.json do format.json do
raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network? raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network?
if params[:page].blank?
skip_session!
expires_in 3.minutes, public: true
end
render json: collection_presenter, render json: collection_presenter,
serializer: ActivityPub::CollectionSerializer, serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter, adapter: ActivityPub::Adapter,

View File

@ -50,7 +50,7 @@ class HomeController < ApplicationController
push_subscription: current_account.user.web_push_subscription(current_session), push_subscription: current_account.user.web_push_subscription(current_session),
current_account: current_account, current_account: current_account,
token: current_session.token, token: current_session.token,
admin: Account.find_local(Setting.site_contact_username), admin: Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')),
} }
end end

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
class PublicTimelinesController < ApplicationController
layout 'public'
before_action :check_enabled
before_action :set_body_classes
before_action :set_instance_presenter
def show
respond_to do |format|
format.html do
@initial_state_json = ActiveModelSerializers::SerializableResource.new(
InitialStatePresenter.new(settings: { known_fediverse: Setting.show_known_fediverse_at_about_page }, token: current_session&.token),
serializer: InitialStateSerializer
).to_json
end
end
end
private
def check_enabled
raise ActiveRecord::RecordNotFound unless Setting.timeline_preview
end
def set_body_classes
@body_classes = 'with-modals'
end
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
end

View File

@ -0,0 +1,104 @@
# frozen_string_literal: true
class RelationshipsController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_accounts, only: :show
before_action :set_body_classes
helper_method :following_relationship?, :followed_by_relationship?, :mutual_relationship?
def show
@form = Form::AccountBatch.new
end
def update
@form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
# Do nothing
ensure
redirect_to relationships_path(current_params)
end
private
def set_accounts
@accounts = relationships_scope.page(params[:page]).per(40)
end
def relationships_scope
scope = begin
if following_relationship?
current_account.following.eager_load(:account_stat).reorder(nil)
else
current_account.followers.eager_load(:account_stat).reorder(nil)
end
end
scope.merge!(Follow.recent) if params[:order].blank? || params[:order] == 'recent'
scope.merge!(Account.by_recent_status) if params[:order] == 'active'
scope.merge!(mutual_relationship_scope) if mutual_relationship?
scope.merge!(moved_account_scope) if params[:status] == 'moved'
scope.merge!(primary_account_scope) if params[:status] == 'primary'
scope.merge!(by_domain_scope) if params[:by_domain].present?
scope.merge!(dormant_account_scope) if params[:activity] == 'dormant'
scope
end
def mutual_relationship_scope
Account.where(id: current_account.following)
end
def moved_account_scope
Account.where.not(moved_to_account_id: nil)
end
def primary_account_scope
Account.where(moved_to_account_id: nil)
end
def dormant_account_scope
AccountStat.where(last_status_at: nil).or(AccountStat.where(AccountStat.arel_table[:last_status_at].lt(1.month.ago)))
end
def by_domain_scope
Account.where(domain: params[:by_domain])
end
def form_account_batch_params
params.require(:form_account_batch).permit(:action, account_ids: [])
end
def following_relationship?
params[:relationship].blank? || params[:relationship] == 'following'
end
def mutual_relationship?
params[:relationship] == 'mutual'
end
def followed_by_relationship?
params[:relationship] == 'followed_by'
end
def current_params
params.slice(:page, :status, :relationship, :by_domain, :activity, :order).permit(:page, :status, :relationship, :by_domain, :activity, :order)
end
def action_from_button
if params[:unfollow]
'unfollow'
elsif params[:remove_from_followers]
'remove_from_followers'
elsif params[:block_domains]
'block_domains'
end
end
def set_body_classes
@body_classes = 'admin'
end
end

View File

@ -13,11 +13,25 @@ class Settings::ExportsController < Settings::BaseController
end end
def create def create
authorize :backup, :create? raise Mastodon::NotPermittedError unless user_signed_in?
backup = nil
RedisLock.acquire(lock_options) do |lock|
if lock.acquired?
authorize :backup, :create?
backup = current_user.backups.create!
else
raise Mastodon::RaceConditionError
end
end
backup = current_user.backups.create!
BackupWorker.perform_async(backup.id) BackupWorker.perform_async(backup.id)
redirect_to settings_export_path redirect_to settings_export_path
end end
def lock_options
{ redis: Redis.current, key: "backup:#{current_user.id}" }
end
end end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
class Settings::FeaturedTagsController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
before_action :set_featured_tags, only: :index
before_action :set_featured_tag, except: [:index, :create]
before_action :set_most_used_tags, only: :index
def index
@featured_tag = FeaturedTag.new
end
def create
@featured_tag = current_account.featured_tags.new(featured_tag_params)
@featured_tag.reset_data
if @featured_tag.save
redirect_to settings_featured_tags_path
else
set_featured_tags
set_most_used_tags
render :index
end
end
def destroy
@featured_tag.destroy!
redirect_to settings_featured_tags_path
end
private
def set_featured_tag
@featured_tag = current_account.featured_tags.find(params[:id])
end
def set_featured_tags
@featured_tags = current_account.featured_tags.order(statuses_count: :desc).reject(&:new_record?)
end
def set_most_used_tags
@most_used_tags = Tag.most_used(current_account).where.not(id: @featured_tags.map(&:id)).limit(10)
end
def featured_tag_params
params.require(:featured_tag).permit(:name)
end
end

View File

@ -1,28 +0,0 @@
# frozen_string_literal: true
class Settings::FollowerDomainsController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
def show
@account = current_account
@domains = current_account.followers.reorder(Arel.sql('MIN(follows.id) DESC')).group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10)
end
def update
domains = bulk_params[:select] || []
AfterAccountDomainBlockWorker.push_bulk(domains) do |domain|
[current_account.id, domain]
end
redirect_to settings_follower_domains_path, notice: I18n.t('followers.success', count: domains.size)
end
private
def bulk_params
params.permit(select: [])
end
end

View File

@ -0,0 +1,63 @@
# frozen_string_literal: true
class Settings::IdentityProofsController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
before_action :check_required_params, only: :new
def index
@proofs = AccountIdentityProof.where(account: current_account).order(provider: :asc, provider_username: :asc)
@proofs.each(&:refresh!)
end
def new
@proof = current_account.identity_proofs.new(
token: params[:token],
provider: params[:provider],
provider_username: params[:provider_username]
)
if current_account.username.casecmp(params[:username]).zero?
render layout: 'auth'
else
flash[:alert] = I18n.t('identity_proofs.errors.wrong_user', proving: params[:username], current: current_account.username)
redirect_to settings_identity_proofs_path
end
end
def create
@proof = current_account.identity_proofs.where(provider: resource_params[:provider], provider_username: resource_params[:provider_username]).first_or_initialize(resource_params)
@proof.token = resource_params[:token]
if @proof.save
PostStatusService.new.call(current_user.account, text: post_params[:status_text]) if publish_proof?
redirect_to @proof.on_success_path(params[:user_agent])
else
flash[:alert] = I18n.t('identity_proofs.errors.failed', provider: @proof.provider.capitalize)
redirect_to settings_identity_proofs_path
end
end
private
def check_required_params
redirect_to settings_identity_proofs_path unless [:provider, :provider_username, :username, :token].all? { |k| params[k].present? }
end
def resource_params
params.require(:account_identity_proof).permit(:provider, :provider_username, :token)
end
def publish_proof?
ActiveModel::Type::Boolean.new.cast(post_params[:post_status])
end
def post_params
params.require(:account_identity_proof).permit(:post_status, :status_text)
end
def set_body_classes
@body_classes = ''
end
end

View File

@ -49,7 +49,8 @@ class Settings::PreferencesController < Settings::BaseController
:setting_theme, :setting_theme,
:setting_hide_network, :setting_hide_network,
:setting_aggregate_reblogs, :setting_aggregate_reblogs,
notification_emails: %i(follow follow_request reblog favourite mention digest report), :setting_show_application,
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
interactions: %i(must_be_follower must_be_following) interactions: %i(must_be_follower must_be_following)
) )
end end

View File

@ -32,6 +32,6 @@ class Settings::ProfilesController < Settings::BaseController
end end
def set_account def set_account
@account = current_user.account @account = current_account
end end
end end

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Settings::SessionsController < Settings::BaseController class Settings::SessionsController < Settings::BaseController
before_action :authenticate_user!
before_action :set_session, only: :destroy before_action :set_session, only: :destroy
def destroy def destroy

View File

@ -21,7 +21,7 @@ class SharesController < ApplicationController
push_subscription: current_account.user.web_push_subscription(current_session), push_subscription: current_account.user.web_push_subscription(current_session),
current_account: current_account, current_account: current_account,
token: current_session.token, token: current_session.token,
admin: Account.find_local(Setting.site_contact_username), admin: Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')),
text: text, text: text,
} }
end end

View File

@ -18,6 +18,7 @@ class StatusesController < ApplicationController
before_action :redirect_to_original, only: [:show] before_action :redirect_to_original, only: [:show]
before_action :set_referrer_policy_header, only: [:show] before_action :set_referrer_policy_header, only: [:show]
before_action :set_cache_headers before_action :set_cache_headers
before_action :set_replies, only: [:replies]
content_security_policy only: :embed do |p| content_security_policy only: :embed do |p|
p.frame_ancestors(false) p.frame_ancestors(false)
@ -26,6 +27,8 @@ class StatusesController < ApplicationController
def show def show
respond_to do |format| respond_to do |format|
format.html do format.html do
mark_cacheable! unless user_signed_in?
@body_classes = 'with-modals' @body_classes = 'with-modals'
set_ancestors set_ancestors
@ -35,7 +38,7 @@ class StatusesController < ApplicationController
end end
format.json do format.json do
skip_session! unless @stream_entry.hidden? mark_cacheable! unless @stream_entry.hidden?
render_cached_json(['activitypub', 'note', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do render_cached_json(['activitypub', 'note', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter) ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter)
@ -63,8 +66,37 @@ class StatusesController < ApplicationController
render 'stream_entries/embed', layout: 'embedded' render 'stream_entries/embed', layout: 'embedded'
end end
def replies
skip_session!
render json: replies_collection_presenter,
serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json',
skip_activities: true
end
private private
def replies_collection_presenter
page = ActivityPub::CollectionPresenter.new(
id: replies_account_status_url(@account, @status, page_params),
type: :unordered,
part_of: replies_account_status_url(@account, @status),
next: next_page,
items: @replies.map { |status| status.local ? status : status.id }
)
if page_requested?
page
else
ActivityPub::CollectionPresenter.new(
id: replies_account_status_url(@account, @status),
type: :unordered,
first: page
)
end
end
def create_descendant_thread(starting_depth, statuses) def create_descendant_thread(starting_depth, statuses)
depth = starting_depth + statuses.size depth = starting_depth + statuses.size
if depth < DESCENDANTS_DEPTH_LIMIT if depth < DESCENDANTS_DEPTH_LIMIT
@ -174,4 +206,27 @@ class StatusesController < ApplicationController
return if @status.public_visibility? || @status.unlisted_visibility? return if @status.public_visibility? || @status.unlisted_visibility?
response.headers['Referrer-Policy'] = 'origin' response.headers['Referrer-Policy'] = 'origin'
end end
def page_requested?
params[:page] == 'true'
end
def set_replies
@replies = page_params[:other_accounts] ? Status.where.not(account_id: @account.id) : @account.statuses
@replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted])
@replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id])
end
def next_page
last_reply = @replies.last
return if last_reply.nil?
same_account = last_reply.account_id == @account.id
return unless same_account || @replies.size == DESCENDANTS_LIMIT
same_account = false unless @replies.size == DESCENDANTS_LIMIT
replies_account_status_url(@account, @status, page: true, min_id: last_reply.id, other_accounts: !same_account)
end
def page_params
{ page: true, other_accounts: params[:other_accounts], min_id: params[:min_id] }.compact
end
end end

View File

@ -9,12 +9,14 @@ class TagsController < ApplicationController
before_action :set_instance_presenter before_action :set_instance_presenter
def show def show
@tag = Tag.find_by!(name: params[:id].downcase) @tag = Tag.find_normalized!(params[:id])
respond_to do |format| respond_to do |format|
format.html do format.html do
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) @initial_state_json = ActiveModelSerializers::SerializableResource.new(
@initial_state_json = serializable_resource.to_json InitialStatePresenter.new(settings: {}, token: current_session&.token),
serializer: InitialStateSerializer
).to_json
end end
format.rss do format.rss do
@ -25,8 +27,7 @@ class TagsController < ApplicationController
end end
format.json do format.json do
@statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local]) @statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local]).paginate_by_max_id(PAGE_SIZE, params[:max_id])
.paginate_by_max_id(PAGE_SIZE, params[:max_id])
@statuses = cache_collection(@statuses, Status) @statuses = cache_collection(@statuses, Status)
render json: collection_presenter, render json: collection_presenter,
@ -55,11 +56,4 @@ class TagsController < ApplicationController
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
) )
end end
def initial_state_params
{
settings: {},
token: current_session&.token,
}
end
end end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
module WellKnown
class KeybaseProofConfigController < ActionController::Base
def show
render json: {}, serializer: ProofProvider::Keybase::ConfigSerializer
end
end
end

View File

@ -9,42 +9,6 @@ module Admin::ActionLogsHelper
end end
end end
def linkable_log_target(record)
case record.class.name
when 'Account'
link_to record.acct, admin_account_path(record.id)
when 'User'
link_to record.account.acct, admin_account_path(record.account_id)
when 'CustomEmoji'
record.shortcode
when 'Report'
link_to "##{record.id}", admin_report_path(record)
when 'DomainBlock', 'EmailDomainBlock'
link_to record.domain, "https://#{record.domain}"
when 'Status'
link_to record.account.acct, TagManager.instance.url_for(record)
when 'AccountWarning'
link_to record.target_account.acct, admin_account_path(record.target_account_id)
end
end
def log_target_from_history(type, attributes)
case type
when 'CustomEmoji'
attributes['shortcode']
when 'DomainBlock', 'EmailDomainBlock'
link_to attributes['domain'], "https://#{attributes['domain']}"
when 'Status'
tmp_status = Status.new(attributes.except('reblogs_count', 'favourites_count'))
if tmp_status.account
link_to tmp_status.account&.acct || "##{tmp_status.account_id}", admin_account_path(tmp_status.account_id)
else
I18n.t('admin.action_logs.deleted_status')
end
end
end
def relevant_log_changes(log) def relevant_log_changes(log)
if log.target_type == 'CustomEmoji' && [:enable, :disable, :destroy].include?(log.action) if log.target_type == 'CustomEmoji' && [:enable, :disable, :destroy].include?(log.action)
log.recorded_changes.slice('domain') log.recorded_changes.slice('domain')
@ -111,4 +75,40 @@ module Admin::ActionLogsHelper
def opposite_verbs?(log) def opposite_verbs?(log)
%w(DomainBlock EmailDomainBlock AccountWarning).include?(log.target_type) %w(DomainBlock EmailDomainBlock AccountWarning).include?(log.target_type)
end end
def linkable_log_target(record)
case record.class.name
when 'Account'
link_to record.acct, admin_account_path(record.id)
when 'User'
link_to record.account.acct, admin_account_path(record.account_id)
when 'CustomEmoji'
record.shortcode
when 'Report'
link_to "##{record.id}", admin_report_path(record)
when 'DomainBlock', 'EmailDomainBlock'
link_to record.domain, "https://#{record.domain}"
when 'Status'
link_to record.account.acct, TagManager.instance.url_for(record)
when 'AccountWarning'
link_to record.target_account.acct, admin_account_path(record.target_account_id)
end
end
def log_target_from_history(type, attributes)
case type
when 'CustomEmoji'
attributes['shortcode']
when 'DomainBlock', 'EmailDomainBlock'
link_to attributes['domain'], "https://#{attributes['domain']}"
when 'Status'
tmp_status = Status.new(attributes.except('reblogs_count', 'favourites_count'))
if tmp_status.account
link_to tmp_status.account&.acct || "##{tmp_status.account_id}", admin_account_path(tmp_status.account_id)
else
I18n.t('admin.action_logs.deleted_status')
end
end
end
end end

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
module Admin::DashboardHelper
def feature_hint(feature, enabled)
indicator = safe_join([enabled ? t('simple_form.yes') : t('simple_form.no'), fa_icon('power-off fw')], ' ')
class_names = enabled ? 'pull-right positive-hint' : 'pull-right neutral-hint'
safe_join([feature, content_tag(:span, indicator, class: class_names)])
end
end

View File

@ -1,14 +1,15 @@
# frozen_string_literal: true # frozen_string_literal: true
module Admin::FilterHelper module Admin::FilterHelper
ACCOUNT_FILTERS = %i(local remote by_domain active silenced suspended username display_name email ip staff).freeze ACCOUNT_FILTERS = %i(local remote by_domain active pending silenced suspended username display_name email ip staff).freeze
REPORT_FILTERS = %i(resolved account_id target_account_id).freeze REPORT_FILTERS = %i(resolved account_id target_account_id).freeze
INVITE_FILTER = %i(available expired).freeze INVITE_FILTER = %i(available expired).freeze
CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze
TAGS_FILTERS = %i(hidden).freeze TAGS_FILTERS = %i(hidden).freeze
INSTANCES_FILTERS = %i(limited).freeze INSTANCES_FILTERS = %i(limited by_domain).freeze
FOLLOWERS_FILTERS = %i(relationship status by_domain activity order).freeze
FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS + TAGS_FILTERS + INSTANCES_FILTERS FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS + TAGS_FILTERS + INSTANCES_FILTERS + FOLLOWERS_FILTERS
def filter_link_to(text, link_to_params, link_class_params = link_to_params) def filter_link_to(text, link_to_params, link_class_params = link_to_params)
new_url = filtered_url_for(link_to_params) new_url = filtered_url_for(link_to_params)

View File

@ -20,7 +20,23 @@ module ApplicationHelper
end end
def open_registrations? def open_registrations?
Setting.open_registrations Setting.registrations_mode == 'open'
end
def approved_registrations?
Setting.registrations_mode == 'approved'
end
def closed_registrations?
Setting.registrations_mode == 'none'
end
def available_sign_up_path
if closed_registrations?
'https://joinmastodon.org/#getting-started'
else
new_user_registration_path
end
end end
def open_deletion? def open_deletion?
@ -101,4 +117,9 @@ module ApplicationHelper
def storage_host? def storage_host?
ENV['S3_ALIAS_HOST'].present? || ENV['S3_CLOUDFRONT_HOST'].present? ENV['S3_ALIAS_HOST'].present? || ENV['S3_CLOUDFRONT_HOST'].present?
end end
def quote_wrap(text, line_width: 80, break_sequence: "\n")
text = word_wrap(text, line_width: line_width - 2, break_sequence: break_sequence)
text.split("\n").map { |line| '> ' + line }.join("\n")
end
end end

View File

@ -56,4 +56,22 @@ module HomeHelper
'emojify' 'emojify'
end end
end end
def optional_link_to(condition, path, options = {}, &block)
if condition
link_to(path, options, &block)
else
content_tag(:div, &block)
end
end
def sign_up_message
if closed_registrations?
t('auth.registration_closed', instance: site_hostname)
elsif open_registrations?
t('auth.register')
elsif approved_registrations?
t('auth.apply_for_account')
end
end
end end

View File

@ -47,6 +47,15 @@ module JsonLdHelper
!uri.start_with?('http://', 'https://') !uri.start_with?('http://', 'https://')
end end
def invalid_origin?(url)
return true if unsupported_uri_scheme?(url)
needle = Addressable::URI.parse(url).host
haystack = Addressable::URI.parse(@account.uri).host
!haystack.casecmp(needle).zero?
end
def canonicalize(json) def canonicalize(json)
graph = RDF::Graph.new << JSON::LD::API.toRdf(json, documentLoader: method(:load_jsonld_context)) graph = RDF::Graph.new << JSON::LD::API.toRdf(json, documentLoader: method(:load_jsonld_context))
graph.dump(:normalize) graph.dump(:normalize)
@ -63,12 +72,19 @@ module JsonLdHelper
json.present? && json['id'] == uri ? json : nil json.present? && json['id'] == uri ? json : nil
end end
def fetch_resource_without_id_validation(uri, on_behalf_of = nil) def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false)
build_request(uri, on_behalf_of).perform do |response| build_request(uri, on_behalf_of).perform do |response|
unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error
raise Mastodon::UnexpectedResponseError, response
end
return body_to_json(response.body_with_limit) if response.code == 200 return body_to_json(response.body_with_limit) if response.code == 200
end end
# If request failed, retry without doing it on behalf of a user # If request failed, retry without doing it on behalf of a user
return if on_behalf_of.nil?
build_request(uri).perform do |response| build_request(uri).perform do |response|
unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error
raise Mastodon::UnexpectedResponseError, response
end
response.code == 200 ? body_to_json(response.body_with_limit) : nil response.code == 200 ? body_to_json(response.body_with_limit) : nil
end end
end end
@ -91,6 +107,14 @@ module JsonLdHelper
private private
def response_successful?(response)
(200...300).cover?(response.code)
end
def response_error_unsalvageable?(response)
(400...500).cover?(response.code) && response.code != 429
end
def build_request(uri, on_behalf_of = nil) def build_request(uri, on_behalf_of = nil)
request = Request.new(:get, uri) request = Request.new(:get, uri)
request.on_behalf_of(on_behalf_of) if on_behalf_of request.on_behalf_of(on_behalf_of) if on_behalf_of

View File

@ -4,8 +4,9 @@ module SettingsHelper
HUMAN_LOCALES = { HUMAN_LOCALES = {
en: 'English', en: 'English',
ar: 'العربية', ar: 'العربية',
ast: 'l\'asturianu', ast: 'Asturianu',
bg: 'Български', bg: 'Български',
bn: 'বাংলা',
ca: 'Català', ca: 'Català',
co: 'Corsu', co: 'Corsu',
cs: 'Čeština', cs: 'Čeština',
@ -19,8 +20,10 @@ module SettingsHelper
fa: 'فارسی', fa: 'فارسی',
fi: 'Suomi', fi: 'Suomi',
fr: 'Français', fr: 'Français',
ga: 'Gaeilge',
gl: 'Galego', gl: 'Galego',
he: 'עברית', he: 'עברית',
hi: 'हिन्दी',
hr: 'Hrvatski', hr: 'Hrvatski',
hu: 'Magyar', hu: 'Magyar',
hy: 'Հայերեն', hy: 'Հայերեն',
@ -29,24 +32,29 @@ module SettingsHelper
it: 'Italiano', it: 'Italiano',
ja: '日本語', ja: '日本語',
ka: 'ქართული', ka: 'ქართული',
kk: 'Қазақша',
ko: '한국어', ko: '한국어',
lt: 'Lietuvių',
lv: 'Latviešu',
ml: 'മലയാളം', ml: 'മലയാളം',
ms: 'Bahasa Melayu',
nl: 'Nederlands', nl: 'Nederlands',
no: 'Norsk', no: 'Norsk',
oc: 'Occitan', oc: 'Occitan',
pl: 'Polszczyzna', pl: 'Polski',
pt: 'Português', pt: 'Português',
'pt-BR': 'Português do Brasil', 'pt-BR': 'Português do Brasil',
ro: 'Limba română', ro: 'Română',
ru: 'Русский', ru: 'Русский',
sk: 'Slovenčina', sk: 'Slovenčina',
sl: 'Slovenščina', sl: 'Slovenščina',
sq: 'Shqip',
sr: 'Српски', sr: 'Српски',
'sr-Latn': 'Srpski (latinica)', 'sr-Latn': 'Srpski (latinica)',
sv: 'Svenska', sv: 'Svenska',
ta: 'தமிழ்', ta: 'தமிழ்',
te: 'తెలుగు', te: 'తెలుగు',
th: 'ภาษาไทย', th: 'ไทย',
tr: 'Türkçe', tr: 'Türkçe',
uk: 'Українська', uk: 'Українська',
zh: '中文', zh: '中文',

View File

@ -23,7 +23,7 @@ module StreamEntriesHelper
safe_join([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('accounts.unfollow')]) safe_join([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('accounts.unfollow')])
end end
elsif !(account.memorial? || account.moved?) elsif !(account.memorial? || account.moved?)
link_to account_follow_path(account), class: 'button logo-button', data: { method: :post } do link_to account_follow_path(account), class: "button logo-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post } do
safe_join([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('accounts.follow')]) safe_join([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('accounts.follow')])
end end
end end
@ -104,9 +104,19 @@ module StreamEntriesHelper
I18n.t('statuses.content_warning', warning: status.spoiler_text) I18n.t('statuses.content_warning', warning: status.spoiler_text)
end end
def poll_summary(status)
return unless status.preloadable_poll
status.preloadable_poll.options.map { |o| "[ ] #{o}" }.join("\n")
end
def status_description(status) def status_description(status)
components = [[media_summary(status), status_text_summary(status)].reject(&:blank?).join(' · ')] components = [[media_summary(status), status_text_summary(status)].reject(&:blank?).join(' · ')]
components << status.text if status.spoiler_text.blank?
if status.spoiler_text.blank?
components << status.text
components << poll_summary(status)
end
components.reject(&:blank?).join("\n\n") components.reject(&:blank?).join("\n\n")
end end

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216.4144 232.00976"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -22,7 +22,7 @@ export function clearAlert() {
}; };
}; };
export function showAlert(title, message) { export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) {
return { return {
type: ALERT_SHOW, type: ALERT_SHOW,
title, title,
@ -34,6 +34,11 @@ export function showAlertForError(error) {
if (error.response) { if (error.response) {
const { data, status, statusText } = error.response; const { data, status, statusText } = error.response;
if (status === 404 || status === 410) {
// Skip these errors as they are reflected in the UI
return {};
}
let message = statusText; let message = statusText;
let title = `${status}`; let title = `${status}`;
@ -44,6 +49,6 @@ export function showAlertForError(error) {
return showAlert(title, message); return showAlert(title, message);
} else { } else {
console.error(error); console.error(error);
return showAlert(messages.unexpectedTitle, messages.unexpectedMessage); return showAlert();
} }
} }

View File

@ -8,6 +8,8 @@ import resizeImage from '../utils/resize_image';
import { importFetchedAccounts } from './importer'; import { importFetchedAccounts } from './importer';
import { updateTimeline } from './timelines'; import { updateTimeline } from './timelines';
import { showAlertForError } from './alerts'; import { showAlertForError } from './alerts';
import { showAlert } from './alerts';
import { defineMessages } from 'react-intl';
let cancelFetchComposeSuggestionsAccounts; let cancelFetchComposeSuggestionsAccounts;
@ -50,6 +52,18 @@ export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'
export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL';
export const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD';
export const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE';
export const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD';
export const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE';
export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE';
export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE';
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
});
export function changeCompose(text) { export function changeCompose(text) {
return { return {
type: COMPOSE_CHANGE, type: COMPOSE_CHANGE,
@ -126,6 +140,7 @@ export function submitCompose(routerHistory) {
sensitive: getState().getIn(['compose', 'sensitive']), sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
visibility: getState().getIn(['compose', 'privacy']), visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null),
local_only: !getState().getIn(['compose', 'federation']), local_only: !getState().getIn(['compose', 'federation']),
}, { }, {
headers: { headers: {
@ -145,7 +160,9 @@ export function submitCompose(routerHistory) {
// into the columns // into the columns
const insertIfOnline = timelineId => { const insertIfOnline = timelineId => {
if (getState().getIn(['timelines', timelineId, 'items', 0]) !== null) { const timeline = getState().getIn(['timelines', timelineId]);
if (timeline && timeline.get('items').size > 0 && timeline.getIn(['items', 0]) !== null && timeline.get('online')) {
dispatch(updateTimeline(timelineId, { ...response.data })); dispatch(updateTimeline(timelineId, { ...response.data }));
} }
}; };
@ -186,20 +203,38 @@ export function submitComposeFail(error) {
export function uploadCompose(files) { export function uploadCompose(files) {
return function (dispatch, getState) { return function (dispatch, getState) {
if (getState().getIn(['compose', 'media_attachments']).size > 3) { const uploadLimit = 4;
const media = getState().getIn(['compose', 'media_attachments']);
const total = Array.from(files).reduce((a, v) => a + v.size, 0);
const progress = new Array(files.length).fill(0);
if (files.length + media.size > uploadLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit));
return;
}
if (getState().getIn(['compose', 'poll'])) {
dispatch(showAlert(undefined, messages.uploadErrorPoll));
return; return;
} }
dispatch(uploadComposeRequest()); dispatch(uploadComposeRequest());
resizeImage(files[0]).then(file => { for (const [i, f] of Array.from(files).entries()) {
const data = new FormData(); if (media.size + i > 3) break;
data.append('file', file);
return api(getState).post('/api/v1/media', data, { resizeImage(f).then(file => {
onUploadProgress: ({ loaded, total }) => dispatch(uploadComposeProgress(loaded, total)), const data = new FormData();
}).then(({ data }) => dispatch(uploadComposeSuccess(data))); data.append('file', file);
}).catch(error => dispatch(uploadComposeFail(error)));
return api(getState).post('/api/v1/media', data, {
onUploadProgress: function({ loaded }){
progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
},
}).then(({ data }) => dispatch(uploadComposeSuccess(data)));
}).catch(error => dispatch(uploadComposeFail(error)));
};
}; };
}; };
@ -475,4 +510,46 @@ export function changeComposing(value) {
type: COMPOSE_COMPOSING_CHANGE, type: COMPOSE_COMPOSING_CHANGE,
value, value,
}; };
} };
export function addPoll() {
return {
type: COMPOSE_POLL_ADD,
};
};
export function removePoll() {
return {
type: COMPOSE_POLL_REMOVE,
};
};
export function addPollOption(title) {
return {
type: COMPOSE_POLL_OPTION_ADD,
title,
};
};
export function changePollOption(index, title) {
return {
type: COMPOSE_POLL_OPTION_CHANGE,
index,
title,
};
};
export function removePollOption(index) {
return {
type: COMPOSE_POLL_OPTION_REMOVE,
index,
};
};
export function changePollSettings(expiresIn, isMultiple) {
return {
type: COMPOSE_POLL_SETTINGS_CHANGE,
expiresIn,
isMultiple,
};
};

View File

@ -41,13 +41,15 @@ export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => {
params.since_id = getState().getIn(['conversations', 'items', 0, 'last_status']); params.since_id = getState().getIn(['conversations', 'items', 0, 'last_status']);
} }
const isLoadingRecent = !!params.since_id;
api(getState).get('/api/v1/conversations', { params }) api(getState).get('/api/v1/conversations', { params })
.then(response => { .then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.reduce((aggr, item) => aggr.concat(item.accounts), []))); dispatch(importFetchedAccounts(response.data.reduce((aggr, item) => aggr.concat(item.accounts), [])));
dispatch(importFetchedStatuses(response.data.map(item => item.last_status).filter(x => !!x))); dispatch(importFetchedStatuses(response.data.map(item => item.last_status).filter(x => !!x)));
dispatch(expandConversationsSuccess(response.data, next ? next.uri : null)); dispatch(expandConversationsSuccess(response.data, next ? next.uri : null, isLoadingRecent));
}) })
.catch(err => dispatch(expandConversationsFail(err))); .catch(err => dispatch(expandConversationsFail(err)));
}; };
@ -56,10 +58,11 @@ export const expandConversationsRequest = () => ({
type: CONVERSATIONS_FETCH_REQUEST, type: CONVERSATIONS_FETCH_REQUEST,
}); });
export const expandConversationsSuccess = (conversations, next) => ({ export const expandConversationsSuccess = (conversations, next, isLoadingRecent) => ({
type: CONVERSATIONS_FETCH_SUCCESS, type: CONVERSATIONS_FETCH_SUCCESS,
conversations, conversations,
next, next,
isLoadingRecent,
}); });
export const expandConversationsFail = error => ({ export const expandConversationsFail = error => ({

View File

@ -0,0 +1,30 @@
import api from '../api';
export const IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST = 'IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST';
export const IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS = 'IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS';
export const IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL = 'IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL';
export const fetchAccountIdentityProofs = accountId => (dispatch, getState) => {
dispatch(fetchAccountIdentityProofsRequest(accountId));
api(getState).get(`/api/v1/accounts/${accountId}/identity_proofs`)
.then(({ data }) => dispatch(fetchAccountIdentityProofsSuccess(accountId, data)))
.catch(err => dispatch(fetchAccountIdentityProofsFail(accountId, err)));
};
export const fetchAccountIdentityProofsRequest = id => ({
type: IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
id,
});
export const fetchAccountIdentityProofsSuccess = (accountId, identity_proofs) => ({
type: IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS,
accountId,
identity_proofs,
});
export const fetchAccountIdentityProofsFail = (accountId, err) => ({
type: IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
accountId,
err,
});

View File

@ -1,11 +1,10 @@
// import { autoPlayGif } from '../../initial_state'; import { normalizeAccount, normalizeStatus, normalizePoll } from './normalizer';
// import { putAccounts, putStatuses } from '../../storage/modifier';
import { normalizeAccount, normalizeStatus } from './normalizer';
export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
export const STATUS_IMPORT = 'STATUS_IMPORT'; export const STATUS_IMPORT = 'STATUS_IMPORT';
export const STATUSES_IMPORT = 'STATUSES_IMPORT'; export const STATUSES_IMPORT = 'STATUSES_IMPORT';
export const POLLS_IMPORT = 'POLLS_IMPORT';
function pushUnique(array, object) { function pushUnique(array, object) {
if (array.every(element => element.id !== object.id)) { if (array.every(element => element.id !== object.id)) {
@ -29,6 +28,10 @@ export function importStatuses(statuses) {
return { type: STATUSES_IMPORT, statuses }; return { type: STATUSES_IMPORT, statuses };
} }
export function importPolls(polls) {
return { type: POLLS_IMPORT, polls };
}
export function importFetchedAccount(account) { export function importFetchedAccount(account) {
return importFetchedAccounts([account]); return importFetchedAccounts([account]);
} }
@ -45,7 +48,6 @@ export function importFetchedAccounts(accounts) {
} }
accounts.forEach(processAccount); accounts.forEach(processAccount);
//putAccounts(normalAccounts, !autoPlayGif);
return importAccounts(normalAccounts); return importAccounts(normalAccounts);
} }
@ -58,6 +60,7 @@ export function importFetchedStatuses(statuses) {
return (dispatch, getState) => { return (dispatch, getState) => {
const accounts = []; const accounts = [];
const normalStatuses = []; const normalStatuses = [];
const polls = [];
function processStatus(status) { function processStatus(status) {
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]))); pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id])));
@ -66,12 +69,22 @@ export function importFetchedStatuses(statuses) {
if (status.reblog && status.reblog.id) { if (status.reblog && status.reblog.id) {
processStatus(status.reblog); processStatus(status.reblog);
} }
if (status.poll && status.poll.id) {
pushUnique(polls, normalizePoll(status.poll));
}
} }
statuses.forEach(processStatus); statuses.forEach(processStatus);
//putStatuses(normalStatuses);
dispatch(importPolls(polls));
dispatch(importFetchedAccounts(accounts)); dispatch(importFetchedAccounts(accounts));
dispatch(importStatuses(normalStatuses)); dispatch(importStatuses(normalStatuses));
}; };
} }
export function importFetchedPoll(poll) {
return dispatch => {
dispatch(importPolls([normalizePoll(poll)]));
};
}

View File

@ -43,6 +43,10 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.reblog = status.reblog.id; normalStatus.reblog = status.reblog.id;
} }
if (status.poll && status.poll.id) {
normalStatus.poll = status.poll.id;
}
// Only calculate these values when status first encountered // Only calculate these values when status first encountered
// Otherwise keep the ones already in the reducer // Otherwise keep the ones already in the reducer
if (normalOldStatus) { if (normalOldStatus) {
@ -63,3 +67,16 @@ export function normalizeStatus(status, normalOldStatus) {
return normalStatus; return normalStatus;
} }
export function normalizePoll(poll) {
const normalPoll = { ...poll };
const emojiMap = makeEmojiMap(normalPoll);
normalPoll.options = poll.options.map(option => ({
...option,
title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap),
}));
return normalPoll;
}

View File

@ -7,6 +7,7 @@ import {
importFetchedStatus, importFetchedStatus,
importFetchedStatuses, importFetchedStatuses,
} from './importer'; } from './importer';
import { saveSettings } from './settings';
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { unescapeHTML } from '../utils/html'; import { unescapeHTML } from '../utils/html';
@ -92,7 +93,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
const excludeTypesFromFilter = filter => { const excludeTypesFromFilter = filter => {
const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention']); const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']);
return allTypes.filterNot(item => item === filter).toJS(); return allTypes.filterNot(item => item === filter).toJS();
}; };
@ -187,5 +188,6 @@ export function setFilter (filterType) {
value: filterType, value: filterType,
}); });
dispatch(expandNotifications()); dispatch(expandNotifications());
dispatch(saveSettings());
}; };
}; };

View File

@ -0,0 +1,60 @@
import api from '../api';
import { importFetchedPoll } from './importer';
export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST';
export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS';
export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL';
export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST';
export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS';
export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL';
export const vote = (pollId, choices) => (dispatch, getState) => {
dispatch(voteRequest());
api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices })
.then(({ data }) => {
dispatch(importFetchedPoll(data));
dispatch(voteSuccess(data));
})
.catch(err => dispatch(voteFail(err)));
};
export const fetchPoll = pollId => (dispatch, getState) => {
dispatch(fetchPollRequest());
api(getState).get(`/api/v1/polls/${pollId}`)
.then(({ data }) => {
dispatch(importFetchedPoll(data));
dispatch(fetchPollSuccess(data));
})
.catch(err => dispatch(fetchPollFail(err)));
};
export const voteRequest = () => ({
type: POLL_VOTE_REQUEST,
});
export const voteSuccess = poll => ({
type: POLL_VOTE_SUCCESS,
poll,
});
export const voteFail = error => ({
type: POLL_VOTE_FAIL,
error,
});
export const fetchPollRequest = () => ({
type: POLL_FETCH_REQUEST,
});
export const fetchPollSuccess = poll => ({
type: POLL_FETCH_SUCCESS,
poll,
});
export const fetchPollFail = error => ({
type: POLL_FETCH_FAIL,
error,
});

View File

@ -37,6 +37,7 @@ export function submitSearch() {
params: { params: {
q: value, q: value,
resolve: true, resolve: true,
limit: 5,
}, },
}).then(response => { }).then(response => {
if (response.data.accounts) { if (response.data.accounts) {

View File

@ -140,7 +140,11 @@ export function redraft(status) {
export function deleteStatus(id, router, withRedraft = false) { export function deleteStatus(id, router, withRedraft = false) {
return (dispatch, getState) => { return (dispatch, getState) => {
const status = getState().getIn(['statuses', id]); let status = getState().getIn(['statuses', id]);
if (status.get('poll')) {
status = status.set('poll', getState().getIn(['polls', status.get('poll')]));
}
dispatch(deleteStatusRequest(id)); dispatch(deleteStatusRequest(id));

View File

@ -3,6 +3,7 @@ import {
updateTimeline, updateTimeline,
deleteFromTimelines, deleteFromTimelines,
expandHomeTimeline, expandHomeTimeline,
connectTimeline,
disconnectTimeline, disconnectTimeline,
} from './timelines'; } from './timelines';
import { updateNotifications, expandNotifications } from './notifications'; import { updateNotifications, expandNotifications } from './notifications';
@ -16,7 +17,12 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
return connectStream (path, pollingRefresh, (dispatch, getState) => { return connectStream (path, pollingRefresh, (dispatch, getState) => {
const locale = getState().getIn(['meta', 'locale']); const locale = getState().getIn(['meta', 'locale']);
return { return {
onConnect() {
dispatch(connectTimeline(timelineId));
},
onDisconnect() { onDisconnect() {
dispatch(disconnectTimeline(timelineId)); dispatch(disconnectTimeline(timelineId));
}, },

View File

@ -12,6 +12,7 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
export function updateTimeline(timeline, status, accept) { export function updateTimeline(timeline, status, accept) {
@ -143,6 +144,13 @@ export function scrollTopTimeline(timeline, top) {
}; };
}; };
export function connectTimeline(timeline) {
return {
type: TIMELINE_CONNECT,
timeline,
};
};
export function disconnectTimeline(timeline) { export function disconnectTimeline(timeline) {
return { return {
type: TIMELINE_DISCONNECT, type: TIMELINE_DISCONNECT,

View File

@ -13,10 +13,14 @@ export const getLinks = response => {
}; };
let csrfHeader = {}; let csrfHeader = {};
function setCSRFHeader() { function setCSRFHeader() {
const csrfToken = document.querySelector('meta[name=csrf-token]').content; const csrfToken = document.querySelector('meta[name=csrf-token]');
csrfHeader['X-CSRF-Token'] = csrfToken; if (csrfToken) {
csrfHeader['X-CSRF-Token'] = csrfToken.content;
}
} }
ready(setCSRFHeader); ready(setCSRFHeader);
export default getState => axios.create({ export default getState => axios.create({

View File

@ -2,6 +2,7 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Icon from 'mastodon/components/icon';
const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
@ -24,7 +25,7 @@ export default class AttachmentList extends ImmutablePureComponent {
return ( return (
<li key={attachment.get('id')}> <li key={attachment.get('id')}>
<a href={displayUrl} target='_blank' rel='noopener'><i className='fa fa-link' /> {filename(displayUrl)}</a> <a href={displayUrl} target='_blank' rel='noopener'><Icon id='link' /> {filename(displayUrl)}</a>
</li> </li>
); );
})} })}
@ -36,7 +37,7 @@ export default class AttachmentList extends ImmutablePureComponent {
return ( return (
<div className='attachment-list'> <div className='attachment-list'>
<div className='attachment-list__icon'> <div className='attachment-list__icon'>
<i className='fa fa-link' /> <Icon id='link' />
</div> </div>
<ul className='attachment-list__list'> <ul className='attachment-list__list'>

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Icon from 'mastodon/components/icon';
export default class ColumnBackButton extends React.PureComponent { export default class ColumnBackButton extends React.PureComponent {
@ -19,7 +20,7 @@ export default class ColumnBackButton extends React.PureComponent {
render () { render () {
return ( return (
<button onClick={this.handleClick} className='column-back-button'> <button onClick={this.handleClick} className='column-back-button'>
<i className='fa fa-fw fa-chevron-left column-back-button__icon' /> <Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' /> <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</button> </button>
); );

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ColumnBackButton from './column_back_button'; import ColumnBackButton from './column_back_button';
import Icon from 'mastodon/components/icon';
export default class ColumnBackButtonSlim extends ColumnBackButton { export default class ColumnBackButtonSlim extends ColumnBackButton {
@ -8,7 +9,7 @@ export default class ColumnBackButtonSlim extends ColumnBackButton {
return ( return (
<div className='column-back-button--slim'> <div className='column-back-button--slim'>
<div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'> <div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'>
<i className='fa fa-fw fa-chevron-left column-back-button__icon' /> <Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' /> <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import Icon from 'mastodon/components/icon';
const messages = defineMessages({ const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
@ -109,22 +110,22 @@ class ColumnHeader extends React.PureComponent {
} }
if (multiColumn && pinned) { if (multiColumn && pinned) {
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><i className='fa fa fa-times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>; pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
moveButtons = ( moveButtons = (
<div key='move-buttons' className='column-header__setting-arrows'> <div key='move-buttons' className='column-header__setting-arrows'>
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><i className='fa fa-chevron-left' /></button> <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button>
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><i className='fa fa-chevron-right' /></button> <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
</div> </div>
); );
} else if (multiColumn) { } else if (multiColumn) {
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><i className='fa fa fa-plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
} }
if (!pinned && (multiColumn || showBackButton)) { if (!pinned && (multiColumn || showBackButton)) {
backButton = ( backButton = (
<button onClick={this.handleBackClick} className='column-header__back-button'> <button onClick={this.handleBackClick} className='column-header__back-button'>
<i className='fa fa-fw fa-chevron-left column-back-button__icon' /> <Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' /> <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</button> </button>
); );
@ -140,7 +141,7 @@ class ColumnHeader extends React.PureComponent {
} }
if (children || multiColumn) { if (children || multiColumn) {
collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>; collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>;
} }
const hasTitle = icon && title; const hasTitle = icon && title;
@ -150,7 +151,7 @@ class ColumnHeader extends React.PureComponent {
<h1 className={buttonClassName}> <h1 className={buttonClassName}>
{hasTitle && ( {hasTitle && (
<button onClick={this.handleTitleClick}> <button onClick={this.handleTitleClick}>
<i className={`fa fa-fw fa-${icon} column-header__icon`} /> <Icon id={icon} fixedWidth className='column-header__icon' />
{title} {title}
</button> </button>
)} )}

View File

@ -22,7 +22,7 @@ export default class DisplayName extends React.PureComponent {
suffix = `+${others.size - 2}`; suffix = `+${others.size - 2}`;
} }
} else { } else {
if (others) { if (others && others.size > 0) {
account = others.first(); account = others.first();
} else { } else {
account = this.props.account; account = this.props.account;

View File

@ -0,0 +1,39 @@
import React from 'react';
import PropTypes from 'prop-types';
import illustration from '../../images/elephant_ui_disappointed.svg';
export default class ErrorBoundary extends React.PureComponent {
static propTypes = {
children: PropTypes.node,
};
state = {
hasError: false,
stackTrace: undefined,
componentStack: undefined,
}
componentDidCatch(error, info) {
this.setState({
hasError: true,
stackTrace: error.stack,
componentStack: info && info.componentStack,
});
}
render() {
const { hasError } = this.state;
if (!hasError) {
return this.props.children;
}
return (
<div>
<img src={illustration} alt='' />
</div>
);
}
}

View File

@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
export default class Icon extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
className: PropTypes.string,
fixedWidth: PropTypes.bool,
};
render () {
const { id, className, fixedWidth, ...other } = this.props;
return (
<i role='img' className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })} {...other} />
);
}
}

View File

@ -3,6 +3,7 @@ import Motion from '../features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
export default class IconButton extends React.PureComponent { export default class IconButton extends React.PureComponent {
@ -85,8 +86,9 @@ export default class IconButton extends React.PureComponent {
onClick={this.handleClick} onClick={this.handleClick}
style={style} style={style}
tabIndex={tabIndex} tabIndex={tabIndex}
disabled={disabled}
> >
<i className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> <Icon id={icon} fixedWidth aria-hidden='true' />
</button> </button>
); );
} }
@ -103,8 +105,9 @@ export default class IconButton extends React.PureComponent {
onClick={this.handleClick} onClick={this.handleClick}
style={style} style={style}
tabIndex={tabIndex} tabIndex={tabIndex}
disabled={disabled}
> >
<i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> <Icon id={icon} style={{ transform: `rotate(${rotate}deg)` }} fixedWidth aria-hidden='true' />
</button> </button>
)} )}
</Motion> </Motion>

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import Icon from 'mastodon/components/icon';
const messages = defineMessages({ const messages = defineMessages({
load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
@ -25,7 +26,7 @@ class LoadGap extends React.PureComponent {
return ( return (
<button className='load-more load-gap' disabled={disabled} onClick={this.handleClick} aria-label={intl.formatMessage(messages.load_more)}> <button className='load-more load-gap' disabled={disabled} onClick={this.handleClick} aria-label={intl.formatMessage(messages.load_more)}>
<i className='fa fa-ellipsis-h' /> <Icon id='ellipsis-h' />
</button> </button>
); );
} }

View File

@ -0,0 +1,140 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { vote, fetchPoll } from 'mastodon/actions/polls';
import Motion from 'mastodon/features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import escapeTextContentForBrowser from 'escape-html';
import emojify from 'mastodon/features/emoji/emoji';
import RelativeTimestamp from './relative_timestamp';
const messages = defineMessages({
closed: { id: 'poll.closed', defaultMessage: 'Closed' },
});
const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
return obj;
}, {});
export default @injectIntl
class Poll extends ImmutablePureComponent {
static propTypes = {
poll: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func,
disabled: PropTypes.bool,
};
state = {
selected: {},
};
handleOptionChange = e => {
const { target: { value } } = e;
if (this.props.poll.get('multiple')) {
const tmp = { ...this.state.selected };
if (tmp[value]) {
delete tmp[value];
} else {
tmp[value] = true;
}
this.setState({ selected: tmp });
} else {
const tmp = {};
tmp[value] = true;
this.setState({ selected: tmp });
}
};
handleVote = () => {
if (this.props.disabled) {
return;
}
this.props.dispatch(vote(this.props.poll.get('id'), Object.keys(this.state.selected)));
};
handleRefresh = () => {
if (this.props.disabled) {
return;
}
this.props.dispatch(fetchPoll(this.props.poll.get('id')));
};
renderOption (option, optionIndex) {
const { poll, disabled } = this.props;
const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100;
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count'));
const active = !!this.state.selected[`${optionIndex}`];
const showResults = poll.get('voted') || poll.get('expired');
let titleEmojified = option.get('title_emojified');
if (!titleEmojified) {
const emojiMap = makeEmojiMap(poll);
titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
}
return (
<li key={option.get('title')}>
{showResults && (
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}>
{({ width }) =>
<span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} />
}
</Motion>
)}
<label className={classNames('poll__text', { selectable: !showResults })}>
<input
name='vote-options'
type={poll.get('multiple') ? 'checkbox' : 'radio'}
value={optionIndex}
checked={active}
onChange={this.handleOptionChange}
disabled={disabled}
/>
{!showResults && <span className={classNames('poll__input', { checkbox: poll.get('multiple'), active })} />}
{showResults && <span className='poll__number'>{Math.round(percent)}%</span>}
<span dangerouslySetInnerHTML={{ __html: titleEmojified }} />
</label>
</li>
);
}
render () {
const { poll, intl } = this.props;
if (!poll) {
return null;
}
const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
const showResults = poll.get('voted') || poll.get('expired');
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
return (
<div className='poll'>
<ul>
{poll.get('options').map((option, i) => this.renderOption(option, i))}
</ul>
<div className='poll__footer'>
{!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
{showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>}
<FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />
{poll.get('expires_at') && <span> · {timeRemaining}</span>}
</div>
</div>
);
}
}

View File

@ -8,6 +8,11 @@ const messages = defineMessages({
minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
days: { id: 'relative_time.days', defaultMessage: '{number}d' }, days: { id: 'relative_time.days', defaultMessage: '{number}d' },
moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' },
hours_remaining: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' },
days_remaining: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' },
}); });
const dateFormatOptions = { const dateFormatOptions = {
@ -86,6 +91,26 @@ export const timeAgoString = (intl, date, now, year) => {
return relativeTime; return relativeTime;
}; };
const timeRemainingString = (intl, date, now) => {
const delta = date.getTime() - now;
let relativeTime;
if (delta < 10 * SECOND) {
relativeTime = intl.formatMessage(messages.moments_remaining);
} else if (delta < MINUTE) {
relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) });
} else if (delta < HOUR) {
relativeTime = intl.formatMessage(messages.minutes_remaining, { number: Math.floor(delta / MINUTE) });
} else if (delta < DAY) {
relativeTime = intl.formatMessage(messages.hours_remaining, { number: Math.floor(delta / HOUR) });
} else {
relativeTime = intl.formatMessage(messages.days_remaining, { number: Math.floor(delta / DAY) });
}
return relativeTime;
};
export default @injectIntl export default @injectIntl
class RelativeTimestamp extends React.Component { class RelativeTimestamp extends React.Component {
@ -93,6 +118,7 @@ class RelativeTimestamp extends React.Component {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
timestamp: PropTypes.string.isRequired, timestamp: PropTypes.string.isRequired,
year: PropTypes.number.isRequired, year: PropTypes.number.isRequired,
futureDate: PropTypes.bool,
}; };
state = { state = {
@ -145,10 +171,10 @@ class RelativeTimestamp extends React.Component {
} }
render () { render () {
const { timestamp, intl, year } = this.props; const { timestamp, intl, year, futureDate } = this.props;
const date = new Date(timestamp); const date = new Date(timestamp);
const relativeTime = timeAgoString(intl, date, this.state.now, year); const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now) : timeAgoString(intl, date, this.state.now, year);
return ( return (
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}> <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>

View File

@ -15,6 +15,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video } from '../features/ui/util/async-components'; import { MediaGallery, Video } from '../features/ui/util/async-components';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
import PollContainer from 'mastodon/containers/poll_container';
// We use the component (and not the container) since we do not want // We use the component (and not the container) since we do not want
// to use the progress bar to show download progress // to use the progress bar to show download progress
@ -249,7 +251,7 @@ class Status extends ImmutablePureComponent {
if (featured) { if (featured) {
prepend = ( prepend = (
<div className='status__prepend'> <div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-thumb-tack status__prepend-icon' /></div> <div className='status__prepend-icon-wrapper'><Icon id='thumb-tack' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.pinned' defaultMessage='Pinned toot' /> <FormattedMessage id='status.pinned' defaultMessage='Pinned toot' />
</div> </div>
); );
@ -258,7 +260,7 @@ class Status extends ImmutablePureComponent {
prepend = ( prepend = (
<div className='status__prepend'> <div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> <div className='status__prepend-icon-wrapper'><Icon id='retweet' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} /> <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
</div> </div>
); );
@ -269,7 +271,9 @@ class Status extends ImmutablePureComponent {
status = status.get('reblog'); status = status.get('reblog');
} }
if (status.get('media_attachments').size > 0) { if (status.get('poll')) {
media = <PollContainer pollId={status.get('poll')} />;
} else if (status.get('media_attachments').size > 0) {
if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) { if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
media = ( media = (
<AttachmentList <AttachmentList
@ -325,7 +329,7 @@ class Status extends ImmutablePureComponent {
); );
} }
if (otherAccounts) { if (otherAccounts && otherAccounts.size > 0) {
statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} />; statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} />;
} else if (account === undefined || account === null) { } else if (account === undefined || account === null) {
statusAvatar = <Avatar account={status.get('account')} size={48} />; statusAvatar = <Avatar account={status.get('account')} size={48} />;
@ -347,7 +351,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'))} ref={this.handleRef}> <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)} 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

@ -33,6 +33,7 @@ const messages = defineMessages({
embed: { id: 'status.embed', defaultMessage: 'Embed' }, embed: { id: 'status.embed', defaultMessage: 'Embed' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
}); });
const obfuscatedCount = count => { const obfuscatedCount = count => {
@ -139,7 +140,7 @@ class StatusActionBar extends ImmutablePureComponent {
} }
handleBlockClick = () => { handleBlockClick = () => {
this.props.onBlock(this.props.status.get('account')); this.props.onBlock(this.props.status);
} }
handleOpen = () => { handleOpen = () => {
@ -158,6 +159,25 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onMuteConversation(this.props.status); this.props.onMuteConversation(this.props.status);
} }
handleCopy = () => {
const url = this.props.status.get('url');
const textarea = document.createElement('textarea');
textarea.textContent = url;
textarea.style.position = 'fixed';
document.body.appendChild(textarea);
try {
textarea.select();
document.execCommand('copy');
} catch (e) {
} finally {
document.body.removeChild(textarea);
}
}
render () { render () {
const { status, intl, withDismiss } = this.props; const { status, intl, withDismiss } = this.props;
@ -174,6 +194,7 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
if (publicStatus) { if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
} }
@ -202,6 +223,7 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
if (isStaff) { if (isStaff) {
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });

View File

@ -5,6 +5,7 @@ import { isRtl } from '../rtl';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Permalink from './permalink'; import Permalink from './permalink';
import classnames from 'classnames'; import classnames from 'classnames';
import Icon from 'mastodon/components/icon';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
@ -160,7 +161,7 @@ export default class StatusContent extends React.PureComponent {
const readMoreButton = ( const readMoreButton = (
<button className='status__content__read-more-button' onClick={this.props.onClick} key='read-more'> <button className='status__content__read-more-button' onClick={this.props.onClick} key='read-more'>
<FormattedMessage id='status.read_more' defaultMessage='Read more' /><i className='fa fa-fw fa-angle-right' /> <FormattedMessage id='status.read_more' defaultMessage='Read more' /><Icon id='angle-right' fixedWidth />
</button> </button>
); );
@ -182,14 +183,14 @@ export default class StatusContent extends React.PureComponent {
return ( return (
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
<span dangerouslySetInnerHTML={spoilerContent} /> <span dangerouslySetInnerHTML={spoilerContent} lang={status.get('language')} />
{' '} {' '}
<button tabIndex='0' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick}>{toggleText}</button> <button tabIndex='0' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick}>{toggleText}</button>
</p> </p>
{mentionsPlaceholder} {mentionsPlaceholder}
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} /> <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
</div> </div>
); );
} else if (this.props.onClick) { } else if (this.props.onClick) {
@ -201,6 +202,7 @@ export default class StatusContent extends React.PureComponent {
className={classNames} className={classNames}
style={directionStyle} style={directionStyle}
dangerouslySetInnerHTML={content} dangerouslySetInnerHTML={content}
lang={status.get('language')}
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp} onMouseUp={this.handleMouseUp}
/>, />,
@ -219,6 +221,7 @@ export default class StatusContent extends React.PureComponent {
className='status__content' className='status__content'
style={directionStyle} style={directionStyle}
dangerouslySetInnerHTML={content} dangerouslySetInnerHTML={content}
lang={status.get('language')}
/> />
); );
} }

View File

@ -13,6 +13,7 @@ import { connectUserStream } from '../actions/streaming';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales'; import { getLocale } from '../locales';
import initialState from '../initial_state'; import initialState from '../initial_state';
import ErrorBoundary from '../components/error_boundary';
const { localeData, messages } = getLocale(); const { localeData, messages } = getLocale();
addLocaleData(localeData); addLocaleData(localeData);
@ -75,7 +76,9 @@ export default class Mastodon extends React.PureComponent {
return ( return (
<IntlProvider locale={locale} messages={messages}> <IntlProvider locale={locale} messages={messages}>
<Provider store={store}> <Provider store={store}>
<MastodonMount /> <ErrorBoundary>
<MastodonMount />
</ErrorBoundary>
</Provider> </Provider>
</IntlProvider> </IntlProvider>
); );

View File

@ -6,6 +6,7 @@ import { getLocale } from '../locales';
import MediaGallery from '../components/media_gallery'; import MediaGallery from '../components/media_gallery';
import Video from '../features/video'; import Video from '../features/video';
import Card from '../features/status/components/card'; import Card from '../features/status/components/card';
import Poll from 'mastodon/components/poll';
import ModalRoot from '../components/modal_root'; import ModalRoot from '../components/modal_root';
import MediaModal from '../features/ui/components/media_modal'; import MediaModal from '../features/ui/components/media_modal';
import { List as ImmutableList, fromJS } from 'immutable'; import { List as ImmutableList, fromJS } from 'immutable';
@ -13,7 +14,7 @@ import { List as ImmutableList, fromJS } from 'immutable';
const { localeData, messages } = getLocale(); const { localeData, messages } = getLocale();
addLocaleData(localeData); addLocaleData(localeData);
const MEDIA_COMPONENTS = { MediaGallery, Video, Card }; const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll };
export default class MediaContainer extends PureComponent { export default class MediaContainer extends PureComponent {
@ -54,11 +55,12 @@ export default class MediaContainer extends PureComponent {
{[].map.call(components, (component, i) => { {[].map.call(components, (component, i) => {
const componentName = component.getAttribute('data-component'); const componentName = component.getAttribute('data-component');
const Component = MEDIA_COMPONENTS[componentName]; const Component = MEDIA_COMPONENTS[componentName];
const { media, card, ...props } = JSON.parse(component.getAttribute('data-props')); const { media, card, poll, ...props } = JSON.parse(component.getAttribute('data-props'));
Object.assign(props, { Object.assign(props, {
...(media ? { media: fromJS(media) } : {}), ...(media ? { media: fromJS(media) } : {}),
...(card ? { card: fromJS(card) } : {}), ...(card ? { card: fromJS(card) } : {}),
...(poll ? { poll: fromJS(poll) } : {}),
...(componentName === 'Video' ? { ...(componentName === 'Video' ? {
onOpenVideo: this.handleOpenVideo, onOpenVideo: this.handleOpenVideo,

View File

@ -0,0 +1,8 @@
import { connect } from 'react-redux';
import Poll from 'mastodon/components/poll';
const mapStateToProps = (state, { pollId }) => ({
poll: state.getIn(['polls', pollId]),
});
export default connect(mapStateToProps)(Poll);

View File

@ -38,6 +38,7 @@ const messages = defineMessages({
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
}); });
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
@ -134,11 +135,17 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(openModal('VIDEO', { media, time })); dispatch(openModal('VIDEO', { media, time }));
}, },
onBlock (account) { onBlock (status) {
const account = status.get('account');
dispatch(openModal('CONFIRM', { dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.blockConfirm), confirm: intl.formatMessage(messages.blockConfirm),
onConfirm: () => dispatch(blockAccount(account.get('id'))), onConfirm: () => dispatch(blockAccount(account.get('id'))),
secondary: intl.formatMessage(messages.blockAndReport),
onSecondary: () => {
dispatch(blockAccount(account.get('id')));
dispatch(initReport(account, status));
},
})); }));
}, },

View File

@ -7,7 +7,6 @@ import { hydrateStore } from '../actions/store';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales'; import { getLocale } from '../locales';
import PublicTimeline from '../features/standalone/public_timeline'; import PublicTimeline from '../features/standalone/public_timeline';
import CommunityTimeline from '../features/standalone/community_timeline';
import HashtagTimeline from '../features/standalone/hashtag_timeline'; import HashtagTimeline from '../features/standalone/hashtag_timeline';
import ModalContainer from '../features/ui/containers/modal_container'; import ModalContainer from '../features/ui/containers/modal_container';
import initialState from '../initial_state'; import initialState from '../initial_state';
@ -26,24 +25,22 @@ export default class TimelineContainer extends React.PureComponent {
static propTypes = { static propTypes = {
locale: PropTypes.string.isRequired, locale: PropTypes.string.isRequired,
hashtag: PropTypes.string, hashtag: PropTypes.string,
showPublicTimeline: PropTypes.bool.isRequired, local: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
showPublicTimeline: initialState.settings.known_fediverse, local: !initialState.settings.known_fediverse,
}; };
render () { render () {
const { locale, hashtag, showPublicTimeline } = this.props; const { locale, hashtag, local } = this.props;
let timeline; let timeline;
if (hashtag) { if (hashtag) {
timeline = <HashtagTimeline hashtag={hashtag} />; timeline = <HashtagTimeline hashtag={hashtag} />;
} else if (showPublicTimeline) {
timeline = <PublicTimeline />;
} else { } else {
timeline = <CommunityTimeline />; timeline = <PublicTimeline local={local} />;
} }
return ( return (
@ -51,6 +48,7 @@ export default class TimelineContainer extends React.PureComponent {
<Provider store={store}> <Provider store={store}>
<Fragment> <Fragment>
{timeline} {timeline}
{ReactDOM.createPortal( {ReactDOM.createPortal(
<ModalContainer />, <ModalContainer />,
document.getElementById('modal-container'), document.getElementById('modal-container'),

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