Browse Source

Merge 2.7.3 (#8)

kɜ:ʳ cybredragon 5 months ago
parent
commit
2a779205b7
86 changed files with 1487 additions and 564 deletions
  1. 40
    30
      AUTHORS.md
  2. 58
    0
      CHANGELOG.md
  3. 1
    1
      README.md
  4. 2
    2
      app/chewy/statuses_index.rb
  5. 3
    0
      app/controllers/admin/custom_emojis_controller.rb
  6. 1
    1
      app/controllers/admin/instances_controller.rb
  7. 4
    0
      app/controllers/admin/reported_statuses_controller.rb
  8. 1
    1
      app/controllers/api/v1/apps/credentials_controller.rb
  9. 1
    0
      app/controllers/auth/registrations_controller.rb
  10. 5
    0
      app/controllers/oauth/authorized_applications_controller.rb
  11. 1
    1
      app/helpers/admin/filter_helper.rb
  12. 1
    1
      app/helpers/stream_entries_helper.rb
  13. 1
    1
      app/javascript/mastodon/components/account.js
  14. 16
    6
      app/javascript/mastodon/components/display_name.js
  15. 1
    1
      app/javascript/mastodon/components/domain.js
  16. 1
    1
      app/javascript/mastodon/components/intersection_observer_article.js
  17. 8
    2
      app/javascript/mastodon/components/media_gallery.js
  18. 27
    1
      app/javascript/mastodon/components/scrollable_list.js
  19. 63
    6
      app/javascript/mastodon/components/status.js
  20. 23
    7
      app/javascript/mastodon/components/status_action_bar.js
  21. 3
    0
      app/javascript/mastodon/containers/compose_container.js
  22. 1
    1
      app/javascript/mastodon/features/account/components/header.js
  23. 4
    1
      app/javascript/mastodon/features/blocks/index.js
  24. 1
    1
      app/javascript/mastodon/features/compose/components/compose_form.js
  25. 1
    1
      app/javascript/mastodon/features/compose/components/privacy_dropdown.js
  26. 1
    2
      app/javascript/mastodon/features/compose/components/upload.js
  27. 4
    1
      app/javascript/mastodon/features/domain_blocks/index.js
  28. 4
    1
      app/javascript/mastodon/features/follow_requests/index.js
  29. 33
    22
      app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js
  30. 12
    5
      app/javascript/mastodon/features/hashtag_timeline/index.js
  31. 4
    1
      app/javascript/mastodon/features/mutes/index.js
  32. 30
    2
      app/javascript/mastodon/features/notifications/components/notification.js
  33. 4
    1
      app/javascript/mastodon/features/status/components/card.js
  34. 1
    1
      app/javascript/mastodon/features/status/components/detailed_status.js
  35. 3
    2
      app/javascript/mastodon/features/video/index.js
  36. 13
    0
      app/javascript/packs/error.js
  37. 55
    0
      app/javascript/styles/contrast/diff.scss
  38. 31
    0
      app/javascript/styles/mastodon/_mixins.scss
  39. 3
    8
      app/javascript/styles/mastodon/about.scss
  40. 8
    6
      app/javascript/styles/mastodon/basics.scss
  41. 66
    33
      app/javascript/styles/mastodon/components.scss
  42. 2
    4
      app/lib/activity_tracker.rb
  43. 49
    2
      app/lib/activitypub/activity.rb
  44. 12
    3
      app/lib/activitypub/activity/announce.rb
  45. 20
    17
      app/lib/activitypub/activity/create.rb
  46. 3
    6
      app/lib/feed_manager.rb
  47. 48
    1
      app/lib/formatter.rb
  48. 2
    4
      app/lib/ostatus/activity/base.rb
  49. 2
    6
      app/lib/potential_friendship_tracker.rb
  50. 2
    1
      app/models/account_conversation.rb
  51. 11
    0
      app/models/concerns/redisable.rb
  52. 2
    0
      app/models/domain_block.rb
  53. 2
    4
      app/models/feed.rb
  54. 6
    2
      app/models/instance_filter.rb
  55. 2
    0
      app/models/relay.rb
  56. 2
    4
      app/models/trending_tags.rb
  57. 6
    2
      app/serializers/activitypub/activity_serializer.rb
  58. 5
    1
      app/serializers/rest/application_serializer.rb
  59. 5
    1
      app/serializers/rest/instance_serializer.rb
  60. 1
    1
      app/services/activitypub/process_account_service.rb
  61. 1
    0
      app/services/activitypub/process_collection_service.rb
  62. 1
    4
      app/services/batched_remove_status_service.rb
  63. 2
    4
      app/services/follow_service.rb
  64. 2
    4
      app/services/post_status_service.rb
  65. 8
    11
      app/services/remove_status_service.rb
  66. 9
    1
      app/services/suspend_account_service.rb
  67. 1
    0
      app/validators/email_mx_validator.rb
  68. 3
    2
      app/views/admin/accounts/index.html.haml
  69. 6
    0
      app/views/admin/accounts/show.html.haml
  70. 1
    1
      app/views/admin/change_emails/show.html.haml
  71. 14
    0
      app/views/admin/instances/index.html.haml
  72. 5
    2
      app/views/layouts/error.html.haml
  73. 5
    0
      app/workers/activitypub/low_priority_delivery_worker.rb
  74. 1
    1
      app/workers/activitypub/processing_worker.rb
  75. 1
    4
      app/workers/scheduler/feed_cleanup_scheduler.rb
  76. 9
    2
      config/initializers/rack_attack.rb
  77. 2
    2
      config/initializers/twitter_regex.rb
  78. 3
    2
      config/locales/en.yml
  79. 1
    1
      dist/mastodon-streaming.service
  80. 3
    3
      lib/mastodon/version.rb
  81. BIN
      public/oops.png
  82. 3
    4
      public/robots.txt
  83. 150
    8
      spec/lib/activitypub/activity/announce_spec.rb
  84. 417
    293
      spec/lib/activitypub/activity/create_spec.rb
  85. 79
    5
      spec/lib/formatter_spec.rb
  86. 38
    0
      spec/validators/email_mx_validator_spec.rb

+ 40
- 30
AUTHORS.md View File

@@ -9,18 +9,18 @@ and provided thanks to the work of the following contributors:
9 9
 * [akihikodaki](https://github.com/akihikodaki)
10 10
 * [ThibG](https://github.com/ThibG)
11 11
 * [mjankowski](https://github.com/mjankowski)
12
+* [dependabot[bot]](https://github.com/apps/dependabot)
12 13
 * [unarist](https://github.com/unarist)
13 14
 * [m4sk1n](https://github.com/m4sk1n)
14
-* [dependabot[bot]](https://github.com/apps/dependabot)
15 15
 * [yiskah](https://github.com/yiskah)
16 16
 * [nolanlawson](https://github.com/nolanlawson)
17
-* [sorin-davidoi](https://github.com/sorin-davidoi)
18 17
 * [ysksn](https://github.com/ysksn)
18
+* [sorin-davidoi](https://github.com/sorin-davidoi)
19 19
 * [abcang](https://github.com/abcang)
20 20
 * [lynlynlynx](https://github.com/lynlynlynx)
21
-* [alpaca-tc](https://github.com/alpaca-tc)
22 21
 * [mayaeh](https://github.com/mayaeh)
23 22
 * [renatolond](https://github.com/renatolond)
23
+* [alpaca-tc](https://github.com/alpaca-tc)
24 24
 * [nclm](https://github.com/nclm)
25 25
 * [ineffyble](https://github.com/ineffyble)
26 26
 * [jeroenpraat](https://github.com/jeroenpraat)
@@ -28,9 +28,9 @@ and provided thanks to the work of the following contributors:
28 28
 * [Quent-in](https://github.com/Quent-in)
29 29
 * [JantsoP](https://github.com/JantsoP)
30 30
 * [mabkenar](https://github.com/mabkenar)
31
+* [Kjwon15](https://github.com/Kjwon15)
31 32
 * [nullkal](https://github.com/nullkal)
32 33
 * [yookoala](https://github.com/yookoala)
33
-* [Kjwon15](https://github.com/Kjwon15)
34 34
 * [shuheiktgw](https://github.com/shuheiktgw)
35 35
 * [ashfurrow](https://github.com/ashfurrow)
36 36
 * [Quenty31](https://github.com/Quenty31)
@@ -48,16 +48,16 @@ and provided thanks to the work of the following contributors:
48 48
 * [rkarabut](https://github.com/rkarabut)
49 49
 * [yukimochi](https://github.com/yukimochi)
50 50
 * [Artoria2e5](https://github.com/Artoria2e5)
51
+* [nightpool](https://github.com/nightpool)
51 52
 * [marrus-sh](https://github.com/marrus-sh)
52 53
 * [krainboltgreene](https://github.com/krainboltgreene)
53
-* [patf](https://github.com/patf)
54
+* [pfigel](https://github.com/pfigel)
54 55
 * [Aldarone](https://github.com/Aldarone)
55 56
 * [BoFFire](https://github.com/BoFFire)
56 57
 * [clworld](https://github.com/clworld)
57 58
 * [dracos](https://github.com/dracos)
58 59
 * [SerCom_KC](mailto:sercom-kc@users.noreply.github.com)
59 60
 * [Sylvhem](https://github.com/Sylvhem)
60
-* [nightpool](https://github.com/nightpool)
61 61
 * [MasterGroosha](https://github.com/MasterGroosha)
62 62
 * [JeanGauthier](https://github.com/JeanGauthier)
63 63
 * [kschaper](https://github.com/kschaper)
@@ -77,11 +77,14 @@ and provided thanks to the work of the following contributors:
77 77
 * [johnsudaar](https://github.com/johnsudaar)
78 78
 * [trebmuh](https://github.com/trebmuh)
79 79
 * [Rakib Hasan](mailto:rmhasan@gmail.com)
80
+* [ashleyhull-versent](https://github.com/ashleyhull-versent)
80 81
 * [lindwurm](https://github.com/lindwurm)
81 82
 * [victorhck](mailto:victorhck@geeko.site)
82 83
 * [voidsatisfaction](https://github.com/voidsatisfaction)
84
+* [rinsuki](https://github.com/rinsuki)
83 85
 * [hikari-no-yume](https://github.com/hikari-no-yume)
84 86
 * [angristan](https://github.com/angristan)
87
+* [hinaloe](https://github.com/hinaloe)
85 88
 * [seefood](https://github.com/seefood)
86 89
 * [jackjennings](https://github.com/jackjennings)
87 90
 * [spla](mailto:spla@mastodont.cat)
@@ -92,20 +95,20 @@ and provided thanks to the work of the following contributors:
92 95
 * [dunn](https://github.com/dunn)
93 96
 * [xqus](https://github.com/xqus)
94 97
 * [hugogameiro](https://github.com/hugogameiro)
98
+* [ariasuni](https://github.com/ariasuni)
95 99
 * [pfm-eyesightjp](https://github.com/pfm-eyesightjp)
96 100
 * [fakenine](https://github.com/fakenine)
97 101
 * [tsuwatch](https://github.com/tsuwatch)
98 102
 * [victorhck](https://github.com/victorhck)
99
-* [ashleyhull-versent](https://github.com/ashleyhull-versent)
100 103
 * [kedamaDQ](https://github.com/kedamaDQ)
101 104
 * [puckipedia](https://github.com/puckipedia)
102 105
 * [fvh-P](https://github.com/fvh-P)
103 106
 * [contraexemplo](https://github.com/contraexemplo)
107
+* [Aditoo17](https://github.com/Aditoo17)
104 108
 * [kazu9su](https://github.com/kazu9su)
105 109
 * [Komic](https://github.com/Komic)
106 110
 * [lmorchard](https://github.com/lmorchard)
107 111
 * [diomed](https://github.com/diomed)
108
-* [ariasuni](https://github.com/ariasuni)
109 112
 * [Neetshin](mailto:neetshin@neetsh.in)
110 113
 * [rainyday](https://github.com/rainyday)
111 114
 * [ProgVal](https://github.com/ProgVal)
@@ -114,7 +117,8 @@ and provided thanks to the work of the following contributors:
114 117
 * [goofy-bz](mailto:goofy@babelzilla.org)
115 118
 * [kadiix](https://github.com/kadiix)
116 119
 * [kodacs](https://github.com/kodacs)
117
-* [rtucker](https://github.com/rtucker)
120
+* [trwnh](https://github.com/trwnh)
121
+* [JMendyk](https://github.com/JMendyk)
118 122
 * [KScl](https://github.com/KScl)
119 123
 * [sterdev](https://github.com/sterdev)
120 124
 * [TheKinrar](https://github.com/TheKinrar)
@@ -125,16 +129,16 @@ and provided thanks to the work of the following contributors:
125 129
 * [fhemberger](https://github.com/fhemberger)
126 130
 * [greysteil](https://github.com/greysteil)
127 131
 * [hensmith](https://github.com/hensmith)
128
-* [hinaloe](https://github.com/hinaloe)
129 132
 * [d6rkaiz](https://github.com/d6rkaiz)
130 133
 * [Reverite](https://github.com/Reverite)
131
-* [JMendyk](https://github.com/JMendyk)
132 134
 * [JohnD28](https://github.com/JohnD28)
133 135
 * [znz](https://github.com/znz)
134 136
 * [Naouak](https://github.com/Naouak)
135 137
 * [pawelngei](https://github.com/pawelngei)
138
+* [rtucker](https://github.com/rtucker)
136 139
 * [reneklacan](https://github.com/reneklacan)
137 140
 * [ekiru](https://github.com/ekiru)
141
+* [noellabo](https://github.com/noellabo)
138 142
 * [tcitworld](https://github.com/tcitworld)
139 143
 * [geta6](https://github.com/geta6)
140 144
 * [happycoloredbanana](https://github.com/happycoloredbanana)
@@ -144,9 +148,9 @@ and provided thanks to the work of the following contributors:
144 148
 * [noraworld](https://github.com/noraworld)
145 149
 * [theboss](https://github.com/theboss)
146 150
 * [178inaba](https://github.com/178inaba)
147
-* [Aditoo17](https://github.com/Aditoo17)
148 151
 * [alyssais](https://github.com/alyssais)
149
-* [kodnaplakal](https://github.com/kodnaplakal)
152
+* [hiphref](https://github.com/hiphref)
153
+* [BenLubar](https://github.com/BenLubar)
150 154
 * [stalker314314](https://github.com/stalker314314)
151 155
 * [huertanix](https://github.com/huertanix)
152 156
 * [genesixx](https://github.com/genesixx)
@@ -157,6 +161,7 @@ and provided thanks to the work of the following contributors:
157 161
 * [kmichl](https://github.com/kmichl)
158 162
 * [Kurtis Rainbolt-Greene](mailto:me@kurtisrainboltgreene.name)
159 163
 * [saper](https://github.com/saper)
164
+* [marek-lach](https://github.com/marek-lach)
160 165
 * [nevillepark](https://github.com/nevillepark)
161 166
 * [ornithocoder](https://github.com/ornithocoder)
162 167
 * [pierreozoux](https://github.com/pierreozoux)
@@ -164,7 +169,6 @@ and provided thanks to the work of the following contributors:
164 169
 * [Ram Lmn](mailto:ramlmn@users.noreply.github.com)
165 170
 * [harukasan](https://github.com/harukasan)
166 171
 * [stamak](https://github.com/stamak)
167
-* [noellabo](https://github.com/noellabo)
168 172
 * [Technowix](mailto:technowix@users.noreply.github.com)
169 173
 * [Eychics](https://github.com/Eychics)
170 174
 * [Thor Harald Johansen](mailto:thj@thj.no)
@@ -179,21 +183,20 @@ and provided thanks to the work of the following contributors:
179 183
 * [hoodie](mailto:hoodiekitten@outlook.com)
180 184
 * [luzi82](https://github.com/luzi82)
181 185
 * [duxovni](https://github.com/duxovni)
182
-* [trwnh](https://github.com/trwnh)
186
+* [tmm576](https://github.com/tmm576)
183 187
 * [unsmell](https://github.com/unsmell)
184 188
 * [valerauko](https://github.com/valerauko)
185 189
 * [chriswmartin](https://github.com/chriswmartin)
186 190
 * [vahnj](https://github.com/vahnj)
187 191
 * [ikuradon](https://github.com/ikuradon)
188 192
 * [AndreLewin](https://github.com/AndreLewin)
189
-* [rinsuki](https://github.com/rinsuki)
190 193
 * [0xflotus](https://github.com/0xflotus)
191 194
 * [redtachyons](https://github.com/redtachyons)
192 195
 * [thurloat](https://github.com/thurloat)
193 196
 * [aaribaud](https://github.com/aaribaud)
197
+* [pointlessone](https://github.com/pointlessone)
194 198
 * [Andrew](mailto:andrewlchronister@gmail.com)
195 199
 * [estuans](https://github.com/estuans)
196
-* [BenLubar](https://github.com/BenLubar)
197 200
 * [dissolve](https://github.com/dissolve)
198 201
 * [PurpleBooth](https://github.com/PurpleBooth)
199 202
 * [bradurani](https://github.com/bradurani)
@@ -216,6 +219,7 @@ and provided thanks to the work of the following contributors:
216 219
 * [ErikXXon](https://github.com/ErikXXon)
217 220
 * [ian-kelling](https://github.com/ian-kelling)
218 221
 * [immae](https://github.com/immae)
222
+* [J0WI](https://github.com/J0WI)
219 223
 * [foozmeat](https://github.com/foozmeat)
220 224
 * [jasonrhodes](https://github.com/jasonrhodes)
221 225
 * [Jason Snell](mailto:jason@newrelic.com)
@@ -230,6 +234,7 @@ and provided thanks to the work of the following contributors:
230 234
 * [Lorenz Diener](mailto:halcyon@icosahedron.website)
231 235
 * [alimony](https://github.com/alimony)
232 236
 * [mig5](https://github.com/mig5)
237
+* [moritzheiber](https://github.com/moritzheiber)
233 238
 * [ndarville](https://github.com/ndarville)
234 239
 * [Abzol](https://github.com/Abzol)
235 240
 * [pwoolcoc](https://github.com/pwoolcoc)
@@ -238,6 +243,7 @@ and provided thanks to the work of the following contributors:
238 243
 * [ignisf](https://github.com/ignisf)
239 244
 * [raymestalez](https://github.com/raymestalez)
240 245
 * [remram44](https://github.com/remram44)
246
+* [sts10](https://github.com/sts10)
241 247
 * [sascha-sl](https://github.com/sascha-sl)
242 248
 * [u1-liquid](https://github.com/u1-liquid)
243 249
 * [sim6](https://github.com/sim6)
@@ -288,6 +294,7 @@ and provided thanks to the work of the following contributors:
288 294
 * [857b](https://github.com/857b)
289 295
 * [insom](https://github.com/insom)
290 296
 * [tachyons](https://github.com/tachyons)
297
+* [acid-chicken](https://github.com/acid-chicken)
291 298
 * [Esteth](https://github.com/Esteth)
292 299
 * [unascribed](https://github.com/unascribed)
293 300
 * [Aguay-val](https://github.com/Aguay-val)
@@ -297,7 +304,6 @@ and provided thanks to the work of the following contributors:
297 304
 * [unleashed](https://github.com/unleashed)
298 305
 * [alxrcs](https://github.com/alxrcs)
299 306
 * [console-cowboy](https://github.com/console-cowboy)
300
-* [pointlessone](https://github.com/pointlessone)
301 307
 * [Alkarex](https://github.com/Alkarex)
302 308
 * [a2](https://github.com/a2)
303 309
 * [0xa](https://github.com/0xa)
@@ -329,6 +335,7 @@ and provided thanks to the work of the following contributors:
329 335
 * [Motoma](https://github.com/Motoma)
330 336
 * [chriswk](https://github.com/chriswk)
331 337
 * [csu](https://github.com/csu)
338
+* [clarcharr](https://github.com/clarcharr)
332 339
 * [kklleemm](https://github.com/kklleemm)
333 340
 * [colindean](https://github.com/colindean)
334 341
 * [dachinat](https://github.com/dachinat)
@@ -356,6 +363,7 @@ and provided thanks to the work of the following contributors:
356 363
 * [espenronnevik](https://github.com/espenronnevik)
357 364
 * [Finariel](https://github.com/Finariel)
358 365
 * [siuying](https://github.com/siuying)
366
+* [zoc](https://github.com/zoc)
359 367
 * [fwenzel](https://github.com/fwenzel)
360 368
 * [GenbuHase](https://github.com/GenbuHase)
361 369
 * [hattori6789](https://github.com/hattori6789)
@@ -416,6 +424,7 @@ and provided thanks to the work of the following contributors:
416 424
 * [martymcguire](https://github.com/martymcguire)
417 425
 * [marvinkopf](https://github.com/marvinkopf)
418 426
 * [otsune](https://github.com/otsune)
427
+* [mbugowski](https://github.com/mbugowski)
419 428
 * [Mathias B](mailto:10813340+mathias-b@users.noreply.github.com)
420 429
 * [matt-auckland](https://github.com/matt-auckland)
421 430
 * [webroo](https://github.com/webroo)
@@ -434,7 +443,6 @@ and provided thanks to the work of the following contributors:
434 443
 * [premist](https://github.com/premist)
435 444
 * [Mnkai](https://github.com/Mnkai)
436 445
 * [mitchhentges](https://github.com/mitchhentges)
437
-* [moritzheiber](https://github.com/moritzheiber)
438 446
 * [mouse-reeve](https://github.com/mouse-reeve)
439 447
 * [Mozinet-fr](https://github.com/Mozinet-fr)
440 448
 * [lae](https://github.com/lae)
@@ -458,17 +466,17 @@ and provided thanks to the work of the following contributors:
458 466
 * [Pangoraw](https://github.com/Pangoraw)
459 467
 * [peterkeen](https://github.com/peterkeen)
460 468
 * [pgate](https://github.com/pgate)
461
-* [retokromer](https://github.com/retokromer)
462
-* [rfwatson](https://github.com/rfwatson)
463
-* [rfreebern](https://github.com/rfreebern)
469
+* [Reto Kromer](mailto:retokromer@users.noreply.github.com)
470
+* [Rey Tucker](mailto:git@reytucker.us)
471
+* [Rob Watson](mailto:rfwatson@users.noreply.github.com)
472
+* [Ryan Freebern](mailto:ryan@freebern.org)
464 473
 * [Ryan Wade](mailto:ryan.wade@protonmail.com)
465
-* [sylph01](https://github.com/sylph01)
466
-* [S-H-GAMELINKS](https://github.com/S-H-GAMELINKS)
467
-* [staticsafe](https://github.com/staticsafe)
468
-* [snwh](https://github.com/snwh)
469
-* [sts10](https://github.com/sts10)
470
-* [skoji](https://github.com/skoji)
471
-* [ScienJus](https://github.com/ScienJus)
474
+* [Ryo Kajiwara](mailto:kfe-fecn6.prussian@s01.info)
475
+* [S.H](mailto:gamelinks007@gmail.com)
476
+* [Sadiq Saif](mailto:staticsafe@users.noreply.github.com)
477
+* [Sam Hewitt](mailto:hewittsamuel@gmail.com)
478
+* [Satoshi KOJIMA](mailto:skoji@mac.com)
479
+* [ScienJus](mailto:i@scienjus.com)
472 480
 * [Scott Larkin](mailto:scott@codeclimate.com)
473 481
 * [Sebastian Hübner](mailto:imolein@users.noreply.github.com)
474 482
 * [Sebastian Morr](mailto:sebastian@morr.cc)
@@ -483,6 +491,7 @@ and provided thanks to the work of the following contributors:
483 491
 * [Sir-Boops](mailto:admin@boops.me)
484 492
 * [Soshi Kato](mailto:mail@sossii.com)
485 493
 * [Spanky](mailto:2788886+spankyworks@users.noreply.github.com)
494
+* [Stanislas](mailto:angristan@pm.me)
486 495
 * [StefOfficiel](mailto:pichard.stephane@free.fr)
487 496
 * [Steven Tappert](mailto:admin@dark-it.net)
488 497
 * [Svetlozar Todorov](mailto:svetlik@users.noreply.github.com)
@@ -532,6 +541,7 @@ and provided thanks to the work of the following contributors:
532 541
 * [fsubal](mailto:fsubal@users.noreply.github.com)
533 542
 * [fusshi-](mailto:dikky1218@users.noreply.github.com)
534 543
 * [gentaro](mailto:gentaroooo@gmail.com)
544
+* [gol-cha](mailto:info@mevo.xyz)
535 545
 * [hakoai](mailto:hk--76@qa2.so-net.ne.jp)
536 546
 * [haosbvnker](mailto:github@chaosbunker.com)
537 547
 * [isati](mailto:phil@juchnowi.cz)
@@ -549,12 +559,12 @@ and provided thanks to the work of the following contributors:
549 559
 * [luzpaz](mailto:luzpaz@users.noreply.github.com)
550 560
 * [maxypy](mailto:maxime@mpigou.fr)
551 561
 * [mhe](mailto:mail@marcus-herrmann.com)
562
+* [mike castleman](mailto:m@mlcastle.net)
552 563
 * [mimikun](mailto:dzdzble_effort_311@outlook.jp)
553 564
 * [mshrtkch](mailto:mshrtkch@users.noreply.github.com)
554 565
 * [muan](mailto:muan@github.com)
555 566
 * [namelessGonbai](mailto:43787036+namelessgonbai@users.noreply.github.com)
556 567
 * [neetshin](mailto:neetshin@neetsh.in)
557
-* [nightpool](mailto:nightpool@users.noreply.github.com)
558 568
 * [rch850](mailto:rich850@gmail.com)
559 569
 * [roikale](mailto:roikale@users.noreply.github.com)
560 570
 * [rysiekpl](mailto:rysiek@hackerspace.pl)

+ 58
- 0
CHANGELOG.md View File

@@ -3,6 +3,64 @@ Changelog
3 3
 
4 4
 All notable changes to this project will be documented in this file.
5 5
 
6
+## [2.7.3] - 2019-02-23
7
+### Added
8
+
9
+- Add domain filter to the admin federation page ([ThibG](https://github.com/tootsuite/mastodon/pull/10071))
10
+- Add quick link from admin account view to block/unblock instance ([ThibG](https://github.com/tootsuite/mastodon/pull/10073))
11
+
12
+### Fixed
13
+
14
+- Fix video player width not being updated to fit container width ([ThibG](https://github.com/tootsuite/mastodon/pull/10069))
15
+- Fix domain filter being shown in admin page when local filter is active ([ThibG](https://github.com/tootsuite/mastodon/pull/10074))
16
+- Fix crash when conversations have no valid participants ([ThibG](https://github.com/tootsuite/mastodon/pull/10078))
17
+- Fix error when performing admin actions on no statuses ([ThibG](https://github.com/tootsuite/mastodon/pull/10094))
18
+
19
+### Changed
20
+
21
+- Change custom emojis to randomize stored file name ([hinaloe](https://github.com/tootsuite/mastodon/pull/10090))
22
+
23
+## [2.7.2] - 2019-02-17
24
+### Added
25
+
26
+- Add support for IPv6 in e-mail validation ([zoc](https://github.com/tootsuite/mastodon/pull/10009))
27
+- Add record of IP address used for signing up ([ThibG](https://github.com/tootsuite/mastodon/pull/10026))
28
+- Add tight rate-limit for API deletions (30 per 30 minutes) ([Gargron](https://github.com/tootsuite/mastodon/pull/10042))
29
+- Add support for embedded `Announce` objects attributed to the same actor ([ThibG](https://github.com/tootsuite/mastodon/pull/9998), [Gargron](https://github.com/tootsuite/mastodon/pull/10065))
30
+- Add spam filter for `Create` and `Announce` activities ([Gargron](https://github.com/tootsuite/mastodon/pull/10005), [Gargron](https://github.com/tootsuite/mastodon/pull/10041), [Gargron](https://github.com/tootsuite/mastodon/pull/10062))
31
+- Add `registrations` attribute to `GET /api/v1/instance` ([Gargron](https://github.com/tootsuite/mastodon/pull/10060))
32
+- Add `vapid_key` to `POST /api/v1/apps` and `GET /api/v1/apps/verify_credentials` ([Gargron](https://github.com/tootsuite/mastodon/pull/10058))
33
+
34
+### Fixed
35
+
36
+- Fix link color and add link underlines in high-contrast theme ([Gargron](https://github.com/tootsuite/mastodon/pull/9949), [Gargron](https://github.com/tootsuite/mastodon/pull/10028))
37
+- Fix unicode characters in URLs not being linkified ([JMendyk](https://github.com/tootsuite/mastodon/pull/8447), [hinaloe](https://github.com/tootsuite/mastodon/pull/9991))
38
+- Fix URLs linkifier grabbing ending quotation as part of the link ([Gargron](https://github.com/tootsuite/mastodon/pull/9997))
39
+- Fix authorized applications page design ([rinsuki](https://github.com/tootsuite/mastodon/pull/9969))
40
+- Fix custom emojis not showing up in share page emoji picker ([rinsuki](https://github.com/tootsuite/mastodon/pull/9970))
41
+- Fix too liberal application of whitespace in toots ([trwnh](https://github.com/tootsuite/mastodon/pull/9968))
42
+- Fix misleading e-mail hint being displayed in admin view ([ThibG](https://github.com/tootsuite/mastodon/pull/9973))
43
+- Fix tombstones not being cleared out ([abcang](https://github.com/tootsuite/mastodon/pull/9978))
44
+- Fix some timeline jumps ([ThibG](https://github.com/tootsuite/mastodon/pull/9982), [ThibG](https://github.com/tootsuite/mastodon/pull/10001), [rinsuki](https://github.com/tootsuite/mastodon/pull/10046))
45
+- Fix content warning input taking keyboard focus even when hidden ([hinaloe](https://github.com/tootsuite/mastodon/pull/10017))
46
+- Fix hashtags select styling in default and high-contrast themes ([Gargron](https://github.com/tootsuite/mastodon/pull/10029))
47
+- Fix style regressions on landing page ([Gargron](https://github.com/tootsuite/mastodon/pull/10030))
48
+- Fix hashtag column not subscribing to stream on mount ([Gargron](https://github.com/tootsuite/mastodon/pull/10040))
49
+- Fix relay enabling/disabling not resetting inbox availability status ([Gargron](https://github.com/tootsuite/mastodon/pull/10048))
50
+- Fix mutes, blocks, domain blocks and follow requests not paginating ([Gargron](https://github.com/tootsuite/mastodon/pull/10057))
51
+- Fix crash on public hashtag pages when streaming fails ([ThibG](https://github.com/tootsuite/mastodon/pull/10061))
52
+
53
+### Changed
54
+
55
+- Change icon for unlisted visibility level ([clarcharr](https://github.com/tootsuite/mastodon/pull/9952))
56
+- Change queue of actor deletes from push to pull for non-follower recipients ([ThibG](https://github.com/tootsuite/mastodon/pull/10016))
57
+- Change robots.txt to exclude media proxy URLs ([nightpool](https://github.com/tootsuite/mastodon/pull/10038))
58
+- Change upload description input to allow line breaks ([BenLubar](https://github.com/tootsuite/mastodon/pull/10036))
59
+- Change `dist/mastodon-streaming.service` to recommend running node without intermediary npm command ([nolanlawson](https://github.com/tootsuite/mastodon/pull/10032))
60
+- Change conversations to always show names of other participants ([Gargron](https://github.com/tootsuite/mastodon/pull/10047))
61
+- Change buttons on timeline preview to open the interaction dialog ([Gargron](https://github.com/tootsuite/mastodon/pull/10054))
62
+- Change error graphic to hover-to-play ([Gargron](https://github.com/tootsuite/mastodon/pull/10055))
63
+
6 64
 ## [2.7.1] - 2019-01-28
7 65
 ### Fixed
8 66
 

+ 1
- 1
README.md View File

@@ -86,7 +86,7 @@ You can open issues for bugs you've found or features you think are missing. You
86 86
 
87 87
 ## License
88 88
 
89
-Copyright (C) 2016-2018 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md))
89
+Copyright (C) 2016-2019 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md))
90 90
 
91 91
 This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
92 92
 

+ 2
- 2
app/chewy/statuses_index.rb View File

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

+ 3
- 0
app/controllers/admin/custom_emojis_controller.rb View File

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

+ 1
- 1
app/controllers/admin/instances_controller.rb View File

@@ -38,7 +38,7 @@ module Admin
38 38
     end
39 39
 
40 40
     def filter_params
41
-      params.permit(:limited)
41
+      params.permit(:limited, :by_domain)
42 42
     end
43 43
   end
44 44
 end

+ 4
- 0
app/controllers/admin/reported_statuses_controller.rb View File

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

+ 1
- 1
app/controllers/api/v1/apps/credentials_controller.rb View File

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

+ 1
- 0
app/controllers/auth/registrations_controller.rb View File

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

+ 5
- 0
app/controllers/oauth/authorized_applications_controller.rb View File

@@ -5,6 +5,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
5 5
 
6 6
   before_action :store_current_location
7 7
   before_action :authenticate_resource_owner!
8
+  before_action :set_body_classes
8 9
 
9 10
   include Localized
10 11
 
@@ -15,6 +16,10 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
15 16
 
16 17
   private
17 18
 
19
+  def set_body_classes
20
+    @body_classes = 'admin'
21
+  end
22
+
18 23
   def store_current_location
19 24
     store_location_for(:user, request.url)
20 25
   end

+ 1
- 1
app/helpers/admin/filter_helper.rb View File

@@ -6,7 +6,7 @@ module Admin::FilterHelper
6 6
   INVITE_FILTER        = %i(available expired).freeze
7 7
   CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze
8 8
   TAGS_FILTERS         = %i(hidden).freeze
9
-  INSTANCES_FILTERS    = %i(limited).freeze
9
+  INSTANCES_FILTERS    = %i(limited by_domain).freeze
10 10
 
11 11
   FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS + TAGS_FILTERS + INSTANCES_FILTERS
12 12
 

+ 1
- 1
app/helpers/stream_entries_helper.rb View File

@@ -170,7 +170,7 @@ module StreamEntriesHelper
170 170
     when 'public'
171 171
       fa_icon 'globe fw'
172 172
     when 'unlisted'
173
-      fa_icon 'unlock-alt fw'
173
+      fa_icon 'unlock fw'
174 174
     when 'private'
175 175
       fa_icon 'lock fw'
176 176
     when 'direct'

+ 1
- 1
app/javascript/mastodon/components/account.js View File

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

+ 16
- 6
app/javascript/mastodon/components/display_name.js View File

@@ -11,26 +11,36 @@ export default class DisplayName extends React.PureComponent {
11 11
   };
12 12
 
13 13
   render () {
14
-    const { account, others, localDomain } = this.props;
15
-    const displayNameHtml = { __html: account.get('display_name_html') };
14
+    const { others, localDomain } = this.props;
16 15
 
17
-    let suffix;
16
+    let displayName, suffix, account;
18 17
 
19 18
     if (others && others.size > 1) {
20
-      suffix = `+${others.size}`;
19
+      displayName = others.take(2).map(a => <bdi key={a.get('id')}><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi>).reduce((prev, cur) => [prev, ', ', cur]);
20
+
21
+      if (others.size - 2 > 0) {
22
+        suffix = `+${others.size - 2}`;
23
+      }
21 24
     } else {
25
+      if (others && others.size > 0) {
26
+        account = others.first();
27
+      } else {
28
+        account = this.props.account;
29
+      }
30
+
22 31
       let acct = account.get('acct');
23 32
 
24 33
       if (acct.indexOf('@') === -1 && localDomain) {
25 34
         acct = `${acct}@${localDomain}`;
26 35
       }
27 36
 
28
-      suffix = <span className='display-name__account'>@{acct}</span>;
37
+      displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
38
+      suffix      = <span className='display-name__account'>@{acct}</span>;
29 39
     }
30 40
 
31 41
     return (
32 42
       <span className='display-name'>
33
-        <bdi><strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /></bdi> {suffix}
43
+        {displayName} {suffix}
34 44
       </span>
35 45
     );
36 46
   }

+ 1
- 1
app/javascript/mastodon/components/domain.js View File

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

+ 1
- 1
app/javascript/mastodon/components/intersection_observer_article.js View File

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

+ 8
- 2
app/javascript/mastodon/components/media_gallery.js View File

@@ -194,6 +194,8 @@ class MediaGallery extends React.PureComponent {
194 194
     height: PropTypes.number.isRequired,
195 195
     onOpenMedia: PropTypes.func.isRequired,
196 196
     intl: PropTypes.object.isRequired,
197
+    defaultWidth: PropTypes.number,
198
+    cacheWidth: PropTypes.func,
197 199
   };
198 200
 
199 201
   static defaultProps = {
@@ -202,6 +204,7 @@ class MediaGallery extends React.PureComponent {
202 204
 
203 205
   state = {
204 206
     visible: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all',
207
+    width: this.props.defaultWidth,
205 208
   };
206 209
 
207 210
   componentWillReceiveProps (nextProps) {
@@ -221,6 +224,7 @@ class MediaGallery extends React.PureComponent {
221 224
   handleRef = (node) => {
222 225
     if (node /*&& this.isStandaloneEligible()*/) {
223 226
       // offsetWidth triggers a layout, so only calculate when we need to
227
+      if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
224 228
       this.setState({
225 229
         width: node.offsetWidth,
226 230
       });
@@ -233,8 +237,10 @@ class MediaGallery extends React.PureComponent {
233 237
   }
234 238
 
235 239
   render () {
236
-    const { media, intl, sensitive, height } = this.props;
237
-    const { width, visible } = this.state;
240
+    const { media, intl, sensitive, height, defaultWidth } = this.props;
241
+    const { visible } = this.state;
242
+
243
+    const width = this.state.width || defaultWidth;
238 244
 
239 245
     let children;
240 246
 

+ 27
- 1
app/javascript/mastodon/components/scrollable_list.js View File

@@ -40,6 +40,7 @@ export default class ScrollableList extends PureComponent {
40 40
 
41 41
   state = {
42 42
     fullscreen: null,
43
+    cachedMediaWidth: 250, // Default media/card width using default Mastodon theme
43 44
   };
44 45
 
45 46
   intersectionObserverWrapper = new IntersectionObserverWrapper();
@@ -130,6 +131,20 @@ export default class ScrollableList extends PureComponent {
130 131
     this.handleScroll();
131 132
   }
132 133
 
134
+  getScrollPosition = () => {
135
+    if (this.node && (this.node.scrollTop > 0 || this.mouseMovedRecently)) {
136
+      return { height: this.node.scrollHeight, top: this.node.scrollTop };
137
+    } else {
138
+      return null;
139
+    }
140
+  }
141
+
142
+  updateScrollBottom = (snapshot) => {
143
+    const newScrollTop = this.node.scrollHeight - snapshot;
144
+
145
+    this.setScrollTop(newScrollTop);
146
+  }
147
+
133 148
   getSnapshotBeforeUpdate (prevProps) {
134 149
     const someItemInserted = React.Children.count(prevProps.children) > 0 &&
135 150
       React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
@@ -150,6 +165,12 @@ export default class ScrollableList extends PureComponent {
150 165
     }
151 166
   }
152 167
 
168
+  cacheMediaWidth = (width) => {
169
+    if (width && this.state.cachedMediaWidth !== width) {
170
+      this.setState({ cachedMediaWidth: width });
171
+    }
172
+  }
173
+
153 174
   componentWillUnmount () {
154 175
     this.clearMouseIdleTimer();
155 176
     this.detachScrollListener();
@@ -239,7 +260,12 @@ export default class ScrollableList extends PureComponent {
239 260
                 intersectionObserverWrapper={this.intersectionObserverWrapper}
240 261
                 saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
241 262
               >
242
-                {child}
263
+                {React.cloneElement(child, {
264
+                  getScrollPosition: this.getScrollPosition,
265
+                  updateScrollBottom: this.updateScrollBottom,
266
+                  cachedMediaWidth: this.state.cachedMediaWidth,
267
+                  cacheMediaWidth: this.cacheMediaWidth,
268
+                })}
243 269
               </IntersectionObserverArticleContainer>
244 270
             ))}
245 271
 

+ 63
- 6
app/javascript/mastodon/components/status.js View File

@@ -68,6 +68,10 @@ class Status extends ImmutablePureComponent {
68 68
     onMoveUp: PropTypes.func,
69 69
     onMoveDown: PropTypes.func,
70 70
     showThread: PropTypes.bool,
71
+    getScrollPosition: PropTypes.func,
72
+    updateScrollBottom: PropTypes.func,
73
+    cacheMediaWidth: PropTypes.func,
74
+    cachedMediaWidth: PropTypes.number,
71 75
   };
72 76
 
73 77
   // Avoid checking props that are functions (and whose equality will always
@@ -79,6 +83,43 @@ class Status extends ImmutablePureComponent {
79 83
     'hidden',
80 84
   ];
81 85
 
86
+  // Track height changes we know about to compensate scrolling
87
+  componentDidMount () {
88
+    this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
89
+  }
90
+
91
+  getSnapshotBeforeUpdate () {
92
+    if (this.props.getScrollPosition) {
93
+      return this.props.getScrollPosition();
94
+    } else {
95
+      return null;
96
+    }
97
+  }
98
+
99
+  // Compensate height changes
100
+  componentDidUpdate (prevProps, prevState, snapshot) {
101
+    const doShowCard  = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
102
+    if (doShowCard && !this.didShowCard) {
103
+      this.didShowCard = true;
104
+      if (snapshot !== null && this.props.updateScrollBottom) {
105
+        if (this.node && this.node.offsetTop < snapshot.top) {
106
+          this.props.updateScrollBottom(snapshot.height - snapshot.top);
107
+        }
108
+      }
109
+    }
110
+  }
111
+
112
+  componentWillUnmount() {
113
+    if (this.node && this.props.getScrollPosition) {
114
+      const position = this.props.getScrollPosition();
115
+      if (position !== null && this.node.offsetTop < position.top) {
116
+        requestAnimationFrame(() => {
117
+          this.props.updateScrollBottom(position.height - position.top);
118
+        });
119
+      }
120
+    }
121
+  }
122
+
82 123
   handleClick = () => {
83 124
     if (this.props.onClick) {
84 125
       this.props.onClick();
@@ -165,6 +206,10 @@ class Status extends ImmutablePureComponent {
165 206
     }
166 207
   }
167 208
 
209
+  handleRef = c => {
210
+    this.node = c;
211
+  }
212
+
168 213
   render () {
169 214
     let media = null;
170 215
     let statusAvatar, prepend, rebloggedByText;
@@ -179,7 +224,7 @@ class Status extends ImmutablePureComponent {
179 224
 
180 225
     if (hidden) {
181 226
       return (
182
-        <div>
227
+        <div ref={this.handleRef}>
183 228
           {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
184 229
           {status.get('content')}
185 230
         </div>
@@ -194,7 +239,7 @@ class Status extends ImmutablePureComponent {
194 239
 
195 240
       return (
196 241
         <HotKeys handlers={minHandlers}>
197
-          <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0'>
242
+          <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}>
198 243
             <FormattedMessage id='status.filtered' defaultMessage='Filtered' />
199 244
           </div>
200 245
         </HotKeys>
@@ -242,11 +287,12 @@ class Status extends ImmutablePureComponent {
242 287
                 preview={video.get('preview_url')}
243 288
                 src={video.get('url')}
244 289
                 alt={video.get('description')}
245
-                width={239}
290
+                width={this.props.cachedMediaWidth}
246 291
                 height={110}
247 292
                 inline
248 293
                 sensitive={status.get('sensitive')}
249 294
                 onOpenVideo={this.handleOpenVideo}
295
+                cacheWidth={this.props.cacheMediaWidth}
250 296
               />
251 297
             )}
252 298
           </Bundle>
@@ -254,7 +300,16 @@ class Status extends ImmutablePureComponent {
254 300
       } else {
255 301
         media = (
256 302
           <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
257
-            {Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />}
303
+            {Component => (
304
+              <Component
305
+                media={status.get('media_attachments')}
306
+                sensitive={status.get('sensitive')}
307
+                height={110}
308
+                onOpenMedia={this.props.onOpenMedia}
309
+                cacheWidth={this.props.cacheMediaWidth}
310
+                defaultWidth={this.props.cachedMediaWidth}
311
+              />
312
+            )}
258 313
           </Bundle>
259 314
         );
260 315
       }
@@ -264,11 +319,13 @@ class Status extends ImmutablePureComponent {
264 319
           onOpenMedia={this.props.onOpenMedia}
265 320
           card={status.get('card')}
266 321
           compact
322
+          cacheWidth={this.props.cacheMediaWidth}
323
+          defaultWidth={this.props.cachedMediaWidth}
267 324
         />
268 325
       );
269 326
     }
270 327
 
271
-    if (otherAccounts) {
328
+    if (otherAccounts && otherAccounts.size > 0) {
272 329
       statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} />;
273 330
     } else if (account === undefined || account === null) {
274 331
       statusAvatar = <Avatar account={status.get('account')} size={48} />;
@@ -290,7 +347,7 @@ class Status extends ImmutablePureComponent {
290 347
 
291 348
     return (
292 349
       <HotKeys handlers={handlers}>
293
-        <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'))}>
350
+        <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}>
294 351
           {prepend}
295 352
 
296 353
           <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')}>

+ 23
- 7
app/javascript/mastodon/components/status_action_bar.js View File

@@ -77,7 +77,11 @@ class StatusActionBar extends ImmutablePureComponent {
77 77
   ]
78 78
 
79 79
   handleReplyClick = () => {
80
-    this.props.onReply(this.props.status, this.context.router.history);
80
+    if (me) {
81
+      this.props.onReply(this.props.status, this.context.router.history);
82
+    } else {
83
+      this._openInteractionDialog('reply');
84
+    }
81 85
   }
82 86
 
83 87
   handleShareClick = () => {
@@ -90,11 +94,23 @@ class StatusActionBar extends ImmutablePureComponent {
90 94
   }
91 95
 
92 96
   handleFavouriteClick = () => {
93
-    this.props.onFavourite(this.props.status);
97
+    if (me) {
98
+      this.props.onFavourite(this.props.status);
99
+    } else {
100
+      this._openInteractionDialog('favourite');
101
+    }
102
+  }
103
+
104
+  handleReblogClick = e => {
105
+    if (me) {
106
+      this.props.onReblog(this.props.status, e);
107
+    } else {
108
+      this._openInteractionDialog('reblog');
109
+    }
94 110
   }
95 111
 
96
-  handleReblogClick = (e) => {
97
-    this.props.onReblog(this.props.status, e);
112
+  _openInteractionDialog = type => {
113
+    window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
98 114
   }
99 115
 
100 116
   handleDeleteClick = () => {
@@ -211,9 +227,9 @@ class StatusActionBar extends ImmutablePureComponent {
211 227
 
212 228
     return (
213 229
       <div className='status__action-bar'>
214
-        <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} />
215
-        <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
216
-        <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='floppy-o' onClick={this.handleFavouriteClick} />
230
+        <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} />
231
+        <IconButton className='status__action-bar-button' disabled={!publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
232
+        <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='floppy-o' onClick={this.handleFavouriteClick} />
217 233
         {shareButton}
218 234
 
219 235
         <div className='status__action-bar-dropdown'>

+ 3
- 0
app/javascript/mastodon/containers/compose_container.js View File

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

+ 1
- 1
app/javascript/mastodon/features/account/components/header.js View File

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

+ 4
- 1
app/javascript/mastodon/features/blocks/index.js View File

@@ -18,6 +18,7 @@ const messages = defineMessages({
18 18
 
19 19
 const mapStateToProps = state => ({
20 20
   accountIds: state.getIn(['user_lists', 'blocks', 'items']),
21
+  hasMore: !!state.getIn(['user_lists', 'blocks', 'next']),
21 22
 });
22 23
 
23 24
 export default @connect(mapStateToProps)
@@ -29,6 +30,7 @@ class Blocks extends ImmutablePureComponent {
29 30
     dispatch: PropTypes.func.isRequired,
30 31
     shouldUpdateScroll: PropTypes.func,
31 32
     accountIds: ImmutablePropTypes.list,
33
+    hasMore: PropTypes.bool,
32 34
     intl: PropTypes.object.isRequired,
33 35
   };
34 36
 
@@ -41,7 +43,7 @@ class Blocks extends ImmutablePureComponent {
41 43
   }, 300, { leading: true });
42 44
 
43 45
   render () {
44
-    const { intl, accountIds, shouldUpdateScroll } = this.props;
46
+    const { intl, accountIds, shouldUpdateScroll, hasMore } = this.props;
45 47
 
46 48
     if (!accountIds) {
47 49
       return (
@@ -59,6 +61,7 @@ class Blocks extends ImmutablePureComponent {
59 61
         <ScrollableList
60 62
           scrollKey='blocks'
61 63
           onLoadMore={this.handleLoadMore}
64
+          hasMore={hasMore}
62 65
           shouldUpdateScroll={shouldUpdateScroll}
63 66
           emptyMessage={emptyMessage}
64 67
         >

+ 1
- 1
app/javascript/mastodon/features/compose/components/compose_form.js View File

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

+ 1
- 1
app/javascript/mastodon/features/compose/components/privacy_dropdown.js View File

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

+ 1
- 2
app/javascript/mastodon/features/compose/components/upload.js View File

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

+ 4
- 1
app/javascript/mastodon/features/domain_blocks/index.js View File

@@ -19,6 +19,7 @@ const messages = defineMessages({
19 19
 
20 20
 const mapStateToProps = state => ({
21 21
   domains: state.getIn(['domain_lists', 'blocks', 'items']),
22
+  hasMore: !!state.getIn(['domain_lists', 'blocks', 'next']),
22 23
 });
23 24
 
24 25
 export default @connect(mapStateToProps)
@@ -29,6 +30,7 @@ class Blocks extends ImmutablePureComponent {
29 30
     params: PropTypes.object.isRequired,
30 31
     dispatch: PropTypes.func.isRequired,
31 32
     shouldUpdateScroll: PropTypes.func,
33
+    hasMore: PropTypes.bool,
32 34
     domains: ImmutablePropTypes.orderedSet,
33 35
     intl: PropTypes.object.isRequired,
34 36
   };
@@ -42,7 +44,7 @@ class Blocks extends ImmutablePureComponent {
42 44
   }, 300, { leading: true });
43 45
 
44 46
   render () {
45
-    const { intl, domains, shouldUpdateScroll } = this.props;
47
+    const { intl, domains, shouldUpdateScroll, hasMore } = this.props;
46 48
 
47 49
     if (!domains) {
48 50
       return (
@@ -60,6 +62,7 @@ class Blocks extends ImmutablePureComponent {
60 62
         <ScrollableList
61 63
           scrollKey='domain_blocks'
62 64
           onLoadMore={this.handleLoadMore}
65
+          hasMore={hasMore}
63 66
           shouldUpdateScroll={shouldUpdateScroll}
64 67
           emptyMessage={emptyMessage}
65 68
         >

+ 4
- 1
app/javascript/mastodon/features/follow_requests/index.js View File

@@ -18,6 +18,7 @@ const messages = defineMessages({
18 18
 
19 19
 const mapStateToProps = state => ({
20 20
   accountIds: state.getIn(['user_lists', 'follow_requests', 'items']),
21
+  hasMore: !!state.getIn(['user_lists', 'follow_requests', 'next']),
21 22
 });
22 23
 
23 24
 export default @connect(mapStateToProps)
@@ -28,6 +29,7 @@ class FollowRequests extends ImmutablePureComponent {
28 29
     params: PropTypes.object.isRequired,
29 30
     dispatch: PropTypes.func.isRequired,
30 31
     shouldUpdateScroll: PropTypes.func,
32
+    hasMore: PropTypes.bool,
31 33
     accountIds: ImmutablePropTypes.list,
32 34
     intl: PropTypes.object.isRequired,
33 35
   };
@@ -41,7 +43,7 @@ class FollowRequests extends ImmutablePureComponent {
41 43
   }, 300, { leading: true });
42 44
 
43 45
   render () {
44
-    const { intl, shouldUpdateScroll, accountIds } = this.props;
46
+    const { intl, shouldUpdateScroll, accountIds, hasMore } = this.props;
45 47
 
46 48
     if (!accountIds) {
47 49
       return (
@@ -59,6 +61,7 @@ class FollowRequests extends ImmutablePureComponent {
59 61
         <ScrollableList
60 62
           scrollKey='follow_requests'
61 63
           onLoadMore={this.handleLoadMore}
64
+          hasMore={hasMore}
62 65
           shouldUpdateScroll={shouldUpdateScroll}
63 66
           emptyMessage={emptyMessage}
64 67
         >

+ 33
- 22
app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js View File

@@ -1,10 +1,15 @@
1 1
 import React from 'react';
2 2
 import PropTypes from 'prop-types';
3 3
 import ImmutablePropTypes from 'react-immutable-proptypes';
4
-import { injectIntl, FormattedMessage } from 'react-intl';
4
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
5 5
 import Toggle from 'react-toggle';
6 6
 import AsyncSelect from 'react-select/lib/Async';
7 7
 
8
+const messages = defineMessages({
9
+  placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' },
10
+  noOptions: { id: 'hashtag.column_settings.select.no_options_message', defaultMessage: 'No suggestions found' },
11
+});
12
+
8 13
 export default @injectIntl
9 14
 class ColumnSettings extends React.PureComponent {
10 15
 
@@ -25,6 +30,7 @@ class ColumnSettings extends React.PureComponent {
25 30
 
26 31
   tags (mode) {
27 32
     let tags = this.props.settings.getIn(['tags', mode]) || [];
33
+
28 34
     if (tags.toJSON) {
29 35
       return tags.toJSON();
30 36
     } else {
@@ -32,33 +38,36 @@ class ColumnSettings extends React.PureComponent {
32 38
     }
33 39
   };
34 40
 
35
-  onSelect = (mode) => {
36
-    return (value) => {
37
-      this.props.onChange(['tags', mode], value);
38
-    };
39
-  };
41
+  onSelect = mode => value => this.props.onChange(['tags', mode], value);
40 42
 
41 43
   onToggle = () => {
42 44
     if (this.state.open && this.hasTags()) {
43 45
       this.props.onChange('tags', {});
44 46
     }
47
+
45 48
     this.setState({ open: !this.state.open });
46 49
   };
47 50
 
51
+  noOptionsMessage = () => this.props.intl.formatMessage(messages.noOptions);
52
+
48 53
   modeSelect (mode) {
49 54
     return (
50
-      <div className='column-settings__section'>
51
-        {this.modeLabel(mode)}
55
+      <div className='column-settings__row'>
56
+        <span className='column-settings__section'>
57
+          {this.modeLabel(mode)}
58
+        </span>
59
+
52 60
         <AsyncSelect
53 61
           isMulti
54 62
           autoFocus
55 63
           value={this.tags(mode)}
56
-          settings={this.props.settings}
57
-          settingPath={['tags', mode]}
58 64
           onChange={this.onSelect(mode)}
59 65
           loadOptions={this.props.onLoad}
60
-          classNamePrefix='column-settings__hashtag-select'
66
+          className='column-select__container'
67
+          classNamePrefix='column-select'
61 68
           name='tags'
69
+          placeholder={this.props.intl.formatMessage(messages.placeholder)}
70
+          noOptionsMessage={this.noOptionsMessage}
62 71
         />
63 72
       </div>
64 73
     );
@@ -66,11 +75,15 @@ class ColumnSettings extends React.PureComponent {
66 75
 
67 76
   modeLabel (mode) {
68 77
     switch(mode) {
69
-    case 'any':  return <FormattedMessage id='hashtag.column_settings.tag_mode.any' defaultMessage='Any of these' />;
70
-    case 'all':  return <FormattedMessage id='hashtag.column_settings.tag_mode.all' defaultMessage='All of these' />;
71
-    case 'none': return <FormattedMessage id='hashtag.column_settings.tag_mode.none' defaultMessage='None of these' />;
78
+    case 'any':
79
+      return <FormattedMessage id='hashtag.column_settings.tag_mode.any' defaultMessage='Any of these' />;
80
+    case 'all':
81
+      return <FormattedMessage id='hashtag.column_settings.tag_mode.all' defaultMessage='All of these' />;
82
+    case 'none':
83
+      return <FormattedMessage id='hashtag.column_settings.tag_mode.none' defaultMessage='None of these' />;
84
+    default:
85
+      return '';
72 86
     }
73
-    return '';
74 87
   };
75 88
 
76 89
   render () {
@@ -78,23 +91,21 @@ class ColumnSettings extends React.PureComponent {
78 91
       <div>
79 92
         <div className='column-settings__row'>
80 93
           <div className='setting-toggle'>
81
-            <Toggle
82
-              id='hashtag.column_settings.tag_toggle'
83
-              onChange={this.onToggle}
84
-              checked={this.state.open}
85
-            />
94
+            <Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} />
95
+
86 96
             <span className='setting-toggle__label'>
87 97
               <FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' />
88 98
             </span>
89 99
           </div>
90 100
         </div>
91
-        {this.state.open &&
101
+
102
+        {this.state.open && (
92 103
           <div className='column-settings__hashtags'>
93 104
             {this.modeSelect('any')}
94 105
             {this.modeSelect('all')}
95 106
             {this.modeSelect('none')}
96 107
           </div>
97
-        }
108
+        )}
98 109
       </div>
99 110
     );
100 111
   }

+ 12
- 5
app/javascript/mastodon/features/hashtag_timeline/index.js View File

@@ -41,15 +41,19 @@ class HashtagTimeline extends React.PureComponent {
41 41
 
42 42
   title = () => {
43 43
     let title = [this.props.params.id];
44
+
44 45
     if (this.additionalFor('any')) {
45
-      title.push(' ', <FormattedMessage id='hashtag.column_header.tag_mode.any'  values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />);
46
+      title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any'  values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />);
46 47
     }
48
+
47 49
     if (this.additionalFor('all')) {
48
-      title.push(' ', <FormattedMessage id='hashtag.column_header.tag_mode.all'  values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />);
50
+      title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all'  values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />);
49 51
     }
52
+
50 53
     if (this.additionalFor('none')) {
51
-      title.push(' ', <FormattedMessage id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />);
54
+      title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />);
52 55
     }
56
+
53 57
     return title;
54 58
   }
55 59
 
@@ -77,9 +81,10 @@ class HashtagTimeline extends React.PureComponent {
77 81
     let all  = (tags.all || []).map(tag => tag.value);
78 82
     let none = (tags.none || []).map(tag => tag.value);
79 83
 
80
-    [id, ...any].map((tag) => {
81
-      this.disconnects.push(dispatch(connectHashtagStream(id, tag, (status) => {
84
+    [id, ...any].map(tag => {
85
+      this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => {
82 86
         let tags = status.tags.map(tag => tag.name);
87
+
83 88
         return all.filter(tag => tags.includes(tag)).length === all.length &&
84 89
                none.filter(tag => tags.includes(tag)).length === 0;
85 90
       })));
@@ -95,12 +100,14 @@ class HashtagTimeline extends React.PureComponent {
95 100
     const { dispatch } = this.props;
96 101
     const { id, tags } = this.props.params;
97 102
 
103
+    this._subscribe(dispatch, id, tags);
98 104
     dispatch(expandHashtagTimeline(id, { tags }));
99 105
   }
100 106
 
101 107
   componentWillReceiveProps (nextProps) {
102 108
     const { dispatch, params } = this.props;
103 109
     const { id, tags } = nextProps.params;
110
+
104 111
     if (id !== params.id || !isEqual(tags, params.tags)) {
105 112
       this._unsubscribe();
106 113
       this._subscribe(dispatch, id, tags);

+ 4
- 1
app/javascript/mastodon/features/mutes/index.js View File

@@ -18,6 +18,7 @@ const messages = defineMessages({
18 18
 
19 19
 const mapStateToProps = state => ({
20 20
   accountIds: state.getIn(['user_lists', 'mutes', 'items']),
21
+  hasMore: !!state.getIn(['user_lists', 'mutes', 'next']),
21 22
 });
22 23
 
23 24
 export default @connect(mapStateToProps)
@@ -28,6 +29,7 @@ class Mutes extends ImmutablePureComponent {
28 29
     params: PropTypes.object.isRequired,
29 30
     dispatch: PropTypes.func.isRequired,
30 31
     shouldUpdateScroll: PropTypes.func,
32
+    hasMore: PropTypes.bool,
31 33
     accountIds: ImmutablePropTypes.list,
32 34
     intl: PropTypes.object.isRequired,
33 35
   };
@@ -41,7 +43,7 @@ class Mutes extends ImmutablePureComponent {
41 43
   }, 300, { leading: true });
42 44
 
43 45
   render () {
44
-    const { intl, shouldUpdateScroll, accountIds } = this.props;
46
+    const { intl, shouldUpdateScroll, hasMore, accountIds } = this.props;
45 47
 
46 48
     if (!accountIds) {
47 49
       return (
@@ -59,6 +61,7 @@ class Mutes extends ImmutablePureComponent {
59 61
         <ScrollableList
60 62
           scrollKey='mutes'
61 63
           onLoadMore={this.handleLoadMore}
64
+          hasMore={hasMore}
62 65
           shouldUpdateScroll={shouldUpdateScroll}
63 66
           emptyMessage={emptyMessage}
64 67
         >

+ 30
- 2
app/javascript/mastodon/features/notifications/components/notification.js View File

@@ -34,6 +34,10 @@ class Notification extends ImmutablePureComponent {
34 34
     onToggleHidden: PropTypes.func.isRequired,
35 35
     status: PropTypes.option,
36 36
     intl: PropTypes.object.isRequired,
37
+    getScrollPosition: PropTypes.func,
38
+    updateScrollBottom: PropTypes.func,
39
+    cacheMediaWidth: PropTypes.func,
40
+    cachedMediaWidth: PropTypes.number,
37 41
   };
38 42
 
39 43
   handleMoveUp = () => {
@@ -128,6 +132,10 @@ class Notification extends ImmutablePureComponent {
128 132
         onMoveDown={this.handleMoveDown}
129 133
         onMoveUp={this.handleMoveUp}
130 134
         contextType='notifications'
135
+        getScrollPosition={this.props.getScrollPosition}
136
+        updateScrollBottom={this.props.updateScrollBottom}
137
+        cachedMediaWidth={this.props.cachedMediaWidth}
138
+        cacheMediaWidth={this.props.cacheMediaWidth}
131 139
       />
132 140
     );
133 141
   }
@@ -148,7 +156,17 @@ class Notification extends ImmutablePureComponent {
148 156
             </span>
149 157
           </div>
150 158
 
151
-          <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
159
+          <StatusContainer
160
+            id={notification.get('status')}
161
+            account={notification.get('account')}
162
+            muted
163
+            withDismiss
164
+            hidden={!!this.props.hidden}
165
+            getScrollPosition={this.props.getScrollPosition}
166
+            updateScrollBottom={this.props.updateScrollBottom}
167
+            cachedMediaWidth={this.props.cachedMediaWidth}
168
+            cacheMediaWidth={this.props.cacheMediaWidth}
169
+          />
152 170
         </div>
153 171
       </HotKeys>
154 172
     );
@@ -170,7 +188,17 @@ class Notification extends ImmutablePureComponent {
170 188
             </span>
171 189
           </div>
172 190
 
173
-          <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
191
+          <StatusContainer
192
+            id={notification.get('status')}
193
+            account={notification.get('account')}
194
+            muted
195
+            withDismiss
196
+            hidden={this.props.hidden}
197
+            getScrollPosition={this.props.getScrollPosition}
198
+            updateScrollBottom={this.props.updateScrollBottom}
199
+            cachedMediaWidth={this.props.cachedMediaWidth}
200
+            cacheMediaWidth={this.props.cacheMediaWidth}
201
+          />
174 202
         </div>
175 203
       </HotKeys>
176 204
     );

+ 4
- 1
app/javascript/mastodon/features/status/components/card.js View File

@@ -60,6 +60,8 @@ export default class Card extends React.PureComponent {
60 60
     maxDescription: PropTypes.number,
61 61
     onOpenMedia: PropTypes.func.isRequired,
62 62
     compact: PropTypes.bool,
63
+    defaultWidth: PropTypes.number,
64
+    cacheWidth: PropTypes.func,
63 65
   };
64 66
 
65 67
   static defaultProps = {
@@ -68,7 +70,7 @@ export default class Card extends React.PureComponent {
68 70
   };
69 71
 
70 72
   state = {
71
-    width: 280,
73
+    width: this.props.defaultWidth || 280,
72 74
     embedded: false,
73 75
   };
74 76
 
@@ -111,6 +113,7 @@ export default class Card extends React.PureComponent {
111 113
 
112 114
   setRef = c => {
113 115
     if (c) {
116
+      if (this.props.cacheWidth) this.props.cacheWidth(c.offsetWidth);
114 117
       this.setState({ width: c.offsetWidth });
115 118
     }
116 119
   }

+ 1
- 1
app/javascript/mastodon/features/status/components/detailed_status.js View File

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

+ 3
- 2
app/javascript/mastodon/features/video/index.js View File

@@ -99,6 +99,7 @@ class Video extends React.PureComponent {
99 99
     onCloseVideo: PropTypes.func,
100 100
     detailed: PropTypes.bool,
101 101
     inline: PropTypes.bool,
102
+    cacheWidth: PropTypes.func,
102 103
     intl: PropTypes.object.isRequired,
103 104
   };
104 105
 
@@ -108,7 +109,7 @@ class Video extends React.PureComponent {
108 109
     volume: 0.5,
109 110
     paused: true,
110 111
     dragging: false,
111
-    containerWidth: false,
112
+    containerWidth: this.props.width,
112 113
     fullscreen: false,
113 114
     hovered: false,
114 115
     muted: false,
@@ -128,6 +129,7 @@ class Video extends React.PureComponent {
128 129
     this.player = c;
129 130
 
130 131
     if (c) {
132
+      if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth);
131 133
       this.setState({
132 134
         containerWidth: c.offsetWidth,
133 135
       });
@@ -344,7 +346,6 @@ class Video extends React.PureComponent {
344 346
       width  = containerWidth;
345 347
       height = containerWidth / (16/9);
346 348
 
347
-      playerStyle.width  = width;
348 349
       playerStyle.height = height;
349 350
     }
350 351
 

+ 13
- 0
app/javascript/packs/error.js View File

@@ -0,0 +1,13 @@
1
+import ready from '../mastodon/ready';
2
+
3
+ready(() => {
4
+  const image = document.querySelector('img');
5
+
6
+  image.addEventListener('mouseenter', () => {
7
+    image.src = '/oops.gif';
8
+  });
9
+
10
+  image.addEventListener('mouseleave', () => {
11
+    image.src = '/oops.png';
12
+  });
13
+});

+ 55
- 0
app/javascript/styles/contrast/diff.scss View File

@@ -12,3 +12,58 @@
12 12
     }
13 13
   }
14 14
 }
15
+
16
+.rich-formatting a,
17
+.rich-formatting p a,
18
+.rich-formatting li a,
19
+.landing-page__short-description p a,
20
+.status__content a,
21
+.reply-indicator__content a {
22
+  color: lighten($ui-highlight-color, 12%);
23
+  text-decoration: underline;
24
+
25
+  &.mention {
26
+    text-decoration: none;
27
+  }
28
+
29
+  &.mention span {
30
+    text-decoration: underline;
31
+
32
+    &:hover,
33
+    &:focus,
34
+    &:active {
35
+      text-decoration: none;
36
+    }
37
+  }
38
+
39
+  &:hover,
40
+  &:focus,
41
+  &:active {
42
+    text-decoration: none;
43
+  }
44
+
45
+  &.status__content__spoiler-link {
46
+    color: $secondary-text-color;
47
+    text-decoration: none;
48
+  }
49
+}
50
+
51
+.status__content__read-more-button {
52
+  text-decoration: underline;
53
+
54
+  &:hover,
55
+  &:focus,
56
+  &:active {
57
+    text-decoration: none;
58
+  }
59
+}
60
+
61
+.getting-started__footer a {
62
+  text-decoration: underline;
63
+
64
+  &:hover,
65
+  &:focus,
66
+  &:active {
67
+    text-decoration: none;
68
+  }
69
+}

+ 31
- 0
app/javascript/styles/mastodon/_mixins.scss View File

@@ -41,3 +41,34 @@
41 41
     font-size: 16px;
42 42
   }
43 43
 }
44
+
45
+@mixin search-popout() {
46
+  background: $simple-background-color;
47
+  border-radius: 4px;
48
+  padding: 10px 14px;
49
+  padding-bottom: 14px;
50
+  margin-top: 10px;
51
+  color: $light-text-color;
52
+  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
53
+
54
+  h4 {
55
+    text-transform: uppercase;
56
+    color: $light-text-color;
57
+    font-size: 13px;
58
+    font-weight: 500;
59
+    margin-bottom: 10px;
60
+  }
61
+
62
+  li {
63
+    padding: 4px 0;
64
+  }
65
+
66
+  ul {
67
+    margin-bottom: 10px;
68
+  }
69
+
70
+  em {
71
+    font-weight: 500;
72
+    color: $inverted-text-color;
73
+  }
74
+}

+ 3
- 8
app/javascript/styles/mastodon/about.scss View File

@@ -49,15 +49,9 @@ $small-breakpoint: 960px;
49 49
     }
50 50
   }
51 51
 
52
+  strong,
52 53
   em {
53
-    display: inline;
54
-    margin: 0;
55
-    padding: 0;
56 54
     font-weight: 700;
57
-    background: transparent;
58
-    font-family: inherit;
59
-    font-size: inherit;
60
-    line-height: inherit;
61 55
     color: lighten($darker-text-color, 10%);
62 56
   }
63 57
 
@@ -796,7 +790,7 @@ $small-breakpoint: 960px;
796 790
       width: 100%;
797 791
       display: flex;
798 792
       flex-direction: row-reverse;
799
-      flex-wrap: wrap;
793
+      flex-wrap: nowrap;
800 794
       justify-content: space-between;
801 795
       align-items: center;
802 796
     }
@@ -846,6 +840,7 @@ $small-breakpoint: 960px;
846 840
     }
847 841
 
848 842
     strong {
843
+      font-weight: 500;
849 844
       display: inline;
850 845
       margin: 0;
851 846
       padding: 0;

+ 8
- 6
app/javascript/styles/mastodon/basics.scss View File

@@ -100,12 +100,14 @@ body {
100 100
       vertical-align: middle;
101 101
       margin: 20px;
102 102
 
103
-      img {
104
-        display: block;
105
-        max-width: 470px;
106
-        width: 100%;
107
-        height: auto;
108
-        margin-top: -120px;
103
+      &__illustration {
104
+        img {
105
+          display: block;
106
+          max-width: 470px;
107
+          width: 100%;
108
+          height: auto;
109
+          margin-top: -120px;
110
+        }
109 111
       }
110 112
 
111 113
       h1 {

+ 66
- 33
app/javascript/styles/mastodon/components.scss View File

@@ -475,7 +475,7 @@
475 475
         opacity: 0;
476 476
         transition: opacity .1s ease;
477 477
 
478
-        input {
478
+        textarea {
479 479
           background: transparent;
480 480
           color: $secondary-text-color;
481 481
           border: 0;
@@ -637,7 +637,6 @@
637 637
   font-weight: 400;
638 638
   overflow: hidden;
639 639
   text-overflow: ellipsis;
640
-  white-space: pre-wrap;
641 640
   padding-top: 2px;
642 641
   color: $primary-text-color;
643 642
 
@@ -661,6 +660,7 @@
661 660
 
662 661
   p {
663 662
     margin-bottom: 20px;
663
+    white-space: pre-wrap;
664 664
 
665 665
     &:last-child {
666 666
       margin-bottom: 0;
@@ -3050,14 +3050,41 @@ a.status-card.compact:hover {
3050 3050
   display: block;
3051 3051
   font-weight: 500;
3052 3052
   margin-bottom: 10px;
3053
+}
3054
+
3055
+.column-settings__hashtags {
3056
+  .column-settings__row {
3057
+    margin-bottom: 15px;
3058
+  }
3053 3059
 
3054
-  .column-settings__hashtag-select {
3060
+  .column-select {
3055 3061
     &__control {
3056 3062
       @include search-input();
3057 3063
     }
3058 3064
 
3065
+    &__placeholder {
3066
+      color: $dark-text-color;
3067
+      padding-left: 2px;
3068
+      font-size: 12px;
3069
+    }
3070
+
3071
+    &__value-container {
3072
+      padding-left: 6px;
3073
+    }
3074
+
3059 3075
     &__multi-value {
3060 3076
       background: lighten($ui-base-color, 8%);
3077
+
3078
+      &__remove {
3079
+        cursor: pointer;
3080
+
3081
+        &:hover,
3082
+        &:active,
3083
+        &:focus {
3084
+          background: lighten($ui-base-color, 12%);
3085
+          color: lighten($darker-text-color, 4%);
3086
+        }
3087
+      }
3061 3088
     }
3062 3089
 
3063 3090
     &__multi-value__label,
@@ -3065,9 +3092,42 @@ a.status-card.compact:hover {
3065 3092
       color: $darker-text-color;
3066 3093
     }
3067 3094
 
3068
-    &__indicator-separator,
3095
+    &__clear-indicator,
3069 3096
     &__dropdown-indicator {
3070
-      display: none;
3097
+      cursor: pointer;
3098
+      transition: none;
3099
+      color: $dark-text-color;
3100
+
3101
+      &:hover,
3102
+      &:active,
3103
+      &:focus {
3104
+        color: lighten($dark-text-color, 4%);
3105
+      }
3106
+    }
3107
+
3108
+    &__indicator-separator {
3109
+      background-color: lighten($ui-base-color, 8%);
3110
+    }
3111
+
3112
+    &__menu {
3113
+      @include search-popout();
3114
+      padding: 0;
3115
+      background: $ui-secondary-color;
3116
+    }
3117
+
3118
+    &__menu-list {
3119
+      padding: 6px;
3120
+    }
3121
+
3122
+    &__option {
3123
+      color: $inverted-text-color;
3124
+      border-radius: 4px;
3125
+      font-size: 14px;
3126
+
3127
+      &--is-focused,
3128
+      &--is-selected {
3129
+        background: darken($ui-secondary-color, 10%);
3130
+      }
3071 3131
     }
3072 3132
   }
3073 3133
 }
@@ -4933,34 +4993,7 @@ a.status-card.compact:hover {
4933 4993
 }
4934 4994
 
4935 4995
 .search-popout {
4936
-  background: $simple-background-color;
4937
-  border-radius: 4px;
4938
-  padding: 10px 14px;
4939
-  padding-bottom: 14px;
4940
-  margin-top: 10px;
4941
-  color: $light-text-color;
4942
-  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
4943
-
4944
-  h4 {
4945
-    text-transform: uppercase;
4946
-    color: $light-text-color;
4947
-    font-size: 13px;
4948
-    font-weight: 500;
4949
-    margin-bottom: 10px;
4950
-  }
4951
-
4952
-  li {
4953
-    padding: 4px 0;
4954
-  }
4955
-
4956
-  ul {
4957
-    margin-bottom: 10px;
4958
-  }
4959
-
4960
-  em {
4961
-    font-weight: 500;
4962
-    color: $inverted-text-color;
4963
-  }
4996
+  @include search-popout();
4964 4997
 }
4965 4998
 
4966 4999
 noscript {

+ 2
- 4
app/lib/activity_tracker.rb View File

@@ -4,6 +4,8 @@ class ActivityTracker
4 4
   EXPIRE_AFTER = 90.days.seconds
5 5
 
6 6
   class << self
7
+    include Redisable
8
+
7 9
     def increment(prefix)
8 10
       key = [prefix, current_week].join(':')
9 11
 
@@ -20,10 +22,6 @@ class ActivityTracker
20 22
 
21 23
     private
22 24
 
23
-    def redis
24
-      Redis.current
25
-    end
26
-
27 25
     def current_week
28 26
       Time.zone.today.cweek
29 27
     end

+ 49
- 2
app/lib/activitypub/activity.rb View File

@@ -2,6 +2,10 @@
2 2
 
3 3
 class ActivityPub::Activity
4 4
   include JsonLdHelper
5
+  include Redisable
6
+
7
+  SUPPORTED_TYPES = %w(Note).freeze
8
+  CONVERTED_TYPES = %w(Image Video Article Page).freeze
5 9
 
6 10
   def initialize(json, account, **options)
7 11
     @json    = json
@@ -70,8 +74,16 @@ class ActivityPub::Activity
70 74
     @object_uri ||= value_or_id(@object)
71 75
   end
72 76
 
73
-  def redis
74
-    Redis.current
77
+  def unsupported_object_type?
78
+    @object.is_a?(String) || !(supported_object_type? || converted_object_type?)
79
+  end
80
+
81
+  def supported_object_type?
82
+    equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
83
+  end
84
+
85
+  def converted_object_type?
86
+    equals_or_includes_any?(@object['type'], CONVERTED_TYPES)
75 87
   end
76 88
 
77 89
   def distribute(status)
@@ -123,6 +135,24 @@ class ActivityPub::Activity
123 135
     redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri)
124 136
   end
125 137
 
138
+  def status_from_object
139
+    # If the status is already known, return it
140
+    status = status_from_uri(object_uri)
141
+
142
+    return status unless status.nil?
143
+
144
+    # If the boosted toot is embedded and it is a self-boost, handle it like a Create
145
+    unless unsupported_object_type?
146
+      actor_id = value_or_id(first_of_value(@object['attributedTo'])) || @account.uri
147
+
148
+      if actor_id == @account.uri
149
+        return ActivityPub::Activity.factory({ 'type' => 'Create', 'actor' => actor_id, 'object' => @object }, @account).perform
150
+      end
151
+    end
152
+
153
+    fetch_remote_original_status
154
+  end
155
+
126 156
   def fetch_remote_original_status
127 157
     if object_uri.start_with?('http')
128 158
       return if ActivityPub::TagManager.instance.local_uri?(object_uri)
@@ -137,4 +167,21 @@ class ActivityPub::Activity
137 167
   ensure
138 168
     redis.del(key)
139 169
   end
170
+
171
+  def fetch?
172
+    !@options[:delivery]
173
+  end
174
+
175
+  def followed_by_local_accounts?
176
+    @account.passive_relationships.exists?
177
+  end
178
+
179
+  def requested_through_relay?
180
+    @options[:relayed_through_account] && Relay.find_by(inbox_url: @options[:relayed_through_account].inbox_url)&.enabled?
181
+  end
182
+
183
+  def reject_payload!
184
+    Rails.logger.info("Rejected #{@json['type']} activity #{@json['id']} from #{@account.uri}#{@options[:relayed_through_account] && "via #{@options[:relayed_through_account].uri}"}")
185
+    nil
186
+  end
140 187
 end

+ 12
- 3
app/lib/activitypub/activity/announce.rb View File

@@ -2,10 +2,11 @@
2 2
 
3 3
 class ActivityPub::Activity::Announce < ActivityPub::Activity
4 4
   def perform
5
-    original_status   = status_from_uri(object_uri)
6
-    original_status ||= fetch_remote_original_status
5
+    return reject_payload! if delete_arrived_first?(@json['id']) || !related_to_local_activity?
7 6
 
8
-    return if original_status.nil? || delete_arrived_first?(@json['id']) || !announceable?(original_status)
7
+    original_status = status_from_object
8
+
9
+    return reject_payload! if original_status.nil? || !announceable?(original_status)
9 10
 
10 11
     status = Status.find_by(account: @account, reblog: original_status)
11 12
 
@@ -41,4 +42,12 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
41 42
   def announceable?(status)
42 43
     status.account_id == @account.id || status.public_visibility? || status.unlisted_visibility?
43 44
   end
45
+
46
+  def related_to_local_activity?
47
+    followed_by_local_accounts? || requested_through_relay? || reblog_of_local_status?
48
+  end
49
+
50
+  def reblog_of_local_status?
51
+    status_from_uri(object_uri)&.account&.local?
52
+  end
44 53
 end

+ 20
- 17
app/lib/activitypub/activity/create.rb View File

@@ -1,12 +1,8 @@
1 1
 # frozen_string_literal: true
2 2
 
3 3
 class ActivityPub::Activity::Create < ActivityPub::Activity
4
-  SUPPORTED_TYPES = %w(Note).freeze
5
-  CONVERTED_TYPES = %w(Image Video Article Page).freeze
6
-
7 4
   def perform
8
-    return if unsupported_object_type? || invalid_origin?(@object['id'])
9
-    return if Tombstone.exists?(uri: @object['id'])
5
+    return reject_payload! if unsupported_object_type? || invalid_origin?(@object['id']) || Tombstone.exists?(uri: @object['id']) || !related_to_local_activity?
10 6
 
11 7
     RedisLock.acquire(lock_options) do |lock|
12 8
       if lock.acquired?
@@ -318,22 +314,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
318 314
     @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty?
319 315
   end
320 316
 
321
-  def unsupported_object_type?
322
-    @object.is_a?(String) || !(supported_object_type? || converted_object_type?)
323
-  end
324
-
325 317
   def unsupported_media_type?(mime_type)
326 318
     mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
327 319
   end
328 320
 
329
-  def supported_object_type?
330
-    equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
331
-  end
332
-
333
-  def converted_object_type?
334
-    equals_or_includes_any?(@object['type'], CONVERTED_TYPES)
335
-  end
336
-
337 321
   def skip_download?
338 322
     return @skip_download if defined?(@skip_download)
339 323
     @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
@@ -352,6 +336,25 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
352 336
     !replied_to_status.nil? && replied_to_status.account.local?
353 337
   end
354 338
 
339
+  def related_to_local_activity?
340
+    fetch? || followed_by_local_accounts? || requested_through_relay? ||
341
+      responds_to_followed_account? || addresses_local_accounts?
342
+  end
343
+
344
+  def responds_to_followed_account?
345
+    !replied_to_status.nil? && (replied_to_status.account.local? || replied_to_status.account.passive_relationships.exists?)
346
+  end
347
+
348
+  def addresses_local_accounts?
349
+    return true if @options[:delivered_to_account_id]
350
+
351
+    local_usernames = (as_array(@object['to']) + as_array(@object['cc'])).uniq.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }.map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) }
352
+
353
+    return false if local_usernames.empty?
354
+
355
+    Account.local.where(username: local_usernames).exists?
356
+  end
357
+
355 358
   def forward_for_reply
356 359
     return unless @json['signature'].present? && reply_to_local?
357 360
     ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url])

+ 3
- 6
app/lib/feed_manager.rb View File

@@ -4,6 +4,7 @@ require 'singleton'
4 4
 
5 5
 class FeedManager
6 6
   include Singleton
7
+  include Redisable
7 8
 
8 9
   MAX_ITEMS = 400
9 10
 
@@ -35,7 +36,7 @@ class FeedManager
35 36
 
36 37
   def unpush_from_home(account, status)
37 38
     return false unless remove_from_feed(:home, account.id, status)
38
-    Redis.current.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
39
+    redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
39 40
     true
40 41
   end
41 42
 
@@ -53,7 +54,7 @@ class FeedManager
53 54
 
54 55
   def unpush_from_list(list, status)
55 56
     return false unless remove_from_feed(:list, list.id, status)
56
-    Redis.current.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
57
+    redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
57 58
     true
58 59
   end
59 60
 
@@ -142,10 +143,6 @@ class FeedManager
142 143
 
143 144
   private
144 145
 
145
-  def redis
146
-    Redis.current
147
-  end
148
-
149 146
   def push_update_required?(timeline_id)
150 147
     redis.exists("subscribed:#{timeline_id}")
151 148
   end

+ 48
- 1
app/lib/formatter.rb View File

@@ -99,7 +99,7 @@ class Formatter
99 99
   end
100 100
 
101 101
   def encode_and_link_urls(html, accounts = nil, options = {})
102
-    entities = Extractor.extract_entities_with_indices(html, extract_url_without_protocol: false)
102
+    entities = utf8_friendly_extractor(html, extract_url_without_protocol: false)
103 103
 
104 104
     if accounts.is_a?(Hash)
105 105
       options  = accounts
@@ -199,6 +199,53 @@ class Formatter
199 199
     result.flatten.join
200 200
   end
201 201
 
202
+  UNICODE_ESCAPE_BLACKLIST_RE = /\p{Z}|\p{P}/
203
+
204
+  def utf8_friendly_extractor(text, options = {})
205
+    old_to_new_index = [0]
206
+
207
+    escaped = text.chars.map do |c|
208
+      output = begin
209
+        if c.ord.to_s(16).length > 2 && UNICODE_ESCAPE_BLACKLIST_RE.match(c).nil?
210
+          CGI.escape(c)
211
+        else
212
+          c
213
+        end
214
+      end
215
+
216
+      old_to_new_index << old_to_new_index.last + output.length
217
+
218
+      output
219
+    end.join
220
+
221
+    # Note: I couldn't obtain list_slug with @user/list-name format
222
+    # for mention so this requires additional check
223
+    special = Extractor.extract_urls_with_indices(escaped, options).map do |extract|
224
+      # exactly one of :url, :hashtag, :screen_name, :cashtag keys is present
225
+      key = (extract.keys & [:url, :hashtag, :screen_name, :cashtag]).first
226
+
227
+      new_indices = [
228
+        old_to_new_index.find_index(extract[:indices].first),
229
+        old_to_new_index.find_index(extract[:indices].last),
230
+      ]
231
+
232
+      has_prefix_char = [:hashtag, :screen_name, :cashtag].include?(key)
233
+      value_indices = [
234
+        new_indices.first + (has_prefix_char ? 1 : 0), # account for #, @ or $
235
+        new_indices.last - 1,
236
+      ]
237
+
238
+      next extract.merge(
239
+        :indices => new_indices,
240
+        key => text[value_indices.first..value_indices.last]
241
+      )
242
+    end
243
+
244
+    standard = Extractor.extract_entities_with_indices(text, options)
245
+
246
+    Extractor.remove_overlapping_entities(special + standard)
247
+  end
248
+
202 249
   def link_to_url(entity, options = {})
203 250
     url        = Addressable::URI.parse(entity[:url])
204 251
     html_attrs = { target: '_blank', rel: 'nofollow noopener' }

+ 2
- 4
app/lib/ostatus/activity/base.rb View File

@@ -1,6 +1,8 @@
1 1
 # frozen_string_literal: true
2 2
 
3 3
 class OStatus::Activity::Base
4
+  include Redisable
5
+
4 6
   def initialize(xml, account = nil, **options)
5 7
     @xml     = xml
6 8
     @account = account
@@ -66,8 +68,4 @@ class OStatus::Activity::Base
66 68
       Status.find_by(uri: uri)
67 69
     end
68 70
   end
69
-
70
-  def redis
71
-    Redis.current
72
-  end
73 71
 end

+ 2
- 6
app/lib/potential_friendship_tracker.rb View File

@@ -11,6 +11,8 @@ class PotentialFriendshipTracker
11 11
   }.freeze
12 12
 
13 13
   class << self
14
+    include Redisable
15
+
14 16
     def record(account_id, target_account_id, action)
15 17
       return if account_id == target_account_id
16 18
 
@@ -31,11 +33,5 @@ class PotentialFriendshipTracker
31 33
       return [] if account_ids.empty?
32 34
       Account.searchable.where(id: account_ids)
33 35
     end
34
-
35
-    private
36
-
37
-    def redis
38
-      Redis.current
39
-    end
40 36
   end
41 37
 end

+ 2
- 1
app/models/account_conversation.rb View File

@@ -30,7 +30,8 @@ class AccountConversation < ApplicationRecord
30 30
     if participant_account_ids.empty?
31 31
       [account]
32 32
     else
33
-      Account.where(id: participant_account_ids)
33
+      participants = Account.where(id: participant_account_ids)
34
+      participants.empty? ? [account] : participants
34 35
     end
35 36
   end
36 37
 

+ 11
- 0
app/models/concerns/redisable.rb View File

@@ -0,0 +1,11 @@
1
+# frozen_string_literal: true
2
+
3
+module Redisable
4
+  extend ActiveSupport::Concern
5
+
6
+  private
7
+
8
+  def redis
9
+    Redis.current
10
+  end
11
+end

+ 2
- 0
app/models/domain_block.rb View File

@@ -24,6 +24,8 @@ class DomainBlock < ApplicationRecord
24 24
   has_many :accounts, foreign_key: :domain, primary_key: :domain
25 25
   delegate :count, to: :accounts, prefix: true
26 26
 
27
+  scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
28
+
27 29
   def self.blocked?(domain)
28 30
     where(domain: domain, severity: :suspend).exists?
29 31
   end

+ 2
- 4
app/models/feed.rb View File

@@ -1,6 +1,8 @@
1 1
 # frozen_string_literal: true
2 2
 
3 3
 class Feed
4
+  include Redisable
5
+
4 6
   def initialize(type, id)
5 7
     @type = type
6 8
     @id   = id
@@ -27,8 +29,4 @@ class Feed
27 29
   def key
28 30
     FeedManager.instance.key(@type, @id)
29 31
   end
30
-
31
-  def redis
32
-    Redis.current
33
-  end
34 32
 end

+ 6
- 2
app/models/instance_filter.rb View File

@@ -9,9 +9,13 @@ class InstanceFilter
9 9
 
10 10
   def results
11 11
     if params[:limited].present?
12
-      DomainBlock.order(id: :desc)
12
+      scope = DomainBlock
13
+      scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present?
14
+      scope.order(id: :desc)
13 15
     else
14
-      Account.remote.by_domain_accounts
16
+      scope = Account.remote
17
+      scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present?
18
+      scope.by_domain_accounts
15 19
     end
16 20
   end
17 21
 end

+ 2
- 0
app/models/relay.rb View File

@@ -29,6 +29,7 @@ class Relay < ApplicationRecord
29 29
     payload     = Oj.dump(follow_activity(activity_id))
30 30
 
31 31
     update!(state: :pending, follow_activity_id: activity_id)
32
+    DeliveryFailureTracker.new(inbox_url).track_success!
32 33
     ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url)
33 34
   end
34 35
 
@@ -37,6 +38,7 @@ class Relay < ApplicationRecord
37 38
     payload     = Oj.dump(unfollow_activity(activity_id))
38 39
 
39 40
     update!(state: :idle, follow_activity_id: nil)
41
+    DeliveryFailureTracker.new(inbox_url).track_success!
40 42
     ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url)
41 43
   end
42 44
 

+ 2
- 4
app/models/trending_tags.rb View File

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

+ 6
- 2
app/serializers/activitypub/activity_serializer.rb View File

@@ -3,8 +3,8 @@
3 3
 class ActivityPub::ActivitySerializer < ActiveModel::Serializer
4 4
   attributes :id, :type, :actor, :published, :to, :cc
5 5
 
6
-  has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, unless: :announce?
7
-  attribute :proper_uri, key: :object, if: :announce?
6
+  has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, unless: :owned_announce?
7
+  attribute :proper_uri, key: :object, if: :owned_announce?
8 8
   attribute :atom_uri, if: :announce?
9 9
 
10 10
   def id
@@ -42,4 +42,8 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer
42 42
   def announce?
43 43
     object.reblog?
44 44
   end
45
+
46
+  def owned_announce?
47
+    announce? && object.account == object.proper.account && object.proper.private_visibility?
48
+  end
45 49
 end

+ 5
- 1
app/serializers/rest/application_serializer.rb View File

@@ -2,7 +2,7 @@
2 2
 
3 3
 class REST::ApplicationSerializer < ActiveModel::Serializer
4 4
   attributes :id, :name, :website, :redirect_uri,
5
-             :client_id, :client_secret
5
+             :client_id, :client_secret, :vapid_key
6 6
 
7 7
   def id
8 8
     object.id.to_s
@@ -19,4 +19,8 @@ class REST::ApplicationSerializer < ActiveModel::Serializer
19 19
   def website
20 20
     object.website.presence
21 21
   end
22
+
23
+  def vapid_key
24
+    Rails.configuration.x.vapid_public_key
25
+  end
22 26
 end

+ 5
- 1
app/serializers/rest/instance_serializer.rb View File

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

+ 1
- 1
app/services/activitypub/process_account_service.rb View File

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

+ 1
- 0
app/services/activitypub/process_collection_service.rb View File

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

+ 1
- 4
app/services/batched_remove_status_service.rb View File

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

+ 2
- 4
app/services/follow_service.rb View File

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

+ 2
- 4
app/services/post_status_service.rb View File

@@ -1,6 +1,8 @@
1 1
 # frozen_string_literal: true
2 2
 
3 3
 class PostStatusService < BaseService
4
+  include Redisable
5
+
4 6
   MIN_SCHEDULE_OFFSET = 5.minutes.freeze
5 7
 
6 8
   # Post a text status update, fetch and notify remote users mentioned
@@ -110,10 +112,6 @@ class PostStatusService < BaseService
110 112
     ProcessHashtagsService.new
111 113
   end
112 114
 
113
-  def redis
114
-    Redis.current
115
-  end
116
-
117 115
   def scheduled?
118 116
     @scheduled_at.present?
119 117
   end

+ 8
- 11
app/services/remove_status_service.rb View File

@@ -2,6 +2,7 @@
2 2
 
3 3
 class RemoveStatusService < BaseService
4 4
   include StreamEntryRenderer
5
+  include Redisable
5 6
 
6 7
   def call(status, **options)
7 8
     @payload      = Oj.dump(event: :delete, payload: status.id.to_s)
@@ -55,7 +56,7 @@ class RemoveStatusService < BaseService
55 56
 
56 57
   def remove_from_affected
57 58
     @mentions.map(&:account).select(&:local?).each do |account|
58
-      Redis.current.publish("timeline:#{account.id}", @payload)
59
+      redis.publish("timeline:#{account.id}", @payload)
59 60
     end
60 61
   end
61 62
 
@@ -133,26 +134,22 @@ class RemoveStatusService < BaseService
133 134
     return unless @status.public_visibility?
134 135
 
135 136
     @tags.each do |hashtag|
136
-      Redis.current.publish("timeline:hashtag:#{hashtag}", @payload)
137
-      Redis.current.publish("timeline:hashtag:#{hashtag}:local", @payload) if @status.local?
137
+      redis.publish("timeline:hashtag:#{hashtag}", @payload)
138
+      redis.publish("timeline:hashtag:#{hashtag}:local", @payload) if @status.local?
138 139
     end
139 140
   end
140 141
 
141 142
   def remove_from_public
142 143
     return unless @status.public_visibility?
143 144
 
144
-    Redis.current.publish('timeline:public', @payload)
145
-    Redis.current.publish('timeline:public:local', @payload) if @status.local?
145
+    redis.publish('timeline:public', @payload)
146
+    redis.publish('timeline:public:local', @payload) if @status.local?
146 147
   end
147 148
 
148 149
   def remove_from_media
149 150
     return unless @status.public_visibility?
150 151
 
151
-    Redis.current.publish('timeline:public:media', @payload)
152
-    Redis.current.publish('timeline:public:local:media', @payload) if @status.local?
153
-  end
154
-
155
-  def redis
156
-    Redis.current
152
+    redis.publish('timeline:public:media', @payload)
153
+    redis.publish('timeline:public:local:media', @payload) if @status.local?
157 154
   end
158 155
 end

+ 9
- 1
app/services/suspend_account_service.rb View File

@@ -102,6 +102,10 @@ class SuspendAccountService < BaseService
102 102
     ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url|
103 103
       [delete_actor_json, @account.id, inbox_url]
104 104
     end
105
+
106
+    ActivityPub::LowPriorityDeliveryWorker.push_bulk(low_priority_delivery_inboxes) do |inbox_url|
107
+      [delete_actor_json, @account.id, inbox_url]
108
+    end
105 109
   end
106 110
 
107 111
   def delete_actor_json
@@ -117,7 +121,11 @@ class SuspendAccountService < BaseService
117 121
   end
118 122
 
119 123
   def delivery_inboxes
120
-    Account.inboxes + Relay.enabled.pluck(:inbox_url)
124
+    @delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url)
125
+  end
126
+
127
+  def low_priority_delivery_inboxes
128
+    Account.inboxes - delivery_inboxes
121 129
   end
122 130
 
123