Compare commits

...

62 commits

Author SHA1 Message Date
Eugen Rochko
a91349d45d Bump version to 2.7.4 2019-03-05 18:09:50 +01:00
ThibG
ae9e5ac6c8 Fix suspended account's fields being set as empty dict instead of list (#10178)
Fixes #10177
2019-03-05 18:05:54 +01:00
ThibG
7034bb65d6 Fix lists export (#10136) 2019-03-05 18:05:16 +01:00
Eugen Rochko
a4bc5c375c Fix home timeline perpetually reloading when empty (#10130)
Regression from #6876
2019-03-05 18:04:59 +01:00
ThibG
2d9138082e Fix serialization of boosts (#10129)
The condition introduced by #9998 was wrong, serializing boosts
that weren't self-boosts, and not serializing self-boosts.
2019-03-05 18:04:40 +01:00
ThibG
3a3cdc9724 Fix direct timeline pagination in the WebUI (#10126)
The `hasMore` property of timelines in redux store was set whenever an API
request returned only one page of results, *even* if the query only requested
newer conversations (using `since_id`), causing `hasMore` to be incorrectly set to
false whenever fetching new toots in the direct timeline, which happens each time
the direct message column is opened.

(Basically #9516 for direct messages)
2019-03-05 18:04:02 +01:00
trwnh
7a5095a53c Allow getting-started to scroll on short screens (#10075)
At 480px height, there is not enough space to fully display the footer.
2019-03-05 18:03:42 +01:00
ThibG
7f5b570688 Fix mention processing for unknwon accounts on incoming ActivityPub Notes (#10125)
`::FetchRemoteAccountService` is not `ActivityPub::FetchRemoteAccountService`,
its second argument is the pre-fetched body. Passing `id: false` actually passed
a `Hash` as the prefetched body, instead of properly resolving unknown remote
accounts.
2019-03-05 18:03:17 +01:00
abcang
aa63c3e22a Improve account media query (#10121) 2019-03-05 18:02:57 +01:00
ThibG
04197a2745 Avoid redundant HTTP request on some error cases (#10115) 2019-03-05 18:02:35 +01:00
Eugen Rochko
1219e463f9 Fix web UI not removing notifications after block (#10108)
Regression from #7311
2019-03-05 18:01:34 +01:00
Eugen Rochko
0359345147 Bump version to 2.7.3 2019-02-23 17:20:54 +01:00
ThibG
6df61bf9d2 Fix “reset” button of instance filter switching to custom emoji admin panel (#10076) 2019-02-23 17:19:36 +01:00
ThibG
80db9a74ef Add quick link from admin account view to block/unblock instance (#10073) 2019-02-23 17:19:21 +01:00
ThibG
3dd663c455 Add domain search/filter to the "Federation" (/admin/instances) page (#10071) 2019-02-23 17:18:50 +01:00
Hinaloe
07fffd226d Randomize emoji filename (#10090) 2019-02-23 17:17:09 +01:00
ThibG
890a606431 Do not error out when performing admin actions on no statuses (#10094)
Same as #8220 but for reports
2019-02-23 17:16:44 +01:00
ThibG
a750d1aa34 Fix crash when conversations have no valid participants (#10078)
* Never return empty participants for conversations

Fixes #10068

* Fix client-side crash when conversations have no participants
2019-02-23 17:15:58 +01:00
ThibG
d23a7f9726 Hide domain filter in admin page when “local” filter is active (#10074)
Since the “domain” field is ignored in this case.
2019-02-23 17:15:13 +01:00
ThibG
3e59a0e838 Fix video player width not being updated to fit container width (#10069) 2019-02-23 17:14:34 +01:00
Eugen Rochko
f3eb99aec3 Bump version to 2.7.2 2019-02-17 19:58:06 +01:00
ThibG
e5f4af23ef Fix crash on public hashtag pages when streaming fails (#10061) 2019-02-17 19:53:37 +01:00
Eugen Rochko
33e8fa0d76 Fix mutes, blocks, domain blocks and follow requests not paginating (#10057)
Regression from #9581
2019-02-17 19:53:19 +01:00
Eugen Rochko
98e38200ab Add vapid_key to the application entity in the REST API (#10058)
Fix #8785
2019-02-17 19:52:27 +01:00
Eugen Rochko
b6a5268e1b Add registrations attribute to instance entity in REST API (#10060)
Fix #9350
2019-02-17 19:51:00 +01:00
Eugen Rochko
caf1450292 Change error graphic to hover-to-play (#10055)
Fix #6060
2019-02-17 19:50:44 +01:00
Eugen Rochko
584f29e62a Change buttons on timeline preview to open the interaction dialog (#10054)
Fix #9922
2019-02-17 19:50:24 +01:00
Eugen Rochko
7b59de4f5c Change conversations to always show names of other participants (#10047)
Fix #9190
2019-02-17 19:49:59 +01:00
rinsuki
5aa147b67d Fix breaks when opening a reply tree in WebUI (#10046)
fix #10045
2019-02-17 19:49:38 +01:00
Nolan Lawson
77a71236ad perf: run node directly when streaming (#10032) 2019-02-17 19:48:44 +01:00
Ben Lubar
1ad0d232b3 Improve image description user experience (#10036)
* Add image descriptions to searchable post content.

* Allow multi-line image descriptions.

* Request image descriptions in the same query as posts when creating the search index.

(see https://github.com/tootsuite/mastodon/pull/10036#discussion_r256551624)
2019-02-17 19:47:49 +01:00
nightpool
45b2bb464b Change robots.txt to exclude only media proxy URLs (#10038)
* Revert "Change robots.txt to exclude some URLs (#10037)"

This reverts commit 80161f4351.

* Let's block media_proxy

/media_proxy/ is a dynamic route used for requesting uncached media, so it's
probably bad to let crawlers use it

* misleading comment
2019-02-17 19:47:17 +01:00
Eugen Rochko
637f0007b9 Change robots.txt to exclude some URLs (#10037)
- Exclude static assets
- Exclude uploaded files
- Exclude alternate versions of the profile page
- Exclude media proxy URLs
2019-02-17 19:47:06 +01:00
Eugen Rochko
8ad75eea62 Fix relay enabling/disabling not resetting inbox availability status (#10048)
Fix #10033
2019-02-17 19:46:27 +01:00
Eugen Rochko
b163368c3e Fix Announce activities of unknown statuses not fetching those statuses (#10065)
Regression from #9998
2019-02-17 19:45:54 +01:00
Eugen Rochko
71b831601d Add logging for rejected ActivityPub payloads and add tests (#10062) 2019-02-17 19:45:32 +01:00
Eugen Rochko
e84c761819 Filter incoming Announce activities by relation to local activity (#10041)
* Filter incoming Announce activities by relation to local activity

Reject if announcer is not followed by local accounts, and is not
from an enabled relay, and the object is not a local status

Follow-up to #10005

* Fix tests
2019-02-17 19:45:09 +01:00
Eugen Rochko
ef45411c53 Filter incoming Create activities by relation to local activity (#10005)
Reject those from accounts with no local followers, from relays
that are not enabled, which do not address local accounts and are
not replies to accounts that do have local followers
2019-02-17 19:43:44 +01:00
ThibG
6c11f0f8cf Alternative handling of private self-boosts (#9998)
* When self-boosting, embed original toot into Announce serialization

* Process unknown self-boosts from Announce object if it is more than an URI

* Add some self-boost specs

* Only serialize private toots in self-Announces
2019-02-17 19:42:18 +01:00
ysksn
737ac4b59d Create Redisable#redis (#9633)
* Create Redisable

* Use #redis instead of Redis.current
2019-02-17 19:42:14 +01:00
Eugen Rochko
17a41e1f77 Fix hashtag column not subscribing to stream on mount (#10040)
Fix #9895
2019-02-17 19:40:51 +01:00
Eugen Rochko
5a04861c7f Add tight rate-limit for API deletions (#10042)
Deletions take a lot of resources to execute and cause a lot of
federation traffic, so it makes sense to decrease the number
someone can queue up through the API.

30 per 30 minutes
2019-02-17 19:40:29 +01:00
Eugen Rochko
2a1adab7d7 Fix style regressions on landing page (#10030) 2019-02-17 19:39:35 +01:00
Eugen Rochko
a46487e895 Fix hashtags select styling in default and high contrast themes (#10029) 2019-02-17 19:39:03 +01:00
Eugen Rochko
f0f657e77c Fix color of static page links in high contrast theme (#10028) 2019-02-17 19:38:21 +01:00
ThibG
1186b9abeb Save IP address used for sign-up, not only sign-in (#10026)
Fixes #9995
2019-02-17 19:37:06 +01:00
Franck Zoccolo
27310a84a4 Add support for IPv6 only MXes in Email validation (#10009)
* Add support for IPv6 only MXes

* Fixed email validator tests
2019-02-17 19:36:10 +01:00
ThibG
d66267508a Move sending account Delete to anyone but the account's followers to the pull̀ queue (#10016) 2019-02-17 19:35:05 +01:00
Hinaloe
41ecf80645 Don't focus spiler input when disabled spoiler (#10017) 2019-02-17 19:34:01 +01:00
ThibG
e1dbdf7377 Fix timeline jumps (#10001)
* Avoid two-step rendering of statuses as much as possible

Cache width shared by Video player, MediaGallery and Cards at the
ScrollableList level, pass it down through StatusList and Notifications.

* Adjust scroll when new preview cards appear

* Adjust scroll when statuses above the current scroll position are deleted
2019-02-17 19:33:11 +01:00
ThibG
d9f0c7fb84 Fix IntersectionObserverArticle not hiding some out-of-view items (#9982)
IntersectionObserverArticle is made to save on RAM by avoiding fully rendering
items that are far out of view. However, it did not work for items spawned
outside the intersection observer.
2019-02-17 19:32:55 +01:00
Eugen Rochko
6ea4cd5b86 Fix URL linkifier grabbing full-width spaces and quotations (#9997)
Fix #9993
Fix #5654
2019-02-17 19:29:40 +01:00
Hinaloe
2a7c091eae Only URLs extract with pre-escaped text (#9991)
* [test] add japanese hashtag testcase

* Only URLs extract with pre-escaped text

( https://github.com/tootsuite/mastodon/issues/9989 )
2019-02-17 19:29:14 +01:00
abcang
e2afe5fdfb Fix Tombstone.delete_all ArgumentError (#9978) 2019-02-17 19:28:21 +01:00
ThibG
edde07f5ab Hide misleading “You will be sent a confirmation e-mail” hint from admin view (#9973)
Thanks @wryk for noticing this issue.
2019-02-17 19:27:38 +01:00
trwnh
cd36ff43fd [UI] Fix whitespace being applied to div instead of p (#9968)
* fix large line breaks

* fix ascii art posts
2019-02-17 19:26:52 +01:00
rinsuki
5e7c75cfd3 Fix not showing custom emojis in share page emoji picker (#9970) 2019-02-17 19:26:26 +01:00
rinsuki
a742a09530 Fix authorized applications list page design (#9969) 2019-02-17 19:25:55 +01:00
Jakub Mendyk
fdf819b83e Allow most kinds of characters in URL query (fixes #8408) (#8447)
* Allow unicode characters in URL query strings

Fixes #8408

* Alternative approach to unicode support in urls

Adds PoC/idea to approch this problem.
2019-02-17 19:24:48 +01:00
Clar Charr
687a0cbcb0 Replace unlock-alt icon with unlock (#9952) 2019-02-17 19:23:59 +01:00
Eugen Rochko
e31970b924 Fix link color in high-contrast theme, add underlines (#9949)
Improve sorting of default themes in the dropdown
2019-02-17 19:22:16 +01:00
Sam Schlinkert
88a1d0cdb4 Bumps copyright year in README.md to 2019 (#9939)
This is so incredibly small, but assuming this is a needed change. Might want to check year in other files.
2019-02-17 19:21:46 +01:00
95 changed files with 1538 additions and 598 deletions

View file

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

View file

@ -3,6 +3,78 @@ Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [2.7.4] - 2019-03-05
### Fixed
- Fix web UI not cleaning up notifications after block ([Gargron](https://github.com/tootsuite/mastodon/pull/10108))
- Fix redundant HTTP requests when resolving private statuses ([ThibG](https://github.com/tootsuite/mastodon/pull/10115))
- Fix performance of account media query ([abcang](https://github.com/tootsuite/mastodon/pull/10121))
- Fix mention processing for unknown accounts ([ThibG](https://github.com/tootsuite/mastodon/pull/10125))
- Fix getting started column not scrolling on short screens ([trwnh](https://github.com/tootsuite/mastodon/pull/10075))
- Fix direct messages pagination in the web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10126))
- Fix serialization of Announce activities ([ThibG](https://github.com/tootsuite/mastodon/pull/10129))
- Fix home timeline perpetually reloading when empty in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10130))
- Fix lists export ([ThibG](https://github.com/tootsuite/mastodon/pull/10136))
- Fix edit profile page crash for suspended-then-unsuspended users ([ThibG](https://github.com/tootsuite/mastodon/pull/10178))
## [2.7.3] - 2019-02-23
### Added
- Add domain filter to the admin federation page ([ThibG](https://github.com/tootsuite/mastodon/pull/10071))
- Add quick link from admin account view to block/unblock instance ([ThibG](https://github.com/tootsuite/mastodon/pull/10073))
### Fixed
- Fix video player width not being updated to fit container width ([ThibG](https://github.com/tootsuite/mastodon/pull/10069))
- Fix domain filter being shown in admin page when local filter is active ([ThibG](https://github.com/tootsuite/mastodon/pull/10074))
- Fix crash when conversations have no valid participants ([ThibG](https://github.com/tootsuite/mastodon/pull/10078))
- Fix error when performing admin actions on no statuses ([ThibG](https://github.com/tootsuite/mastodon/pull/10094))
### Changed
- Change custom emojis to randomize stored file name ([hinaloe](https://github.com/tootsuite/mastodon/pull/10090))
## [2.7.2] - 2019-02-17
### Added
- Add support for IPv6 in e-mail validation ([zoc](https://github.com/tootsuite/mastodon/pull/10009))
- Add record of IP address used for signing up ([ThibG](https://github.com/tootsuite/mastodon/pull/10026))
- Add tight rate-limit for API deletions (30 per 30 minutes) ([Gargron](https://github.com/tootsuite/mastodon/pull/10042))
- Add support for embedded `Announce` objects attributed to the same actor ([ThibG](https://github.com/tootsuite/mastodon/pull/9998), [Gargron](https://github.com/tootsuite/mastodon/pull/10065))
- Add spam filter for `Create` and `Announce` activities ([Gargron](https://github.com/tootsuite/mastodon/pull/10005), [Gargron](https://github.com/tootsuite/mastodon/pull/10041), [Gargron](https://github.com/tootsuite/mastodon/pull/10062))
- Add `registrations` attribute to `GET /api/v1/instance` ([Gargron](https://github.com/tootsuite/mastodon/pull/10060))
- Add `vapid_key` to `POST /api/v1/apps` and `GET /api/v1/apps/verify_credentials` ([Gargron](https://github.com/tootsuite/mastodon/pull/10058))
### Fixed
- Fix link color and add link underlines in high-contrast theme ([Gargron](https://github.com/tootsuite/mastodon/pull/9949), [Gargron](https://github.com/tootsuite/mastodon/pull/10028))
- Fix unicode characters in URLs not being linkified ([JMendyk](https://github.com/tootsuite/mastodon/pull/8447), [hinaloe](https://github.com/tootsuite/mastodon/pull/9991))
- Fix URLs linkifier grabbing ending quotation as part of the link ([Gargron](https://github.com/tootsuite/mastodon/pull/9997))
- Fix authorized applications page design ([rinsuki](https://github.com/tootsuite/mastodon/pull/9969))
- Fix custom emojis not showing up in share page emoji picker ([rinsuki](https://github.com/tootsuite/mastodon/pull/9970))
- Fix too liberal application of whitespace in toots ([trwnh](https://github.com/tootsuite/mastodon/pull/9968))
- Fix misleading e-mail hint being displayed in admin view ([ThibG](https://github.com/tootsuite/mastodon/pull/9973))
- Fix tombstones not being cleared out ([abcang](https://github.com/tootsuite/mastodon/pull/9978))
- Fix some timeline jumps ([ThibG](https://github.com/tootsuite/mastodon/pull/9982), [ThibG](https://github.com/tootsuite/mastodon/pull/10001), [rinsuki](https://github.com/tootsuite/mastodon/pull/10046))
- Fix content warning input taking keyboard focus even when hidden ([hinaloe](https://github.com/tootsuite/mastodon/pull/10017))
- Fix hashtags select styling in default and high-contrast themes ([Gargron](https://github.com/tootsuite/mastodon/pull/10029))
- Fix style regressions on landing page ([Gargron](https://github.com/tootsuite/mastodon/pull/10030))
- Fix hashtag column not subscribing to stream on mount ([Gargron](https://github.com/tootsuite/mastodon/pull/10040))
- Fix relay enabling/disabling not resetting inbox availability status ([Gargron](https://github.com/tootsuite/mastodon/pull/10048))
- Fix mutes, blocks, domain blocks and follow requests not paginating ([Gargron](https://github.com/tootsuite/mastodon/pull/10057))
- Fix crash on public hashtag pages when streaming fails ([ThibG](https://github.com/tootsuite/mastodon/pull/10061))
### Changed
- Change icon for unlisted visibility level ([clarcharr](https://github.com/tootsuite/mastodon/pull/9952))
- Change queue of actor deletes from push to pull for non-follower recipients ([ThibG](https://github.com/tootsuite/mastodon/pull/10016))
- Change robots.txt to exclude media proxy URLs ([nightpool](https://github.com/tootsuite/mastodon/pull/10038))
- Change upload description input to allow line breaks ([BenLubar](https://github.com/tootsuite/mastodon/pull/10036))
- Change `dist/mastodon-streaming.service` to recommend running node without intermediary npm command ([nolanlawson](https://github.com/tootsuite/mastodon/pull/10032))
- Change conversations to always show names of other participants ([Gargron](https://github.com/tootsuite/mastodon/pull/10047))
- Change buttons on timeline preview to open the interaction dialog ([Gargron](https://github.com/tootsuite/mastodon/pull/10054))
- Change error graphic to hover-to-play ([Gargron](https://github.com/tootsuite/mastodon/pull/10055))
## [2.7.1] - 2019-01-28 ## [2.7.1] - 2019-01-28
### Fixed ### Fixed

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -50,9 +50,9 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
# Also, Avoid getting slow by not narrowing down by `statuses.account_id`. # Also, Avoid getting slow by not narrowing down by `statuses.account_id`.
# When narrowing down by `statuses.account_id`, `index_statuses_20180106` will be used # When narrowing down by `statuses.account_id`, `index_statuses_20180106` will be used
# and the table will be joined by `Merge Semi Join`, so the query will be slow. # and the table will be joined by `Merge Semi Join`, so the query will be slow.
Status.joins(:media_attachments).merge(@account.media_attachments).permitted_for(@account, current_account) @account.statuses.joins(:media_attachments).merge(@account.media_attachments).permitted_for(@account, current_account)
.paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) .paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
.reorder(id: :desc).distinct(:id).pluck(:id) .reorder(id: :desc).distinct(:id).pluck(:id)
end end
def pinned_scope def pinned_scope

View file

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

View file

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

View file

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

View file

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

View file

@ -68,6 +68,7 @@ module JsonLdHelper
return body_to_json(response.body_with_limit) if response.code == 200 return body_to_json(response.body_with_limit) if response.code == 200
end end
# If request failed, retry without doing it on behalf of a user # If request failed, retry without doing it on behalf of a user
return if on_behalf_of.nil?
build_request(uri).perform do |response| build_request(uri).perform do |response|
response.code == 200 ? body_to_json(response.body_with_limit) : nil response.code == 200 ? body_to_json(response.body_with_limit) : nil
end end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -194,6 +194,8 @@ class MediaGallery extends React.PureComponent {
height: PropTypes.number.isRequired, height: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired, onOpenMedia: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
}; };
static defaultProps = { static defaultProps = {
@ -202,6 +204,7 @@ class MediaGallery extends React.PureComponent {
state = { state = {
visible: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all', visible: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all',
width: this.props.defaultWidth,
}; };
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
@ -221,6 +224,7 @@ class MediaGallery extends React.PureComponent {
handleRef = (node) => { handleRef = (node) => {
if (node /*&& this.isStandaloneEligible()*/) { if (node /*&& this.isStandaloneEligible()*/) {
// offsetWidth triggers a layout, so only calculate when we need to // offsetWidth triggers a layout, so only calculate when we need to
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
this.setState({ this.setState({
width: node.offsetWidth, width: node.offsetWidth,
}); });
@ -233,8 +237,10 @@ class MediaGallery extends React.PureComponent {
} }
render () { render () {
const { media, intl, sensitive, height } = this.props; const { media, intl, sensitive, height, defaultWidth } = this.props;
const { width, visible } = this.state; const { visible } = this.state;
const width = this.state.width || defaultWidth;
let children; let children;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
isPartial: state.getIn(['timelines', 'home', 'items', 0], null) === null, isPartial: state.getIn(['timelines', 'home', 'isPartial']),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -35,7 +35,7 @@ const updateConversation = (state, item) => state.update('items', list => {
} }
}); });
const expandNormalizedConversations = (state, conversations, next) => { const expandNormalizedConversations = (state, conversations, next, isLoadingRecent) => {
let items = ImmutableList(conversations.map(conversationToMap)); let items = ImmutableList(conversations.map(conversationToMap));
return state.withMutations(mutable => { return state.withMutations(mutable => {
@ -66,7 +66,7 @@ const expandNormalizedConversations = (state, conversations, next) => {
}); });
} }
if (!next) { if (!next && !isLoadingRecent) {
mutable.set('hasMore', false); mutable.set('hasMore', false);
} }
@ -81,7 +81,7 @@ export default function conversations(state = initialState, action) {
case CONVERSATIONS_FETCH_FAIL: case CONVERSATIONS_FETCH_FAIL:
return state.set('isLoading', false); return state.set('isLoading', false);
case CONVERSATIONS_FETCH_SUCCESS: case CONVERSATIONS_FETCH_SUCCESS:
return expandNormalizedConversations(state, action.conversations, action.next); return expandNormalizedConversations(state, action.conversations, action.next, action.isLoadingRecent);
case CONVERSATIONS_UPDATE: case CONVERSATIONS_UPDATE:
return updateConversation(state, action.conversation); return updateConversation(state, action.conversation);
case CONVERSATIONS_MOUNT: case CONVERSATIONS_MOUNT:

View file

@ -108,6 +108,7 @@ export default function notifications(state = initialState, action) {
case NOTIFICATIONS_EXPAND_SUCCESS: case NOTIFICATIONS_EXPAND_SUCCESS:
return expandNormalizedNotifications(state, action.notifications, action.next); return expandNormalizedNotifications(state, action.notifications, action.next);
case ACCOUNT_BLOCK_SUCCESS: case ACCOUNT_BLOCK_SUCCESS:
return filterNotifications(state, action.relationship);
case ACCOUNT_MUTE_SUCCESS: case ACCOUNT_MUTE_SUCCESS:
return action.relationship.muting_notifications ? filterNotifications(state, action.relationship) : state; return action.relationship.muting_notifications ? filterNotifications(state, action.relationship) : state;
case NOTIFICATIONS_CLEAR: case NOTIFICATIONS_CLEAR:

View file

@ -29,6 +29,8 @@ const initialTimeline = ImmutableMap({
const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent) => { const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent) => {
return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
mMap.set('isLoading', false); mMap.set('isLoading', false);
mMap.set('isPartial', isPartial);
if (!next && !isLoadingRecent) mMap.set('hasMore', false); if (!next && !isLoadingRecent) mMap.set('hasMore', false);
if (!statuses.isEmpty()) { if (!statuses.isEmpty()) {

View file

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

View file

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

View file

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

View file

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

View file

@ -100,12 +100,14 @@ body {
vertical-align: middle; vertical-align: middle;
margin: 20px; margin: 20px;
img { &__illustration {
display: block; img {
max-width: 470px; display: block;
width: 100%; max-width: 470px;
height: auto; width: 100%;
margin-top: -120px; height: auto;
margin-top: -120px;
}
} }
h1 { h1 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -240,6 +240,7 @@ class Account < ApplicationRecord
def fields_attributes=(attributes) def fields_attributes=(attributes)
fields = [] fields = []
old_fields = self[:fields] || [] old_fields = self[:fields] || []
old_fields = [] if old_fields.is_a?(Hash)
if attributes.is_a?(Hash) if attributes.is_a?(Hash)
attributes.each_value do |attr| attributes.each_value do |attr|

View file

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

View file

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

View file

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

View file

@ -22,7 +22,7 @@ class Export
def to_lists_csv def to_lists_csv
CSV.generate do |csv| CSV.generate do |csv|
account.owned_lists.select(:title).each do |list| account.owned_lists.select(:title, :id).each do |list|
list.accounts.select(:username, :domain).each do |account| list.accounts.select(:username, :domain).each do |account|
csv << [list.title, acct(account)] csv << [list.title, acct(account)]
end end

View file

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

View file

@ -9,9 +9,13 @@ class InstanceFilter
def results def results
if params[:limited].present? if params[:limited].present?
DomainBlock.order(id: :desc) scope = DomainBlock
scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present?
scope.order(id: :desc)
else else
Account.remote.by_domain_accounts scope = Account.remote
scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present?
scope.by_domain_accounts
end end
end end
end end

View file

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

View file

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

View file

@ -3,8 +3,8 @@
class ActivityPub::ActivitySerializer < ActiveModel::Serializer class ActivityPub::ActivitySerializer < ActiveModel::Serializer
attributes :id, :type, :actor, :published, :to, :cc attributes :id, :type, :actor, :published, :to, :cc
has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, unless: :announce? has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, if: :serialize_object?
attribute :proper_uri, key: :object, if: :announce? attribute :proper_uri, key: :object, unless: :serialize_object?
attribute :atom_uri, if: :announce? attribute :atom_uri, if: :announce?
def id def id
@ -42,4 +42,10 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer
def announce? def announce?
object.reblog? object.reblog?
end end
def serialize_object?
return true unless announce?
# Serialize private self-boosts of local toots
object.account == object.proper.account && object.proper.private_visibility? && object.local?
end
end end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -84,7 +84,7 @@ class SuspendAccountService < BaseService
@account.locked = false @account.locked = false
@account.display_name = '' @account.display_name = ''
@account.note = '' @account.note = ''
@account.fields = {} @account.fields = []
@account.statuses_count = 0 @account.statuses_count = 0
@account.followers_count = 0 @account.followers_count = 0
@account.following_count = 0 @account.following_count = 0
@ -102,6 +102,10 @@ class SuspendAccountService < BaseService
ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url| ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url|
[delete_actor_json, @account.id, inbox_url] [delete_actor_json, @account.id, inbox_url]
end end
ActivityPub::LowPriorityDeliveryWorker.push_bulk(low_priority_delivery_inboxes) do |inbox_url|
[delete_actor_json, @account.id, inbox_url]
end
end end
def delete_actor_json def delete_actor_json
@ -117,7 +121,11 @@ class SuspendAccountService < BaseService
end end
def delivery_inboxes def delivery_inboxes
Account.inboxes + Relay.enabled.pluck(:inbox_url) @delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url)
end
def low_priority_delivery_inboxes
Account.inboxes - delivery_inboxes
end end
def associations_for_destruction def associations_for_destruction

View file

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

View file

@ -26,8 +26,9 @@
= hidden_field_tag key, params[key] = hidden_field_tag key, params[key]
- %i(username by_domain display_name email ip).each do |key| - %i(username by_domain display_name email ip).each do |key|
.input.string.optional - unless key == :by_domain && params[:remote].blank?
= text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.accounts.#{key}") .input.string.optional
= text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.accounts.#{key}")
.actions .actions
%button= t('admin.accounts.search') %button= t('admin.accounts.search')

View file

@ -166,6 +166,12 @@
- else - else
= link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@account.id, type: 'suspend'), class: 'button button--destructive' if can?(:suspend, @account) = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@account.id, type: 'suspend'), class: 'button button--destructive' if can?(:suspend, @account)
- unless @account.local?
- if DomainBlock.where(domain: @account.domain).exists?
= link_to t('admin.domain_blocks.undo'), admin_instance_path(@account.domain), class: 'button'
- else
= link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @account.domain), class: 'button button--destructive'
%hr.spacer/ %hr.spacer/
- unless @warnings.empty? - unless @warnings.empty?

View file

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

View file

@ -11,6 +11,20 @@
%div{ style: 'flex: 1 1 auto; text-align: right' } %div{ style: 'flex: 1 1 auto; text-align: right' }
= link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path, class: 'button' = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path, class: 'button'
= form_tag admin_instances_url, method: 'GET', class: 'simple_form' do
.fields-group
- Admin::FilterHelper::INSTANCES_FILTERS.each do |key|
- if params[key].present?
= hidden_field_tag key, params[key]
- %i(by_domain).each do |key|
.input.string.optional
= text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.instances.#{key}")
.actions
%button= t('admin.accounts.search')
= link_to t('admin.accounts.reset'), admin_instances_path, class: 'button negative'
%hr.spacer/ %hr.spacer/
- @instances.each do |instance| - @instances.each do |instance|

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -302,6 +302,7 @@ en:
back_to_account: Back To Account back_to_account: Back To Account
title: "%{acct}'s Followers" title: "%{acct}'s Followers"
instances: instances:
by_domain: Domain
delivery_available: Delivery is available delivery_available: Delivery is available
known_accounts: known_accounts:
one: "%{count} known account" one: "%{count} known account"
@ -929,9 +930,9 @@ en:
<p>Originally adapted from the <a href="https://github.com/discourse/discourse">Discourse privacy policy</a>.</p> <p>Originally adapted from the <a href="https://github.com/discourse/discourse">Discourse privacy policy</a>.</p>
title: "%{instance} Terms of Service and Privacy Policy" title: "%{instance} Terms of Service and Privacy Policy"
themes: themes:
contrast: High contrast contrast: Mastodon (High contrast)
default: Mastodon default: Mastodon (Dark)
mastodon-light: Mastodon (light) mastodon-light: Mastodon (Light)
time: time:
formats: formats:
default: "%b %d, %Y, %H:%M" default: "%b %d, %Y, %H:%M"

View file

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

View file

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

BIN
public/oops.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

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

View file

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

View file

@ -13,8 +13,6 @@ RSpec.describe ActivityPub::Activity::Create do
}.with_indifferent_access }.with_indifferent_access
end end
subject { described_class.new(json, sender) }
before do before do
sender.update(uri: ActivityPub::TagManager.instance.uri_for(sender)) sender.update(uri: ActivityPub::TagManager.instance.uri_for(sender))
@ -23,11 +21,402 @@ RSpec.describe ActivityPub::Activity::Create do
end end
describe '#perform' do describe '#perform' do
before do context 'when fetching' do
subject.perform subject { described_class.new(json, sender) }
before do
subject.perform
end
context 'standalone' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.text).to eq 'Lorem ipsum'
end
it 'missing to/cc defaults to direct privacy' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.visibility).to eq 'direct'
end
end
context 'public' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
to: 'https://www.w3.org/ns/activitystreams#Public',
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.visibility).to eq 'public'
end
end
context 'unlisted' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
cc: 'https://www.w3.org/ns/activitystreams#Public',
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.visibility).to eq 'unlisted'
end
end
context 'private' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
to: 'http://example.com/followers',
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.visibility).to eq 'private'
end
end
context 'limited' do
let(:recipient) { Fabricate(:account) }
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
to: ActivityPub::TagManager.instance.uri_for(recipient),
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.visibility).to eq 'limited'
end
it 'creates silent mention' do
status = sender.statuses.first
expect(status.mentions.first).to be_silent
end
end
context 'direct' do
let(:recipient) { Fabricate(:account) }
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
to: ActivityPub::TagManager.instance.uri_for(recipient),
tag: {
type: 'Mention',
href: ActivityPub::TagManager.instance.uri_for(recipient),
},
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.visibility).to eq 'direct'
end
end
context 'as a reply' do
let(:original_status) { Fabricate(:status) }
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
inReplyTo: ActivityPub::TagManager.instance.uri_for(original_status),
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.thread).to eq original_status
expect(status.reply?).to be true
expect(status.in_reply_to_account).to eq original_status.account
expect(status.conversation).to eq original_status.conversation
end
end
context 'with mentions' do
let(:recipient) { Fabricate(:account) }
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
tag: [
{
type: 'Mention',
href: ActivityPub::TagManager.instance.uri_for(recipient),
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.mentions.map(&:account)).to include(recipient)
end
end
context 'with mentions missing href' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
tag: [
{
type: 'Mention',
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
end
end
context 'with media attachments' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
attachment: [
{
type: 'Document',
mediaType: 'image/png',
url: 'http://example.com/attachment.png',
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.media_attachments.map(&:remote_url)).to include('http://example.com/attachment.png')
end
end
context 'with media attachments with focal points' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
attachment: [
{
type: 'Document',
mediaType: 'image/png',
url: 'http://example.com/attachment.png',
focalPoint: [0.5, -0.7],
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.media_attachments.map(&:focus)).to include('0.5,-0.7')
end
end
context 'with media attachments missing url' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
attachment: [
{
type: 'Document',
mediaType: 'image/png',
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
end
end
context 'with hashtags' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
tag: [
{
type: 'Hashtag',
href: 'http://example.com/blah',
name: '#test',
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.tags.map(&:name)).to include('test')
end
end
context 'with hashtags missing name' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
tag: [
{
type: 'Hashtag',
href: 'http://example.com/blah',
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
end
end
context 'with emojis' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum :tinking:',
tag: [
{
type: 'Emoji',
icon: {
url: 'http://example.com/emoji.png',
},
name: 'tinking',
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.emojis.map(&:shortcode)).to include('tinking')
end
end
context 'with emojis missing name' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum :tinking:',
tag: [
{
type: 'Emoji',
icon: {
url: 'http://example.com/emoji.png',
},
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
end
end
context 'with emojis missing icon' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum :tinking:',
tag: [
{
type: 'Emoji',
name: 'tinking',
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
end
end
end end
context 'standalone' do context 'when sender is followed by local users' do
subject { described_class.new(json, sender, delivery: true) }
before do
Fabricate(:account).follow!(sender)
subject.perform
end
let(:object_json) do let(:object_json) do
{ {
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
@ -42,78 +431,23 @@ RSpec.describe ActivityPub::Activity::Create do
expect(status).to_not be_nil expect(status).to_not be_nil
expect(status.text).to eq 'Lorem ipsum' expect(status.text).to eq 'Lorem ipsum'
end end
it 'missing to/cc defaults to direct privacy' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.visibility).to eq 'direct'
end
end end
context 'public' do context 'when sender replies to local status' do
let(:object_json) do let!(:local_status) { Fabricate(:status) }
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, subject { described_class.new(json, sender, delivery: true) }
type: 'Note',
content: 'Lorem ipsum', before do
to: 'https://www.w3.org/ns/activitystreams#Public', subject.perform
}
end end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.visibility).to eq 'public'
end
end
context 'unlisted' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
cc: 'https://www.w3.org/ns/activitystreams#Public',
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.visibility).to eq 'unlisted'
end
end
context 'private' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
to: 'http://example.com/followers',
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.visibility).to eq 'private'
end
end
context 'limited' do
let(:recipient) { Fabricate(:account) }
let(:object_json) do let(:object_json) do
{ {
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
to: ActivityPub::TagManager.instance.uri_for(recipient), inReplyTo: ActivityPub::TagManager.instance.uri_for(local_status),
} }
end end
@ -121,28 +455,25 @@ RSpec.describe ActivityPub::Activity::Create do
status = sender.statuses.first status = sender.statuses.first
expect(status).to_not be_nil expect(status).to_not be_nil
expect(status.visibility).to eq 'limited' expect(status.text).to eq 'Lorem ipsum'
end
it 'creates silent mention' do
status = sender.statuses.first
expect(status.mentions.first).to be_silent
end end
end end
context 'direct' do context 'when sender targets a local user' do
let(:recipient) { Fabricate(:account) } let!(:local_account) { Fabricate(:account) }
subject { described_class.new(json, sender, delivery: true) }
before do
subject.perform
end
let(:object_json) do let(:object_json) do
{ {
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
to: ActivityPub::TagManager.instance.uri_for(recipient), to: ActivityPub::TagManager.instance.uri_for(local_account),
tag: {
type: 'Mention',
href: ActivityPub::TagManager.instance.uri_for(recipient),
},
} }
end end
@ -150,19 +481,25 @@ RSpec.describe ActivityPub::Activity::Create do
status = sender.statuses.first status = sender.statuses.first
expect(status).to_not be_nil expect(status).to_not be_nil
expect(status.visibility).to eq 'direct' expect(status.text).to eq 'Lorem ipsum'
end end
end end
context 'as a reply' do context 'when sender cc\'s a local user' do
let(:original_status) { Fabricate(:status) } let!(:local_account) { Fabricate(:account) }
subject { described_class.new(json, sender, delivery: true) }
before do
subject.perform
end
let(:object_json) do let(:object_json) do
{ {
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
inReplyTo: ActivityPub::TagManager.instance.uri_for(original_status), cc: ActivityPub::TagManager.instance.uri_for(local_account),
} }
end end
@ -170,240 +507,27 @@ RSpec.describe ActivityPub::Activity::Create do
status = sender.statuses.first status = sender.statuses.first
expect(status).to_not be_nil expect(status).to_not be_nil
expect(status.thread).to eq original_status expect(status.text).to eq 'Lorem ipsum'
expect(status.reply?).to be true
expect(status.in_reply_to_account).to eq original_status.account
expect(status.conversation).to eq original_status.conversation
end end
end end
context 'with mentions' do context 'when the sender has no relevance to local activity' do
let(:recipient) { Fabricate(:account) } subject { described_class.new(json, sender, delivery: true) }
before do
subject.perform
end
let(:object_json) do let(:object_json) do
{ {
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note', type: 'Note',
content: 'Lorem ipsum', content: 'Lorem ipsum',
tag: [
{
type: 'Mention',
href: ActivityPub::TagManager.instance.uri_for(recipient),
},
],
} }
end end
it 'creates status' do it 'does not create anything' do
status = sender.statuses.first expect(sender.statuses.count).to eq 0
expect(status).to_not be_nil
expect(status.mentions.map(&:account)).to include(recipient)
end
end
context 'with mentions missing href' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
tag: [
{
type: 'Mention',
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
end
end
context 'with media attachments' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
attachment: [
{
type: 'Document',
mediaType: 'image/png',
url: 'http://example.com/attachment.png',
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.media_attachments.map(&:remote_url)).to include('http://example.com/attachment.png')
end
end
context 'with media attachments with focal points' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
attachment: [
{
type: 'Document',
mediaType: 'image/png',
url: 'http://example.com/attachment.png',
focalPoint: [0.5, -0.7],
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.media_attachments.map(&:focus)).to include('0.5,-0.7')
end
end
context 'with media attachments missing url' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
attachment: [
{
type: 'Document',
mediaType: 'image/png',
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
end
end
context 'with hashtags' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
tag: [
{
type: 'Hashtag',
href: 'http://example.com/blah',
name: '#test',
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.tags.map(&:name)).to include('test')
end
end
context 'with hashtags missing name' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
tag: [
{
type: 'Hashtag',
href: 'http://example.com/blah',
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
end
end
context 'with emojis' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum :tinking:',
tag: [
{
type: 'Emoji',
icon: {
url: 'http://example.com/emoji.png',
},
name: 'tinking',
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.emojis.map(&:shortcode)).to include('tinking')
end
end
context 'with emojis missing name' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum :tinking:',
tag: [
{
type: 'Emoji',
icon: {
url: 'http://example.com/emoji.png',
},
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
end
end
context 'with emojis missing icon' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum :tinking:',
tag: [
{
type: 'Emoji',
name: 'tinking',
},
],
}
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
end end
end end
end end

View file

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

View file

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