Merge tag 'v2.8.2'

This commit is contained in:
Mike Barnes 2019-05-13 21:56:32 +10:00
commit fc50cffd44
727 changed files with 27044 additions and 9474 deletions

View File

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

View File

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

View File

@ -3,6 +3,171 @@ Changelog
All notable changes to this project will be documented in this file.
## [2.8.2] - 2019-05-05
### Added
- Add `SOURCE_TAG` environment variable ([ushitora-anqou](https://github.com/tootsuite/mastodon/pull/10698))
### Fixed
- Fix cropped hero image on frontpage ([BaptisteGelez](https://github.com/tootsuite/mastodon/pull/10702))
- Fix blurhash gem not compiling on some operating systems ([Gargron](https://github.com/tootsuite/mastodon/pull/10700))
- Fix unexpected CSS animations in some browsers ([ThibG](https://github.com/tootsuite/mastodon/pull/10699))
- Fix closing video modal scrolling timelines to top ([ThibG](https://github.com/tootsuite/mastodon/pull/10695))
## [2.8.1] - 2019-05-04
### Added
- Add link to existing domain block when trying to block an already-blocked domain ([ThibG](https://github.com/tootsuite/mastodon/pull/10663))
- Add button to view context to media modal when opened from account gallery in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10676))
- Add ability to create multiple-choice polls in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10603))
- Add `GITHUB_REPOSITORY` and `SOURCE_BASE_URL` environment variables ([rosylilly](https://github.com/tootsuite/mastodon/pull/10600))
- Add `/interact/` paths to `robots.txt` ([ThibG](https://github.com/tootsuite/mastodon/pull/10666))
- Add `blurhash` to the Attachment entity in the REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
### Changed
- Change hidden media to be shown as a blurhash-based colorful gradient instead of a black box in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
- Change rejected media to be shown as a blurhash-based gradient instead of a list of filenames in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
- Change e-mail whitelist/blacklist to not be checked when invited ([Gargron](https://github.com/tootsuite/mastodon/pull/10683))
- Change cache header of REST API results to no-cache ([ThibG](https://github.com/tootsuite/mastodon/pull/10655))
- Change the "mark media as sensitive" button to be more obvious in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10673), [Gargron](https://github.com/tootsuite/mastodon/pull/10682))
- Change account gallery in web UI to display 3 columns, open media modal ([Gargron](https://github.com/tootsuite/mastodon/pull/10667), [Gargron](https://github.com/tootsuite/mastodon/pull/10674))
### Fixed
- Fix LDAP/PAM/SAML/CAS users not being pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10621))
- Fix accounts created through tootctl not being always pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10684))
- Fix Sidekiq retrying ActivityPub processing jobs that fail validation ([ThibG](https://github.com/tootsuite/mastodon/pull/10614))
- Fix toots not being scrolled into view sometimes through keyboard selection ([ThibG](https://github.com/tootsuite/mastodon/pull/10593))
- Fix expired invite links being usable to bypass approval mode ([ThibG](https://github.com/tootsuite/mastodon/pull/10657))
- Fix not being able to save e-mail preference for new pending accounts ([Gargron](https://github.com/tootsuite/mastodon/pull/10622))
- Fix upload progressbar when image resizing is involved ([ThibG](https://github.com/tootsuite/mastodon/pull/10632))
- Fix block action not automatically cancelling pending follow request ([ThibG](https://github.com/tootsuite/mastodon/pull/10633))
- Fix stoplight logging to stderr separate from Rails logger ([Gargron](https://github.com/tootsuite/mastodon/pull/10624))
- Fix sign up button not saying sign up when invite is used ([Gargron](https://github.com/tootsuite/mastodon/pull/10623))
- Fix health checks in Docker Compose configuration ([fabianonline](https://github.com/tootsuite/mastodon/pull/10553))
- Fix modal items not being scrollable on touch devices ([kedamaDQ](https://github.com/tootsuite/mastodon/pull/10605))
- Fix Keybase configuration using wrong domain when a web domain is used ([BenLubar](https://github.com/tootsuite/mastodon/pull/10565))
- Fix avatar GIFs not being animated on-hover on public profiles ([hyenagirl64](https://github.com/tootsuite/mastodon/pull/10549))
- Fix OpenGraph parser not understanding some valid property meta tags ([da2x](https://github.com/tootsuite/mastodon/pull/10604))
- Fix wrong fonts being displayed when Roboto is installed on user's machine ([ThibG](https://github.com/tootsuite/mastodon/pull/10594))
- Fix confirmation modals being too narrow for a secondary action button ([ThibG](https://github.com/tootsuite/mastodon/pull/10586))
## [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
### Added

View File

@ -1,90 +1,128 @@
FROM node:8.15-alpine as node
FROM ruby:2.6-alpine3.8
FROM ubuntu:18.04 as build-dep
LABEL maintainer="https://github.com/tootsuite/mastodon" \
description="Your self-hosted, globally interconnected microblogging community"
# Use bash for the shell
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 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 \
RAILS_SERVE_STATIC_FILES=true \
RAILS_ENV=production \
NODE_ENV=production
# Install masto runtime deps
RUN apt -y --no-install-recommends install \
libssl1.1 libpq5 imagemagick ffmpeg \
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
ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178
# Add tini
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
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
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
# Tell rails to serve static files
ENV RAILS_SERVE_STATIC_FILES="true"
# Set the run user
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", "--"]

41
Gemfile
View File

@ -6,31 +6,32 @@ ruby '>= 2.4.0', '< 2.7.0'
gem 'pkg-config', '~> 1.3'
gem 'puma', '~> 3.12'
gem 'rails', '~> 5.2.2'
gem 'rails', '~> 5.2.3'
gem 'thor', '~> 0.20'
gem 'hamlit-rails', '~> 0.2'
gem 'pg', '~> 1.1'
gem 'makara', '~> 0.4'
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-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0'
gem 'paperclip-av-transcoder', '~> 0.6'
gem 'streamio-ffmpeg', '~> 3.0'
gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.6'
gem 'bootsnap', '~> 1.3', require: false
gem 'bootsnap', '~> 1.4', require: false
gem 'browser'
gem 'charlock_holmes', '~> 0.7.6'
gem 'iso-639'
gem 'chewy', '~> 5.0'
gem 'cld3', '~> 3.2.3'
gem 'devise', '~> 4.5'
gem 'cld3', '~> 3.2.4'
gem 'devise', '~> 4.6'
gem 'devise-two-factor', '~> 3.0'
group :pam_authentication, optional: true do
@ -42,7 +43,7 @@ gem 'omniauth-cas', '~> 1.1'
gem 'omniauth-saml', '~> 1.10'
gem 'omniauth', '~> 1.9'
gem 'doorkeeper', '~> 5.0'
gem 'doorkeeper', '~> 5.1'
gem 'fast_blank', '~> 1.0'
gem 'fastimage'
gem 'goldfinger', '~> 2.1'
@ -65,7 +66,7 @@ gem 'ox', '~> 2.10'
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
gem 'pundit', '~> 2.0'
gem 'premailer-rails'
gem 'rack-attack', '~> 5.4'
gem 'rack-attack', '~> 6.0'
gem 'rack-cors', '~> 1.0', require: 'rack/cors'
gem 'rails-i18n', '~> 5.1'
gem 'rails-settings-cached', '~> 0.6'
@ -85,8 +86,8 @@ gem 'strong_migrations', '~> 0.3'
gem 'tty-command', '~> 0.8', require: false
gem 'tty-prompt', '~> 0.18', require: false
gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2018'
gem 'webpacker', '~> 3.5'
gem 'tzinfo-data', '~> 1.2019'
gem 'webpacker', '~> 4.0'
gem 'webpush'
gem 'json-ld', '~> 3.0'
@ -97,7 +98,7 @@ group :development, :test do
gem 'fabrication', '~> 2.20'
gem 'fuubar', '~> 2.3'
gem 'i18n-tasks', '~> 0.9', require: false
gem 'pry-byebug', '~> 3.6'
gem 'pry-byebug', '~> 3.7'
gem 'pry-rails', '~> 0.3'
gem 'rspec-rails', '~> 3.8'
end
@ -107,30 +108,30 @@ group :production, :test do
end
group :test do
gem 'capybara', '~> 3.12'
gem 'capybara', '~> 3.18'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 1.9'
gem 'microformats', '~> 4.0'
gem 'microformats', '~> 4.1'
gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.0'
gem 'simplecov', '~> 0.16', require: false
gem 'webmock', '~> 3.5'
gem 'parallel_tests', '~> 2.27'
gem 'parallel_tests', '~> 2.28'
end
group :development do
gem 'active_record_query_trace', '~> 1.5'
gem 'active_record_query_trace', '~> 1.6'
gem 'annotate', '~> 2.7'
gem 'better_errors', '~> 2.5'
gem 'binding_of_caller', '~> 0.7'
gem 'bullet', '~> 5.9'
gem 'bullet', '~> 6.0'
gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler'
gem 'rubocop', '~> 0.63', require: false
gem 'brakeman', '~> 4.4', require: false
gem 'rubocop', '~> 0.68', require: false
gem 'brakeman', '~> 4.5', require: false
gem 'bundler-audit', '~> 0.6', require: false
gem 'scss_lint', '~> 0.57', require: false
gem 'scss_lint', '~> 0.58', require: false
gem 'capistrano', '~> 3.11'
gem 'capistrano-rails', '~> 1.4'
@ -142,7 +143,7 @@ group :development do
end
group :production do
gem 'lograge', '~> 0.10'
gem 'lograge', '~> 0.11'
gem 'redis-rails', '~> 5.0'
end

View File

@ -15,49 +15,49 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (5.2.2)
actionpack (= 5.2.2)
actioncable (5.2.3)
actionpack (= 5.2.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailer (5.2.2)
actionpack (= 5.2.2)
actionview (= 5.2.2)
activejob (= 5.2.2)
actionmailer (5.2.3)
actionpack (= 5.2.3)
actionview (= 5.2.3)
activejob (= 5.2.3)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (5.2.2)
actionview (= 5.2.2)
activesupport (= 5.2.2)
actionpack (5.2.3)
actionview (= 5.2.3)
activesupport (= 5.2.3)
rack (~> 2.0)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionview (5.2.2)
activesupport (= 5.2.2)
actionview (5.2.3)
activesupport (= 5.2.3)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.3)
active_model_serializers (0.10.8)
active_model_serializers (0.10.9)
actionpack (>= 4.1, < 6)
activemodel (>= 4.1, < 6)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
active_record_query_trace (1.5.4)
activejob (5.2.2)
activesupport (= 5.2.2)
active_record_query_trace (1.6.2)
activejob (5.2.3)
activesupport (= 5.2.3)
globalid (>= 0.3.6)
activemodel (5.2.2)
activesupport (= 5.2.2)
activerecord (5.2.2)
activemodel (= 5.2.2)
activesupport (= 5.2.2)
activemodel (5.2.3)
activesupport (= 5.2.3)
activerecord (5.2.3)
activemodel (= 5.2.3)
activesupport (= 5.2.3)
arel (>= 9.0)
activestorage (5.2.2)
actionpack (= 5.2.2)
activerecord (= 5.2.2)
activestorage (5.2.3)
actionpack (= 5.2.3)
activerecord (= 5.2.3)
marcel (~> 0.3.1)
activesupport (5.2.2)
activesupport (5.2.3)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
@ -66,8 +66,8 @@ GEM
public_suffix (>= 2.0.2, < 4.0)
airbrussh (1.3.0)
sshkit (>= 1.6.1, != 1.7.0)
annotate (2.7.4)
activerecord (>= 3.2, < 6.0)
annotate (2.7.5)
activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 13.0)
arel (9.0.0)
ast (2.4.0)
@ -75,41 +75,44 @@ GEM
encryptor (~> 3.0.0)
av (0.9.0)
cocaine (~> 0.5.3)
aws-eventstream (1.0.1)
aws-partitions (1.131.0)
aws-sdk-core (3.45.0)
aws-eventstream (~> 1.0)
aws-eventstream (1.0.2)
aws-partitions (1.151.0)
aws-sdk-core (3.48.4)
aws-eventstream (~> 1.0, >= 1.0.2)
aws-partitions (~> 1.0)
aws-sigv4 (~> 1.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.13.0)
aws-sdk-core (~> 3, >= 3.39.0)
aws-sigv4 (~> 1.0)
aws-sdk-s3 (1.30.1)
aws-sdk-core (~> 3, >= 3.39.0)
aws-sdk-kms (1.17.0)
aws-sdk-core (~> 3, >= 3.48.2)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.36.1)
aws-sdk-core (~> 3, >= 3.48.2)
aws-sdk-kms (~> 1)
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)
benchmark-ips (2.7.2)
better_errors (2.5.0)
better_errors (2.5.1)
coderay (>= 1.0.0)
erubi (>= 1.0.0)
rack (>= 0.9.0)
binding_of_caller (0.8.0)
debug_inspector (>= 0.0.1)
bootsnap (1.3.2)
blurhash (0.1.3)
ffi (~> 1.10.0)
bootsnap (1.4.4)
msgpack (~> 1.0)
brakeman (4.4.0)
brakeman (4.5.0)
browser (2.5.3)
builder (3.2.3)
bullet (5.9.0)
bullet (6.0.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundler-audit (0.6.1)
bundler (>= 1.2.0, < 3)
thor (~> 0.18)
byebug (10.0.2)
byebug (11.0.0)
capistrano (3.11.0)
airbrussh (>= 1.0.0)
i18n
@ -126,7 +129,7 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (3.12.0)
capybara (3.18.0)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
@ -142,13 +145,13 @@ GEM
elasticsearch (>= 2.0.0)
elasticsearch-dsl
chunky_png (1.3.10)
cld3 (3.2.3)
ffi (>= 1.1.0, < 1.10.0)
cld3 (3.2.4)
ffi (>= 1.1.0, < 1.11.0)
climate_control (0.2.0)
cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0)
coderay (1.1.2)
concurrent-ruby (1.1.4)
concurrent-ruby (1.1.5)
connection_pool (2.2.2)
crack (0.4.3)
safe_yaml (~> 1.0.0)
@ -164,7 +167,7 @@ GEM
rack (>= 1)
rake (> 10, < 13)
thor (~> 0.19)
devise (4.5.0)
devise (4.6.2)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0, < 6.0)
@ -183,12 +186,12 @@ GEM
docile (1.3.0)
domain_name (0.5.20180417)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.0.2)
railties (>= 4.2)
dotenv (2.6.0)
dotenv-rails (2.6.0)
dotenv (= 2.6.0)
railties (>= 3.2, < 6.0)
doorkeeper (5.1.0)
railties (>= 5)
dotenv (2.7.2)
dotenv-rails (2.7.2)
dotenv (= 2.7.2)
railties (>= 3.2, < 6.1)
elasticsearch (6.0.2)
elasticsearch-api (= 6.0.2)
elasticsearch-transport (= 6.0.2)
@ -204,14 +207,14 @@ GEM
et-orbi (1.1.6)
tzinfo
excon (0.62.0)
fabrication (2.20.1)
faker (1.9.1)
fabrication (2.20.2)
faker (1.9.3)
i18n (>= 0.7)
faraday (0.15.0)
multipart-post (>= 1.2, < 3)
fast_blank (1.0.0)
fastimage (2.1.5)
ffi (1.9.25)
ffi (1.10.0)
fog-core (2.1.0)
builder
excon (~> 0.58)
@ -232,18 +235,18 @@ GEM
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
get_process_mem (0.2.3)
globalid (0.4.1)
globalid (0.4.2)
activesupport (>= 4.2.0)
goldfinger (2.1.0)
addressable (~> 2.5)
http (~> 3.0)
nokogiri (~> 1.8)
oj (~> 3.0)
hamlit (2.8.8)
hamlit (2.9.3)
temple (>= 0.8.0)
thor
tilt
hamlit-rails (0.2.0)
hamlit-rails (0.2.3)
actionpack (>= 4.0.1)
activesupport (>= 4.0.1)
hamlit (>= 1.2.0)
@ -253,7 +256,7 @@ GEM
hashdiff (0.3.7)
hashie (3.6.0)
heapy (0.1.4)
highline (2.0.0)
highline (2.0.1)
hiredis (0.6.3)
hkdf (0.3.0)
htmlentities (4.3.4)
@ -266,12 +269,12 @@ GEM
domain_name (~> 0.5)
http-form_data (2.1.1)
http_accept_language (2.1.1)
httplog (1.2.0)
httplog (1.2.2)
rack (>= 1.0)
rainbow (>= 2.0.0)
i18n (1.5.2)
i18n (1.6.0)
concurrent-ruby (~> 1.0)
i18n-tasks (0.9.28)
i18n-tasks (0.9.29)
activesupport (>= 4.0.2)
ast (>= 2.1.0)
erubi
@ -317,7 +320,7 @@ GEM
letter_opener (~> 1.0)
railties (>= 3.2)
link_header (0.0.8)
lograge (0.10.0)
lograge (0.11.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
@ -327,25 +330,25 @@ GEM
nokogiri (>= 1.5.9)
mail (2.7.1)
mini_mime (>= 0.1.1)
makara (0.4.0)
makara (0.4.1)
activerecord (>= 3.0.0)
marcel (0.3.3)
mimemagic (~> 0.3.2)
mario-redis-lock (1.2.1)
redis (>= 3.0.5)
memory_profiler (0.9.12)
memory_profiler (0.9.13)
method_source (0.9.2)
microformats (4.0.7)
json
nokogiri
microformats (4.1.0)
json (~> 2.1)
nokogiri (~> 1.8, >= 1.8.3)
mime-types (3.2.2)
mime-types-data (~> 3.2015)
mime-types-data (3.2018.0812)
mimemagic (0.3.2)
mimemagic (0.3.3)
mini_mime (1.0.1)
mini_portile2 (2.4.0)
minitest (5.11.3)
msgpack (1.2.4)
msgpack (1.2.10)
multi_json (1.13.1)
multipart-post (2.0.0)
necromancer (0.4.0)
@ -354,7 +357,7 @@ GEM
net-ssh (>= 2.6.5)
net-ssh (5.0.2)
nio4r (2.3.1)
nokogiri (1.10.1)
nokogiri (1.10.3)
mini_portile2 (~> 2.4.0)
nokogumbo (2.0.0)
nokogiri (~> 1.8, >= 1.8.4)
@ -363,7 +366,7 @@ GEM
concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.7.8)
oj (3.7.12)
omniauth (1.9.0)
hashie (>= 3.4.6, < 3.7.0)
rack (>= 1.6.2, < 3)
@ -389,10 +392,10 @@ GEM
paperclip-av-transcoder (0.6.4)
av (~> 0.9.0)
paperclip (>= 2.5.2)
parallel (1.13.0)
parallel_tests (2.27.1)
parallel (1.17.0)
parallel_tests (2.28.0)
parallel
parser (2.6.0.0)
parser (2.6.3.0)
ast (~> 2.4.0)
pastel (0.7.2)
equatable (~> 0.5.0)
@ -400,8 +403,7 @@ GEM
pg (1.1.4)
pghero (2.2.0)
activerecord
pkg-config (1.3.2)
powerpack (0.1.2)
pkg-config (1.3.7)
premailer (1.11.1)
addressable
css_parser (>= 1.6.0)
@ -413,38 +415,38 @@ GEM
pry (0.12.2)
coderay (~> 1.1.0)
method_source (~> 0.9.0)
pry-byebug (3.6.0)
byebug (~> 10.0)
pry-byebug (3.7.0)
byebug (~> 11.0)
pry (~> 0.10)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (3.0.3)
puma (3.12.0)
puma (3.12.1)
pundit (2.0.1)
activesupport (>= 3.0.0)
raabro (1.1.6)
rack (2.0.6)
rack-attack (5.4.2)
rack (2.0.7)
rack-attack (6.0.0)
rack (>= 1.0, < 3)
rack-cors (1.0.2)
rack-cors (1.0.3)
rack-protection (2.0.5)
rack
rack-proxy (0.6.4)
rack-proxy (0.6.5)
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rails (5.2.2)
actioncable (= 5.2.2)
actionmailer (= 5.2.2)
actionpack (= 5.2.2)
actionview (= 5.2.2)
activejob (= 5.2.2)
activemodel (= 5.2.2)
activerecord (= 5.2.2)
activestorage (= 5.2.2)
activesupport (= 5.2.2)
rails (5.2.3)
actioncable (= 5.2.3)
actionmailer (= 5.2.3)
actionpack (= 5.2.3)
actionview (= 5.2.3)
activejob (= 5.2.3)
activemodel (= 5.2.3)
activerecord (= 5.2.3)
activestorage (= 5.2.3)
activesupport (= 5.2.3)
bundler (>= 1.3.0)
railties (= 5.2.2)
railties (= 5.2.3)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.4)
actionpack (>= 5.0.1.x)
@ -455,22 +457,22 @@ GEM
nokogiri (>= 1.6)
rails-html-sanitizer (1.0.4)
loofah (~> 2.2, >= 2.2.2)
rails-i18n (5.1.2)
rails-i18n (5.1.3)
i18n (>= 0.7, < 2)
railties (>= 5.0, < 6)
rails-settings-cached (0.6.6)
rails (>= 4.2.0)
railties (5.2.2)
actionpack (= 5.2.2)
activesupport (= 5.2.2)
railties (5.2.3)
actionpack (= 5.2.3)
activesupport (= 5.2.3)
method_source
rake (>= 0.8.7)
thor (>= 0.19.0, < 2.0)
rainbow (3.0.0)
rake (12.3.2)
rb-fsevent (0.10.3)
rb-inotify (0.9.10)
ffi (>= 0.5.0, < 2)
rb-inotify (0.10.0)
ffi (~> 1.0)
rdf (3.0.9)
hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8)
@ -495,12 +497,12 @@ GEM
redis-store (>= 1.2, < 2)
redis-store (1.5.0)
redis (>= 2.2, < 5)
regexp_parser (1.3.0)
regexp_parser (1.4.0)
request_store (1.4.1)
rack (>= 1.4)
responders (2.4.0)
actionpack (>= 4.2.0, < 5.3)
railties (>= 4.2.0, < 5.3)
responders (2.4.1)
actionpack (>= 4.2.0, < 6.0)
railties (>= 4.2.0, < 6.0)
rotp (2.1.2)
rpam2 (4.0.2)
rqrcode (0.10.1)
@ -525,14 +527,13 @@ GEM
rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0)
rspec-support (3.8.0)
rubocop (0.63.1)
rubocop (0.68.1)
jaro_winkler (~> 1.5.1)
parallel (~> 1.10)
parser (>= 2.5, != 2.5.1.1)
powerpack (~> 0.1)
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.4.0)
unicode-display_width (>= 1.4.0, < 1.6)
ruby-progressbar (1.10.0)
ruby-saml (1.9.0)
nokogiri (>= 1.5.10)
@ -543,15 +544,15 @@ GEM
crass (~> 1.0.2)
nokogiri (>= 1.8.0)
nokogumbo (~> 2.0)
sass (3.6.0)
sass (3.7.4)
sass-listen (~> 4.0.0)
sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
scss_lint (0.57.1)
scss_lint (0.58.0)
rake (>= 0.9, < 13)
sass (~> 3.5, >= 3.5.5)
sidekiq (5.2.5)
sidekiq (5.2.7)
connection_pool (~> 2.2, >= 2.2.2)
rack (>= 1.5.0)
rack-protection (>= 1.5.0)
@ -563,9 +564,9 @@ GEM
rufus-scheduler (~> 3.2)
sidekiq (>= 3)
tilt (>= 1.4.0)
sidekiq-unique-jobs (6.0.8)
sidekiq-unique-jobs (6.0.13)
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 4.0, < 6.0)
sidekiq (>= 4.0, < 7.0)
thor (~> 0)
simple-navigation (4.0.5)
activesupport (>= 2.3.2)
@ -594,14 +595,14 @@ GEM
multi_json (~> 1.8)
strong_migrations (0.3.1)
activerecord (>= 3.2.0)
temple (0.8.0)
temple (0.8.1)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
terrapin (0.6.0)
climate_control (>= 0.0.3, < 1.0)
thor (0.20.3)
thread_safe (0.3.6)
tilt (2.0.8)
tilt (2.0.9)
timers (4.2.0)
tty-color (0.4.3)
tty-command (0.8.2)
@ -622,24 +623,24 @@ GEM
unf (~> 0.1.0)
tzinfo (1.2.5)
thread_safe (~> 0.1)
tzinfo-data (1.2018.9)
tzinfo-data (1.2019.1)
tzinfo (>= 1.0.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.5)
unicode-display_width (1.4.1)
unicode-display_width (1.5.0)
uniform_notifier (1.12.1)
warden (1.2.7)
rack (>= 1.0)
warden (1.2.8)
rack (>= 2.0.6)
webmock (3.5.1)
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff
webpacker (3.5.5)
webpacker (4.0.2)
activesupport (>= 4.2)
rack-proxy (>= 0.6.1)
railties (>= 4.2)
webpush (0.3.6)
webpush (0.3.8)
hkdf (~> 0.2)
jwt (~> 2.0)
websocket-driver (0.7.0)
@ -654,33 +655,34 @@ PLATFORMS
DEPENDENCIES
active_model_serializers (~> 0.10)
active_record_query_trace (~> 1.5)
active_record_query_trace (~> 1.6)
addressable (~> 2.6)
annotate (~> 2.7)
aws-sdk-s3 (~> 1.30)
aws-sdk-s3 (~> 1.36)
better_errors (~> 2.5)
binding_of_caller (~> 0.7)
bootsnap (~> 1.3)
brakeman (~> 4.4)
blurhash (~> 0.1)
bootsnap (~> 1.4)
brakeman (~> 4.5)
browser
bullet (~> 5.9)
bullet (~> 6.0)
bundler-audit (~> 0.6)
capistrano (~> 3.11)
capistrano-rails (~> 1.4)
capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0)
capybara (~> 3.12)
capybara (~> 3.18)
charlock_holmes (~> 0.7.6)
chewy (~> 5.0)
cld3 (~> 3.2.3)
cld3 (~> 3.2.4)
climate_control (~> 0.2)
concurrent-ruby
derailed_benchmarks
devise (~> 4.5)
devise (~> 4.6)
devise-two-factor (~> 3.0)
devise_pam_authenticatable2 (~> 9.2)
doorkeeper (~> 5.0)
dotenv-rails (~> 2.6)
doorkeeper (~> 5.1)
dotenv-rails (~> 2.7)
fabrication (~> 2.20)
faker (~> 1.9)
fast_blank (~> 1.0)
@ -705,11 +707,11 @@ DEPENDENCIES
letter_opener (~> 1.7)
letter_opener_web (~> 1.3)
link_header (~> 0.0)
lograge (~> 0.10)
lograge (~> 0.11)
makara (~> 0.4)
mario-redis-lock (~> 1.2)
memory_profiler
microformats (~> 4.0)
microformats (~> 4.1)
mime-types (~> 3.2)
net-ldap (~> 0.10)
nokogiri (~> 1.10)
@ -722,20 +724,20 @@ DEPENDENCIES
ox (~> 2.10)
paperclip (~> 6.0)
paperclip-av-transcoder (~> 0.6)
parallel_tests (~> 2.27)
parallel_tests (~> 2.28)
pg (~> 1.1)
pghero (~> 2.2)
pkg-config (~> 1.3)
posix-spawn!
premailer-rails
private_address_check (~> 0.5)
pry-byebug (~> 3.6)
pry-byebug (~> 3.7)
pry-rails (~> 0.3)
puma (~> 3.12)
pundit (~> 2.0)
rack-attack (~> 5.4)
rack-attack (~> 6.0)
rack-cors (~> 1.0)
rails (~> 5.2.2)
rails (~> 5.2.3)
rails-controller-testing (~> 1.0)
rails-i18n (~> 5.1)
rails-settings-cached (~> 0.6)
@ -746,9 +748,9 @@ DEPENDENCIES
rqrcode (~> 0.10)
rspec-rails (~> 3.8)
rspec-sidekiq (~> 3.0)
rubocop (~> 0.63)
rubocop (~> 0.68)
sanitize (~> 5.0)
scss_lint (~> 0.57)
scss_lint (~> 0.58)
sidekiq (~> 5.2)
sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 3.0)
@ -765,13 +767,13 @@ DEPENDENCIES
tty-command (~> 0.8)
tty-prompt (~> 0.18)
twitter-text (~> 1.14)
tzinfo-data (~> 1.2018)
tzinfo-data (~> 1.2019)
webmock (~> 3.5)
webpacker (~> 3.5)
webpacker (~> 4.0)
webpush
RUBY VERSION
ruby 2.6.0p0
ruby 2.6.1p33
BUNDLED WITH
1.17.3

13
Vagrantfile vendored
View File

@ -44,7 +44,18 @@ sudo apt-get install \
# Install rvm
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
source /home/vagrant/.rvm/scripts/rvm

View File

@ -48,6 +48,7 @@ class StatusesIndex < Chewy::Index
end
root date_detection: false do
field :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
@ -55,7 +56,6 @@ class StatusesIndex < Chewy::Index
end
field :searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) }
field :created_at, type: 'date'
end
end
end

View File

@ -1,26 +1,25 @@
# frozen_string_literal: true
class AboutController < ApplicationController
before_action :set_body_classes
layout 'public'
before_action :set_instance_presenter, only: [:show, :more, :terms]
def show
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
@initial_state_json = serializable_resource.to_json
@hide_navbar = true
end
def more
render layout: 'public'
end
def more; end
def terms
render layout: 'public'
end
def terms; end
private
def new_user
User.new.tap(&:build_account)
User.new.tap do |user|
user.build_account
user.build_invite_request
end
end
helper_method :new_user
@ -28,15 +27,4 @@ class AboutController < ApplicationController
def set_instance_presenter
@instance_presenter = InstancePresenter.new
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

View File

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

View File

@ -2,11 +2,14 @@
class ActivityPub::InboxesController < Api::BaseController
include SignatureVerification
include JsonLdHelper
before_action :set_account
def create
if signed_request_account
if unknown_deleted_account?
head 202
elsif signed_request_account
upgrade_account
process_payload
head 202
@ -17,12 +20,22 @@ class ActivityPub::InboxesController < Api::BaseController
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
@account = Account.find_local!(params[:account_username]) if params[:account_username]
end
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
def upgrade_account
@ -36,6 +49,6 @@ class ActivityPub::InboxesController < Api::BaseController
end
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

View File

@ -7,8 +7,14 @@ class ActivityPub::OutboxesController < Api::BaseController
before_action :set_account
before_action :set_statuses
before_action :set_cache_headers
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'
end

View File

@ -2,9 +2,9 @@
module Admin
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_local_account!, only: [:enable, :memorialize]
before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject]
def index
authorize :account, :index?
@ -45,6 +45,18 @@ module Admin
redirect_to admin_account_path(@account.id)
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
authorize @account, :unsilence?
@account.unsilence!
@ -114,6 +126,7 @@ module Admin
:remote,
:by_domain,
:active,
:pending,
:silenced,
:suspended,
:username,

View File

@ -5,6 +5,9 @@ module Admin
before_action :set_custom_emoji, except: [:index, :new, :create]
before_action :set_filter_params
include ObfuscateFilename
obfuscate_filename [:custom_emoji, :image]
def index
authorize :custom_emoji, :index?
@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
@relay_enabled = Relay.enabled.exists?
@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
@invites_enabled = Setting.min_invite_role == 'user'
@search_enabled = Chewy.enabled?
@ -29,6 +29,7 @@ module Admin
@hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true'
@trending_hashtags = TrendingTags.get(7)
@profile_directory = Setting.profile_directory
@timeline_preview = Setting.timeline_preview
end
private

View File

@ -13,13 +13,25 @@ module Admin
authorize :domain_block, :create?
@domain_block = DomainBlock.new(resource_params)
existing_domain_block = resource_params[:domain].present? ? DomainBlock.find_by(domain: resource_params[:domain]) : nil
if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id)
log_action :create, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
else
if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
@domain_block.save
flash[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
@domain_block.errors[:domain].clear
render :new
else
if existing_domain_block.present?
@domain_block = existing_domain_block
@domain_block.update(resource_params)
end
if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id)
log_action :create, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
else
render :new
end
end
end

View File

@ -38,7 +38,7 @@ module Admin
end
def filter_params
params.permit(:limited)
params.permit(:limited, :by_domain)
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))
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)
end

View File

@ -2,85 +2,29 @@
module Admin
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
authorize :settings, :show?
@admin_settings = Form::AdminSettings.new
end
def update
authorize :settings, :update?
settings_params.each do |key, value|
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
@admin_settings = Form::AdminSettings.new(settings_params)
flash[:notice] = I18n.t('generic.changes_saved_msg')
redirect_to edit_admin_settings_path
if @admin_settings.save
flash[:notice] = I18n.t('generic.changes_saved_msg')
redirect_to edit_admin_settings_path
else
render :edit
end
end
private
def settings_params
params.require(:form_admin_settings).permit(ADMIN_SETTINGS)
end
def value_for_update(key, value)
if BOOLEAN_SETTINGS.include?(key)
value == '1'
else
value
end
params.require(:form_admin_settings).permit(*Form::AdminSettings::KEYS)
end
end
end

View File

@ -9,6 +9,8 @@ class Api::BaseController < ApplicationController
skip_before_action :store_current_location
skip_before_action :check_user_permissions
before_action :set_cache_headers
protect_from_forgery with: :null_session
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
@ -73,7 +75,9 @@ class Api::BaseController < ApplicationController
elsif current_user.disabled?
render json: { error: 'Your login is currently disabled' }, status: 403
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
set_user_activity
end
@ -86,4 +90,8 @@ class Api::BaseController < ApplicationController
def authorize_if_got_token!(*scopes)
doorkeeper_authorize!(*scopes) if doorkeeper_token
end
def set_cache_headers
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
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
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
end
def hide_results?
(@account.user_hides_network? && current_account.id != @account.id) || (current_account && @account.blocking?(current_account))
end
def default_accounts
Account.includes(:active_relationships, :account_stat).references(:active_relationships)
end

View File

@ -19,11 +19,15 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
end
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
end
def hide_results?
(@account.user_hides_network? && current_account.id != @account.id) || (current_account && @account.blocking?(current_account))
end
def default_accounts
Account.includes(:passive_relationships, :account_stat).references(:passive_relationships)
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
AccountSearchService.new.call(
params[:q],
limit_param(DEFAULT_ACCOUNTS_LIMIT),
current_account,
limit: limit_param(DEFAULT_ACCOUNTS_LIMIT),
resolve: truthy_param?(:resolve),
following: truthy_param?(:following)
following: truthy_param?(:following),
offset: params[:offset]
)
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!(no_replies_scope) if truthy_param?(:exclude_replies)
statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
statuses.merge!(hashtag_scope) if params[:tagged].present?
statuses
end
@ -50,9 +51,9 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
# Also, Avoid getting slow by not narrowing down by `statuses.account_id`.
# When narrowing down by `statuses.account_id`, `index_statuses_20180106` will be used
# and the table will be joined by `Merge Semi Join`, so the query will be slow.
Status.joins(:media_attachments).merge(@account.media_attachments).permitted_for(@account, current_account)
.paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
.reorder(id: :desc).distinct(:id).pluck(:id)
@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])
.reorder(id: :desc).distinct(:id).pluck(:id)
end
def pinned_scope
@ -67,6 +68,16 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
Status.without_reblogs
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)
params.slice(:limit, :only_media, :exclude_replies).permit(:limit, :only_media, :exclude_replies).merge(core_params)
end

View File

@ -80,6 +80,10 @@ class Api::V1::AccountsController < Api::BaseController
end
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

View File

@ -3,6 +3,8 @@
class Api::V1::CustomEmojisController < Api::BaseController
respond_to :json
skip_before_action :set_cache_headers
def index
render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do
ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer)

View File

@ -2,6 +2,7 @@
class Api::V1::Instances::ActivityController < Api::BaseController
before_action :require_enabled_api!
skip_before_action :set_cache_headers
respond_to :json

View File

@ -2,6 +2,7 @@
class Api::V1::Instances::PeersController < Api::BaseController
before_action :require_enabled_api!
skip_before_action :set_cache_headers
respond_to :json

View File

@ -2,6 +2,7 @@
class Api::V1::InstancesController < Api::BaseController
respond_to :json
skip_before_action :set_cache_headers
def show
render_cached_json('api:v1:instances', expires_in: 5.minutes) do

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
include Authorization
RESULTS_LIMIT = 5
RESULTS_LIMIT = 20
before_action -> { doorkeeper_authorize! :read, :'read:search' }
before_action :require_user!
@ -11,30 +11,22 @@ class Api::V1::SearchController < Api::BaseController
respond_to :json
def index
@search = Search.new(search)
@search = Search.new(search_results)
render json: @search, serializer: REST::SearchSerializer
end
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
SearchService.new.call(
params[:q],
RESULTS_LIMIT,
truthy_param?(:resolve),
current_account
current_account,
limit_param(RESULTS_LIMIT),
search_params.merge(resolve: truthy_param?(:resolve))
)
end
def search_params
params.permit(:type, :offset, :min_id, :max_id, :account_id)
end
end

View File

@ -9,7 +9,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
respond_to :json
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
end
@ -32,4 +32,8 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
def status_for_destroy
current_user.account.statuses.where(reblog_of_id: params[:status_id]).first!
end
def reblog_params
params.permit(:visibility)
end
end

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :set_instance_presenter, only: [:new, :create, :update]
before_action :set_body_classes, only: [:new, :create, :edit, :update]
def new
super(&:build_invite_request)
end
def destroy
not_found
end
@ -24,9 +28,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def build_resource(hash = nil)
super(hash)
resource.locale = I18n.locale
resource.invite_code = params[:invite_code] if resource.invite_code.blank?
resource.agreement = true
resource.locale = I18n.locale
resource.invite_code = params[:invite_code] if resource.invite_code.blank?
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?
@ -34,7 +39,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def configure_sign_up_params
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
@ -65,7 +70,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
def allowed_registrations?
Setting.open_registrations || @invite&.valid_for_use?
Setting.registrations_mode != 'none' || @invite&.valid_for_use?
end
def invite_code
@ -87,7 +92,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
def set_invite
@invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
@invite = invite&.valid_for_use? ? invite : nil
end
def determine_layout

View File

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

View File

@ -32,7 +32,7 @@ class DirectoriesController < ApplicationController
end
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
end
end

View File

@ -3,9 +3,13 @@
class FollowerAccountsController < ApplicationController
include AccountControllerConcern
before_action :set_cache_headers
def index
respond_to do |format|
format.html do
mark_cacheable! unless user_signed_in?
next if @account.user_hides_network?
follows
@ -15,6 +19,11 @@ class FollowerAccountsController < ApplicationController
format.json do
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,
serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter,

View File

@ -3,9 +3,13 @@
class FollowingAccountsController < ApplicationController
include AccountControllerConcern
before_action :set_cache_headers
def index
respond_to do |format|
format.html do
mark_cacheable! unless user_signed_in?
next if @account.user_hides_network?
follows
@ -15,6 +19,11 @@ class FollowingAccountsController < ApplicationController
format.json do
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,
serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter,

View File

@ -50,7 +50,7 @@ class HomeController < ApplicationController
push_subscription: current_account.user.web_push_subscription(current_session),
current_account: current_account,
token: current_session.token,
admin: Account.find_local(Setting.site_contact_username),
admin: Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')),
}
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
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)
redirect_to settings_export_path
end
def lock_options
{ redis: Redis.current, key: "backup:#{current_user.id}" }
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

@ -25,7 +25,7 @@ class Settings::NotificationsController < Settings::BaseController
def user_settings_params
params.require(:user).permit(
notification_emails: %i(follow follow_request reblog favourite mention digest report),
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
interactions: %i(must_be_follower must_be_following must_be_following_dm)
)
end

View File

@ -48,7 +48,8 @@ class Settings::PreferencesController < Settings::BaseController
:setting_theme,
:setting_hide_network,
: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)
)
end

View File

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

View File

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

View File

@ -21,7 +21,7 @@ class SharesController < ApplicationController
push_subscription: current_account.user.web_push_subscription(current_session),
current_account: current_account,
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,
}
end

View File

@ -18,6 +18,7 @@ class StatusesController < ApplicationController
before_action :redirect_to_original, only: [:show]
before_action :set_referrer_policy_header, only: [:show]
before_action :set_cache_headers
before_action :set_replies, only: [:replies]
content_security_policy only: :embed do |p|
p.frame_ancestors(false)
@ -26,6 +27,8 @@ class StatusesController < ApplicationController
def show
respond_to do |format|
format.html do
mark_cacheable! unless user_signed_in?
@body_classes = 'with-modals'
set_ancestors
@ -35,7 +38,7 @@ class StatusesController < ApplicationController
end
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
ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter)
@ -63,8 +66,37 @@ class StatusesController < ApplicationController
render 'stream_entries/embed', layout: 'embedded'
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
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)
depth = starting_depth + statuses.size
if depth < DESCENDANTS_DEPTH_LIMIT
@ -174,4 +206,27 @@ class StatusesController < ApplicationController
return if @status.public_visibility? || @status.unlisted_visibility?
response.headers['Referrer-Policy'] = 'origin'
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

View File

@ -9,12 +9,14 @@ class TagsController < ApplicationController
before_action :set_instance_presenter
def show
@tag = Tag.find_by!(name: params[:id].downcase)
@tag = Tag.find_normalized!(params[:id])
respond_to do |format|
format.html do
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
@initial_state_json = serializable_resource.to_json
@initial_state_json = ActiveModelSerializers::SerializableResource.new(
InitialStatePresenter.new(settings: {}, token: current_session&.token),
serializer: InitialStateSerializer
).to_json
end
format.rss do
@ -25,8 +27,7 @@ class TagsController < ApplicationController
end
format.json do
@statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local])
.paginate_by_max_id(PAGE_SIZE, params[:max_id])
@statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local]).paginate_by_max_id(PAGE_SIZE, params[:max_id])
@statuses = cache_collection(@statuses, Status)
render json: collection_presenter,
@ -55,11 +56,4 @@ class TagsController < ApplicationController
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
)
end
def initial_state_params
{
settings: {},
token: current_session&.token,
}
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
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)
if log.target_type == 'CustomEmoji' && [:enable, :disable, :destroy].include?(log.action)
log.recorded_changes.slice('domain')
@ -111,4 +75,40 @@ module Admin::ActionLogsHelper
def opposite_verbs?(log)
%w(DomainBlock EmailDomainBlock AccountWarning).include?(log.target_type)
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

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
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
INVITE_FILTER = %i(available expired).freeze
CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).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)
new_url = filtered_url_for(link_to_params)

View File

@ -20,7 +20,23 @@ module ApplicationHelper
end
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
def open_deletion?
@ -101,4 +117,9 @@ module ApplicationHelper
def storage_host?
ENV['S3_ALIAS_HOST'].present? || ENV['S3_CLOUDFRONT_HOST'].present?
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

View File

@ -56,4 +56,22 @@ module HomeHelper
'emojify'
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

View File

@ -47,6 +47,15 @@ module JsonLdHelper
!uri.start_with?('http://', 'https://')
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)
graph = RDF::Graph.new << JSON::LD::API.toRdf(json, documentLoader: method(:load_jsonld_context))
graph.dump(:normalize)
@ -63,12 +72,19 @@ module JsonLdHelper
json.present? && json['id'] == uri ? json : nil
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|
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
end
# If request failed, retry without doing it on behalf of a user
return if on_behalf_of.nil?
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
end
end
@ -91,6 +107,14 @@ module JsonLdHelper
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)
request = Request.new(:get, uri)
request.on_behalf_of(on_behalf_of) if on_behalf_of

View File

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

View File

@ -23,7 +23,7 @@ module StreamEntriesHelper
safe_join([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('accounts.unfollow')])
end
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')])
end
end
@ -104,9 +104,19 @@ module StreamEntriesHelper
I18n.t('statuses.content_warning', warning: status.spoiler_text)
end
def poll_summary(status)
return unless status.preloadable_poll
status.preloadable_poll.options.map { |o| "[ ] #{o}" }.join("\n")
end
def status_description(status)
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")
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 {
type: ALERT_SHOW,
title,
@ -34,6 +34,11 @@ export function showAlertForError(error) {
if (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 title = `${status}`;
@ -44,6 +49,6 @@ export function showAlertForError(error) {
return showAlert(title, message);
} else {
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 { updateTimeline } from './timelines';
import { showAlertForError } from './alerts';
import { showAlert } from './alerts';
import { defineMessages } from 'react-intl';
let cancelFetchComposeSuggestionsAccounts;
@ -49,6 +51,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_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) {
return {
type: COMPOSE_CHANGE,
@ -125,6 +139,7 @@ export function submitCompose(routerHistory) {
sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null),
}, {
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
@ -143,7 +158,9 @@ export function submitCompose(routerHistory) {
// into the columns
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 }));
}
};
@ -184,20 +201,40 @@ export function submitComposeFail(error) {
export function uploadCompose(files) {
return function (dispatch, getState) {
if (getState().getIn(['compose', 'media_attachments']).size > 3) {
const uploadLimit = 4;
const media = getState().getIn(['compose', 'media_attachments']);
const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
if (files.length + media.size > uploadLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit));
return;
}
if (getState().getIn(['compose', 'poll'])) {
dispatch(showAlert(undefined, messages.uploadErrorPoll));
return;
}
dispatch(uploadComposeRequest());
resizeImage(files[0]).then(file => {
const data = new FormData();
data.append('file', file);
for (const [i, f] of Array.from(files).entries()) {
if (media.size + i > 3) break;
return api(getState).post('/api/v1/media', data, {
onUploadProgress: ({ loaded, total }) => dispatch(uploadComposeProgress(loaded, total)),
}).then(({ data }) => dispatch(uploadComposeSuccess(data)));
}).catch(error => dispatch(uploadComposeFail(error)));
resizeImage(f).then(file => {
const data = new FormData();
data.append('file', file);
// Account for disparity in size of original image and resized data
total += file.size - f.size;
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)));
};
};
};
@ -466,4 +503,46 @@ export function changeComposing(value) {
type: COMPOSE_COMPOSING_CHANGE,
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']);
}
const isLoadingRecent = !!params.since_id;
api(getState).get('/api/v1/conversations', { params })
.then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.reduce((aggr, item) => aggr.concat(item.accounts), [])));
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)));
};
@ -56,10 +58,11 @@ export const expandConversationsRequest = () => ({
type: CONVERSATIONS_FETCH_REQUEST,
});
export const expandConversationsSuccess = (conversations, next) => ({
export const expandConversationsSuccess = (conversations, next, isLoadingRecent) => ({
type: CONVERSATIONS_FETCH_SUCCESS,
conversations,
next,
isLoadingRecent,
});
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 { putAccounts, putStatuses } from '../../storage/modifier';
import { normalizeAccount, normalizeStatus } from './normalizer';
import { normalizeAccount, normalizeStatus, normalizePoll } from './normalizer';
export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
export const ACCOUNT_IMPORT = 'ACCOUNT_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 POLLS_IMPORT = 'POLLS_IMPORT';
function pushUnique(array, object) {
if (array.every(element => element.id !== object.id)) {
@ -29,6 +28,10 @@ export function importStatuses(statuses) {
return { type: STATUSES_IMPORT, statuses };
}
export function importPolls(polls) {
return { type: POLLS_IMPORT, polls };
}
export function importFetchedAccount(account) {
return importFetchedAccounts([account]);
}
@ -45,7 +48,6 @@ export function importFetchedAccounts(accounts) {
}
accounts.forEach(processAccount);
//putAccounts(normalAccounts, !autoPlayGif);
return importAccounts(normalAccounts);
}
@ -58,6 +60,7 @@ export function importFetchedStatuses(statuses) {
return (dispatch, getState) => {
const accounts = [];
const normalStatuses = [];
const polls = [];
function processStatus(status) {
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id])));
@ -66,12 +69,22 @@ export function importFetchedStatuses(statuses) {
if (status.reblog && status.reblog.id) {
processStatus(status.reblog);
}
if (status.poll && status.poll.id) {
pushUnique(polls, normalizePoll(status.poll));
}
}
statuses.forEach(processStatus);
//putStatuses(normalStatuses);
dispatch(importPolls(polls));
dispatch(importFetchedAccounts(accounts));
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;
}
if (status.poll && status.poll.id) {
normalStatus.poll = status.poll.id;
}
// Only calculate these values when status first encountered
// Otherwise keep the ones already in the reducer
if (normalOldStatus) {
@ -63,3 +67,16 @@ export function normalizeStatus(status, normalOldStatus) {
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,
importFetchedStatuses,
} from './importer';
import { saveSettings } from './settings';
import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
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 excludeTypesFromFilter = filter => {
const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention']);
const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']);
return allTypes.filterNot(item => item === filter).toJS();
};
@ -187,5 +188,6 @@ export function setFilter (filterType) {
value: filterType,
});
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: {
q: value,
resolve: true,
limit: 5,
},
}).then(response => {
if (response.data.accounts) {

View File

@ -140,7 +140,11 @@ export function redraft(status) {
export function deleteStatus(id, router, withRedraft = false) {
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));

View File

@ -3,6 +3,7 @@ import {
updateTimeline,
deleteFromTimelines,
expandHomeTimeline,
connectTimeline,
disconnectTimeline,
} from './timelines';
import { updateNotifications, expandNotifications } from './notifications';
@ -16,7 +17,12 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
return connectStream (path, pollingRefresh, (dispatch, getState) => {
const locale = getState().getIn(['meta', 'locale']);
return {
onConnect() {
dispatch(connectTimeline(timelineId));
},
onDisconnect() {
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_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
export function updateTimeline(timeline, status, accept) {
@ -95,7 +96,7 @@ export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done =
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
@ -143,6 +144,13 @@ export function scrollTopTimeline(timeline, top) {
};
};
export function connectTimeline(timeline) {
return {
type: TIMELINE_CONNECT,
timeline,
};
};
export function disconnectTimeline(timeline) {
return {
type: TIMELINE_DISCONNECT,

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import Icon from 'mastodon/components/icon';
export default class ColumnBackButton extends React.PureComponent {
@ -19,7 +20,7 @@ export default class ColumnBackButton extends React.PureComponent {
render () {
return (
<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' />
</button>
);

View File

@ -1,6 +1,7 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import ColumnBackButton from './column_back_button';
import Icon from 'mastodon/components/icon';
export default class ColumnBackButtonSlim extends ColumnBackButton {
@ -8,7 +9,7 @@ export default class ColumnBackButtonSlim extends ColumnBackButton {
return (
<div className='column-back-button--slim'>
<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' />
</div>
</div>

View File

@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import Icon from 'mastodon/components/icon';
const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
@ -109,22 +110,22 @@ class ColumnHeader extends React.PureComponent {
}
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 = (
<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.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.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}><Icon id='chevron-right' /></button>
</div>
);
} 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)) {
backButton = (
<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' />
</button>
);
@ -140,7 +141,7 @@ class ColumnHeader extends React.PureComponent {
}
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;
@ -150,7 +151,7 @@ class ColumnHeader extends React.PureComponent {
<h1 className={buttonClassName}>
{hasTitle && (
<button onClick={this.handleTitleClick}>
<i className={`fa fa-fw fa-${icon} column-header__icon`} />
<Icon id={icon} fixedWidth className='column-header__icon' />
{title}
</button>
)}

View File

@ -22,7 +22,7 @@ export default class DisplayName extends React.PureComponent {
suffix = `+${others.size - 2}`;
}
} else {
if (others) {
if (others && others.size > 0) {
account = others.first();
} else {
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 PropTypes from 'prop-types';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
export default class IconButton extends React.PureComponent {
@ -85,8 +86,9 @@ export default class IconButton extends React.PureComponent {
onClick={this.handleClick}
style={style}
tabIndex={tabIndex}
disabled={disabled}
>
<i className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
<Icon id={icon} fixedWidth aria-hidden='true' />
</button>
);
}
@ -103,8 +105,9 @@ export default class IconButton extends React.PureComponent {
onClick={this.handleClick}
style={style}
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>
)}
</Motion>

View File

@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import Icon from 'mastodon/components/icon';
const messages = defineMessages({
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
@ -25,7 +26,7 @@ class LoadGap extends React.PureComponent {
return (
<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>
);
}

View File

@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../is_mobile';
import classNames from 'classnames';
import { autoPlayGif, displayMedia } from '../initial_state';
import { decode } from 'blurhash';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
@ -21,6 +22,7 @@ class Item extends React.PureComponent {
size: PropTypes.number.isRequired,
onClick: PropTypes.func.isRequired,
displayWidth: PropTypes.number,
visible: PropTypes.bool.isRequired,
};
static defaultProps = {
@ -29,6 +31,10 @@ class Item extends React.PureComponent {
size: 1,
};
state = {
loaded: false,
};
handleMouseEnter = (e) => {
if (this.hoverToPlay()) {
e.target.play();
@ -62,8 +68,40 @@ class Item extends React.PureComponent {
e.stopPropagation();
}
componentDidMount () {
if (this.props.attachment.get('blurhash')) {
this._decode();
}
}
componentDidUpdate (prevProps) {
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
this._decode();
}
}
_decode () {
const hash = this.props.attachment.get('blurhash');
const pixels = decode(hash, 32, 32);
if (pixels) {
const ctx = this.canvas.getContext('2d');
const imageData = new ImageData(pixels, 32, 32);
ctx.putImageData(imageData, 0, 0);
}
}
setCanvasRef = c => {
this.canvas = c;
}
handleImageLoad = () => {
this.setState({ loaded: true });
}
render () {
const { attachment, index, size, standalone, displayWidth } = this.props;
const { attachment, index, size, standalone, displayWidth, visible } = this.props;
let width = 50;
let height = 100;
@ -116,12 +154,20 @@ class Item extends React.PureComponent {
let thumbnail = '';
if (attachment.get('type') === 'image') {
if (attachment.get('type') === 'unknown') {
return (
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
</a>
</div>
);
} else if (attachment.get('type') === 'image') {
const previewUrl = attachment.get('preview_url');
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
@ -147,6 +193,7 @@ class Item extends React.PureComponent {
alt={attachment.get('description')}
title={attachment.get('description')}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={this.handleImageLoad}
/>
</a>
);
@ -176,7 +223,8 @@ class Item extends React.PureComponent {
return (
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
{thumbnail}
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
{visible && thumbnail}
</div>
);
}
@ -242,7 +290,7 @@ class MediaGallery extends React.PureComponent {
const width = this.state.width || defaultWidth;
let children;
let children, spoilerButton;
const style = {};
@ -256,35 +304,28 @@ class MediaGallery extends React.PureComponent {
style.height = height;
}
if (!visible) {
let warning;
const size = media.take(4).size;
if (sensitive) {
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else {
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
}
if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
} else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />);
}
children = (
<button type='button' className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
<span className='media-spoiler__warning'>{warning}</span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
if (visible) {
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
} else {
spoilerButton = (
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
</button>
);
} else {
const size = media.take(4).size;
if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} />;
} else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} />);
}
}
return (
<div className='media-gallery' style={style} ref={this.handleRef}>
<div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
{spoilerButton}
</div>
{children}

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' },
hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
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 = {
@ -86,6 +91,26 @@ export const timeAgoString = (intl, date, now, year) => {
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
class RelativeTimestamp extends React.Component {
@ -93,6 +118,7 @@ class RelativeTimestamp extends React.Component {
intl: PropTypes.object.isRequired,
timestamp: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
futureDate: PropTypes.bool,
};
state = {
@ -145,10 +171,10 @@ class RelativeTimestamp extends React.Component {
}
render () {
const { timestamp, intl, year } = this.props;
const { timestamp, intl, year, futureDate } = this.props;
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 (
<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 { HotKeys } from 'react-hotkeys';
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
// to use the progress bar to show download progress
@ -249,7 +251,7 @@ class Status extends ImmutablePureComponent {
if (featured) {
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-thumb-tack status__prepend-icon' /></div>
<div className='status__prepend-icon-wrapper'><Icon id='thumb-tack' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.pinned' defaultMessage='Pinned toot' />
</div>
);
@ -258,7 +260,7 @@ class Status extends ImmutablePureComponent {
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> }} />
</div>
);
@ -269,8 +271,10 @@ class Status extends ImmutablePureComponent {
status = status.get('reblog');
}
if (status.get('media_attachments').size > 0) {
if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
if (status.get('poll')) {
media = <PollContainer pollId={status.get('poll')} />;
} else if (status.get('media_attachments').size > 0) {
if (this.props.muted) {
media = (
<AttachmentList
compact
@ -285,6 +289,7 @@ class Status extends ImmutablePureComponent {
{Component => (
<Component
preview={video.get('preview_url')}
blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
width={this.props.cachedMediaWidth}
@ -325,7 +330,7 @@ class Status extends ImmutablePureComponent {
);
}
if (otherAccounts) {
if (otherAccounts && otherAccounts.size > 0) {
statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} />;
} else if (account === undefined || account === null) {
statusAvatar = <Avatar account={status.get('account')} size={48} />;
@ -347,7 +352,7 @@ class Status extends ImmutablePureComponent {
return (
<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}
<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')}>

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