diff --git a/.env.production.sample b/.env.production.sample
index 2fbecc91a..417e91528 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -115,6 +115,20 @@ SMTP_FROM_ADDRESS=notifications@example.com
# S3_ENDPOINT=
# S3_SIGNATURE_VERSION=
+# Google Cloud Storage (optional)
+# Use S3 compatible API. Since GCS does not support Multipart Upload,
+# increase the value of S3_MULTIPART_THRESHOLD to disable Multipart Upload.
+# The attachment host must allow cross origin request - see the description
+# above.
+# S3_ENABLED=true
+# AWS_ACCESS_KEY_ID=
+# AWS_SECRET_ACCESS_KEY=
+# S3_REGION=
+# S3_PROTOCOL=https
+# S3_HOSTNAME=storage.googleapis.com
+# S3_ENDPOINT=https://storage.googleapis.com
+# S3_MULTIPART_THRESHOLD=52428801 # 50.megabytes
+
# Swift (optional)
# The attachment host must allow cross origin request - see the description
# above.
diff --git a/.ruby-version b/.ruby-version
index 6a6a3d8e3..2714f5313 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-2.6.1
+2.6.4
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 539fec531..4e9ccdc8c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,239 @@ Changelog
All notable changes to this project will be documented in this file.
+## Unreleased
+
+### Added
+
+- Add "not available" label to unloaded media attachments in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11715), [Gargron](https://github.com/tootsuite/mastodon/pull/11745))
+- **Add profile directory to web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11688), [mayaeh](https://github.com/tootsuite/mastodon/pull/11872))
+ - Add profile directory opt-in federation
+ - Add profile directory REST API
+- Add special alert for throttled requests in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11677))
+- Add confirmation modal when logging out from the web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11671))
+- **Add audio player in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11644), [Gargron](https://github.com/tootsuite/mastodon/pull/11652), [Gargron](https://github.com/tootsuite/mastodon/pull/11654), [ThibG](https://github.com/tootsuite/mastodon/pull/11629))
+- **Add autosuggestions for hashtags in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11422), [ThibG](https://github.com/tootsuite/mastodon/pull/11632), [Gargron](https://github.com/tootsuite/mastodon/pull/11764), [Gargron](https://github.com/tootsuite/mastodon/pull/11588), [Gargron](https://github.com/tootsuite/mastodon/pull/11442))
+- **Add media editing modal with OCR tool in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11563), [Gargron](https://github.com/tootsuite/mastodon/pull/11566), [ThibG](https://github.com/tootsuite/mastodon/pull/11575), [ThibG](https://github.com/tootsuite/mastodon/pull/11576), [Gargron](https://github.com/tootsuite/mastodon/pull/11577), [Gargron](https://github.com/tootsuite/mastodon/pull/11573), [Gargron](https://github.com/tootsuite/mastodon/pull/11571))
+- Add indicator of unread notifications to window title when web UI is out of focus ([Gargron](https://github.com/tootsuite/mastodon/pull/11560), [Gargron](https://github.com/tootsuite/mastodon/pull/11572))
+- Add indicator for which options you voted for in a poll in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11195))
+- **Add search results pagination to web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11409), [ThibG](https://github.com/tootsuite/mastodon/pull/11447))
+- **Add option to disable real-time updates in web UI ("slow mode")** ([Gargron](https://github.com/tootsuite/mastodon/pull/9984), [ykzts](https://github.com/tootsuite/mastodon/pull/11880), [ThibG](https://github.com/tootsuite/mastodon/pull/11883), [Gargron](https://github.com/tootsuite/mastodon/pull/11898), [ThibG](https://github.com/tootsuite/mastodon/pull/11859))
+- Add option to disable blurhash previews in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11188))
+- Add native smooth scrolling when supported in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11207))
+- Add search and sort functions to hashtag admin UI ([mayaeh](https://github.com/tootsuite/mastodon/pull/11829), [Gargron](https://github.com/tootsuite/mastodon/pull/11897), [mayaeh](https://github.com/tootsuite/mastodon/pull/11875))
+- Add setting for default search engine indexing in admin UI ([brortao](https://github.com/tootsuite/mastodon/pull/11804))
+- Add account bio to account view in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11473))
+- **Add option to include reported statuses in warning e-mail from admin UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11639), [Gargron](https://github.com/tootsuite/mastodon/pull/11812), [Gargron](https://github.com/tootsuite/mastodon/pull/11741), [Gargron](https://github.com/tootsuite/mastodon/pull/11698), [mayaeh](https://github.com/tootsuite/mastodon/pull/11765))
+- Add number of pending accounts and pending hashtags to dashboard in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11514))
+- **Add account migration UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11846), [noellabo](https://github.com/tootsuite/mastodon/pull/11905), [noellabo](https://github.com/tootsuite/mastodon/pull/11907), [noellabo](https://github.com/tootsuite/mastodon/pull/11906), [noellabo](https://github.com/tootsuite/mastodon/pull/11902))
+- **Add table of contents to about page** ([Gargron](https://github.com/tootsuite/mastodon/pull/11885), [ykzts](https://github.com/tootsuite/mastodon/pull/11941), [ykzts](https://github.com/tootsuite/mastodon/pull/11895), [Kjwon15](https://github.com/tootsuite/mastodon/pull/11916))
+- **Add password challenge to 2FA settings, e-mail notifications** ([Gargron](https://github.com/tootsuite/mastodon/pull/11878))
+- Add optional invite comments ([ThibG](https://github.com/tootsuite/mastodon/pull/10465))
+- **Add optional public list of domain blocks with comments** ([ThibG](https://github.com/tootsuite/mastodon/pull/11298), [ThibG](https://github.com/tootsuite/mastodon/pull/11515), [Gargron](https://github.com/tootsuite/mastodon/pull/11908))
+- Add an RSS feed for featured hashtags ([noellabo](https://github.com/tootsuite/mastodon/pull/10502))
+- Add explanations to featured hashtags UI and profile ([Gargron](https://github.com/tootsuite/mastodon/pull/11586))
+- **Add hashtag trends with admin and user settings** ([Gargron](https://github.com/tootsuite/mastodon/pull/11490), [Gargron](https://github.com/tootsuite/mastodon/pull/11502), [Gargron](https://github.com/tootsuite/mastodon/pull/11641), [Gargron](https://github.com/tootsuite/mastodon/pull/11594), [Gargron](https://github.com/tootsuite/mastodon/pull/11517), [mayaeh](https://github.com/tootsuite/mastodon/pull/11845), [Gargron](https://github.com/tootsuite/mastodon/pull/11774), [Gargron](https://github.com/tootsuite/mastodon/pull/11712), [Gargron](https://github.com/tootsuite/mastodon/pull/11791), [Gargron](https://github.com/tootsuite/mastodon/pull/11743), [Gargron](https://github.com/tootsuite/mastodon/pull/11740), [Gargron](https://github.com/tootsuite/mastodon/pull/11714), [ThibG](https://github.com/tootsuite/mastodon/pull/11631), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/11569), [Gargron](https://github.com/tootsuite/mastodon/pull/11524), [Gargron](https://github.com/tootsuite/mastodon/pull/11513))
+ - Add hashtag usage breakdown to admin UI
+ - Add batch actions for hashtags to admin UI
+ - Add trends to web UI
+ - Add trends to public pages
+ - Add user preference to hide trends
+ - Add admin setting to disable trends
+- **Add categories for custom emojis** ([Gargron](https://github.com/tootsuite/mastodon/pull/11196), [Gargron](https://github.com/tootsuite/mastodon/pull/11793), [Gargron](https://github.com/tootsuite/mastodon/pull/11920), [highemerly](https://github.com/tootsuite/mastodon/pull/11876))
+ - Add custom emoji categories to emoji picker in web UI
+ - Add `category` to custom emojis in REST API
+ - Add batch actions for custom emojis in admin UI
+- Add max image dimensions to error message ([raboof](https://github.com/tootsuite/mastodon/pull/11552))
+- Add aac, m4a, 3gp, amr, wma to allowed audio formats ([Gargron](https://github.com/tootsuite/mastodon/pull/11342), [umonaca](https://github.com/tootsuite/mastodon/pull/11687))
+- **Add search syntax for operators and phrases** ([Gargron](https://github.com/tootsuite/mastodon/pull/11411))
+- **Add REST API for managing featured hashtags** ([noellabo](https://github.com/tootsuite/mastodon/pull/11778))
+- **Add REST API for managing timeline read markers** ([Gargron](https://github.com/tootsuite/mastodon/pull/11762))
+- Add `exclude_unreviewed` param to `GET /api/v2/search` REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/11977))
+- **Add ActivityPub secure mode** ([Gargron](https://github.com/tootsuite/mastodon/pull/11269), [ThibG](https://github.com/tootsuite/mastodon/pull/11332), [ThibG](https://github.com/tootsuite/mastodon/pull/11295))
+- Add HTTP signatures to all outgoing ActivityPub GET requests ([Gargron](https://github.com/tootsuite/mastodon/pull/11284), [ThibG](https://github.com/tootsuite/mastodon/pull/11300))
+- Add support for ActivityPub Audio activities ([ThibG](https://github.com/tootsuite/mastodon/pull/11189))
+- Add ActivityPub actor representing the entire server ([ThibG](https://github.com/tootsuite/mastodon/pull/11321), [rtucker](https://github.com/tootsuite/mastodon/pull/11400), [ThibG](https://github.com/tootsuite/mastodon/pull/11561), [Gargron](https://github.com/tootsuite/mastodon/pull/11798))
+- **Add whitelist mode** ([Gargron](https://github.com/tootsuite/mastodon/pull/11291), [mayaeh](https://github.com/tootsuite/mastodon/pull/11634))
+- Add config of multipart threshold for S3 ([ykzts](https://github.com/tootsuite/mastodon/pull/11924), [ykzts](https://github.com/tootsuite/mastodon/pull/11944))
+- Add health check endpoint for web ([ykzts](https://github.com/tootsuite/mastodon/pull/11770), [ykzts](https://github.com/tootsuite/mastodon/pull/11947))
+- Add HTTP signature keyId to request log ([Gargron](https://github.com/tootsuite/mastodon/pull/11591))
+- Add `SMTP_REPLY_TO` environment variable ([hugogameiro](https://github.com/tootsuite/mastodon/pull/11718))
+- Add `tootctl preview_cards remove` command ([mayaeh](https://github.com/tootsuite/mastodon/pull/11320))
+- Add `tootctl media refresh` command ([Gargron](https://github.com/tootsuite/mastodon/pull/11775))
+- Add `tootctl cache recount` command ([Gargron](https://github.com/tootsuite/mastodon/pull/11597))
+- Add option to exclude suspended domains from `tootctl domains crawl` ([dariusk](https://github.com/tootsuite/mastodon/pull/11454))
+- Add soft delete for statuses for instant deletes through API ([Gargron](https://github.com/tootsuite/mastodon/pull/11623), [Gargron](https://github.com/tootsuite/mastodon/pull/11648))
+- Add rails-level JSON caching ([Gargron](https://github.com/tootsuite/mastodon/pull/11333), [Gargron](https://github.com/tootsuite/mastodon/pull/11271))
+- **Add request pool to improve delivery performance** ([Gargron](https://github.com/tootsuite/mastodon/pull/10353), [ykzts](https://github.com/tootsuite/mastodon/pull/11756))
+- Add concurrent connection attempts to resolved IP addresses ([ThibG](https://github.com/tootsuite/mastodon/pull/11757))
+- Add index for remember_token to improve login performance ([abcang](https://github.com/tootsuite/mastodon/pull/11881))
+- **Add more accurate hashtag search** ([Gargron](https://github.com/tootsuite/mastodon/pull/11579), [Gargron](https://github.com/tootsuite/mastodon/pull/11427), [Gargron](https://github.com/tootsuite/mastodon/pull/11448))
+- **Add more accurate account search** ([Gargron](https://github.com/tootsuite/mastodon/pull/11537), [Gargron](https://github.com/tootsuite/mastodon/pull/11580))
+- **Add a spam check** ([Gargron](https://github.com/tootsuite/mastodon/pull/11217), [Gargron](https://github.com/tootsuite/mastodon/pull/11806), [ThibG](https://github.com/tootsuite/mastodon/pull/11296))
+
+### Changed
+
+- **Change conversations UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11896))
+- Change dashboard to short number notation ([noellabo](https://github.com/tootsuite/mastodon/pull/11847), [noellabo](https://github.com/tootsuite/mastodon/pull/11911))
+- Change REST API `GET /api/v1/timelines/public` to require authentication when public preview is off ([ThibG](https://github.com/tootsuite/mastodon/pull/11802))
+- Change REST API `POST /api/v1/follow_requests/:id/(approve|reject)` to return relationship ([ThibG](https://github.com/tootsuite/mastodon/pull/11800))
+- Change rate limit for media proxy ([ykzts](https://github.com/tootsuite/mastodon/pull/11814))
+- Change unlisted custom emoji to not appear in autosuggestions ([Gargron](https://github.com/tootsuite/mastodon/pull/11818))
+- Change max length of media descriptions from 420 to 1500 characters ([Gargron](https://github.com/tootsuite/mastodon/pull/11819), [ThibG](https://github.com/tootsuite/mastodon/pull/11836))
+- **Change deletes to preserve soft-deleted statuses in unresolved reports** ([Gargron](https://github.com/tootsuite/mastodon/pull/11805))
+- **Change tootctl to use inline parallelization instead of Sidekiq** ([Gargron](https://github.com/tootsuite/mastodon/pull/11776))
+- **Change account deletion page to have better explanations** ([Gargron](https://github.com/tootsuite/mastodon/pull/11753), [Gargron](https://github.com/tootsuite/mastodon/pull/11763))
+- Change hashtag component in web UI to show numbers for 2 last days ([Gargron](https://github.com/tootsuite/mastodon/pull/11742), [Gargron](https://github.com/tootsuite/mastodon/pull/11755), [Gargron](https://github.com/tootsuite/mastodon/pull/11754))
+- Change OpenGraph description on sign-up page to reflect invite ([Gargron](https://github.com/tootsuite/mastodon/pull/11744))
+- Change layout of public profile directory to be the same as in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11705))
+- Change detailed status child ordering to sort self-replies on top ([ThibG](https://github.com/tootsuite/mastodon/pull/11686))
+- Change window resize handler to switch to/from mobile layout as soon as needed ([ThibG](https://github.com/tootsuite/mastodon/pull/11656))
+- Change icon button styles to make hover/focus states more obvious ([ThibG](https://github.com/tootsuite/mastodon/pull/11474))
+- Change contrast of status links that are not mentions or hashtags ([ThibG](https://github.com/tootsuite/mastodon/pull/11406))
+- **Change hashtags to preserve first-used casing** ([Gargron](https://github.com/tootsuite/mastodon/pull/11416), [Gargron](https://github.com/tootsuite/mastodon/pull/11508), [Gargron](https://github.com/tootsuite/mastodon/pull/11504), [Gargron](https://github.com/tootsuite/mastodon/pull/11507), [Gargron](https://github.com/tootsuite/mastodon/pull/11441))
+- **Change unconfirmed user login behaviour** ([Gargron](https://github.com/tootsuite/mastodon/pull/11375), [ThibG](https://github.com/tootsuite/mastodon/pull/11394), [Gargron](https://github.com/tootsuite/mastodon/pull/11860))
+- **Change single-column mode to scroll the whole page** ([Gargron](https://github.com/tootsuite/mastodon/pull/11359), [Gargron](https://github.com/tootsuite/mastodon/pull/11894), [Gargron](https://github.com/tootsuite/mastodon/pull/11891), [ThibG](https://github.com/tootsuite/mastodon/pull/11655), [Gargron](https://github.com/tootsuite/mastodon/pull/11463), [Gargron](https://github.com/tootsuite/mastodon/pull/11458), [ThibG](https://github.com/tootsuite/mastodon/pull/11395), [Gargron](https://github.com/tootsuite/mastodon/pull/11418))
+- Change `tootctl accounts follow` to only work with local accounts ([angristan](https://github.com/tootsuite/mastodon/pull/11592))
+- Change Dockerfile ([Shleeble](https://github.com/tootsuite/mastodon/pull/11710), [ykzts](https://github.com/tootsuite/mastodon/pull/11768), [Shleeble](https://github.com/tootsuite/mastodon/pull/11707))
+- Change supported Node versions to include v12 ([abcang](https://github.com/tootsuite/mastodon/pull/11706))
+- Change Portuguese language from `pt` to `pt-PT` ([Gargron](https://github.com/tootsuite/mastodon/pull/11820))
+- Change domain block silence to always require approval on follow ([ThibG](https://github.com/tootsuite/mastodon/pull/11975))
+
+### Removed
+
+- **Remove OStatus support** ([Gargron](https://github.com/tootsuite/mastodon/pull/11205), [Gargron](https://github.com/tootsuite/mastodon/pull/11303), [Gargron](https://github.com/tootsuite/mastodon/pull/11460), [ThibG](https://github.com/tootsuite/mastodon/pull/11280), [ThibG](https://github.com/tootsuite/mastodon/pull/11278))
+- Remove Atom feeds and old URLs in the form of `GET /:username/updates/:id` ([Gargron](https://github.com/tootsuite/mastodon/pull/11247))
+- Remove WebP support ([angristan](https://github.com/tootsuite/mastodon/pull/11589))
+- Remove deprecated config options from Heroku and Scalingo ([ykzts](https://github.com/tootsuite/mastodon/pull/11925))
+- Remove deprecated REST API `GET /api/v1/search` API ([Gargron](https://github.com/tootsuite/mastodon/pull/11823))
+- Remove deprecated REST API `GET /api/v1/statuses/:id/card` ([Gargron](https://github.com/tootsuite/mastodon/pull/11213))
+- Remove deprecated REST API `POST /api/v1/notifications/dismiss?id=:id` ([Gargron](https://github.com/tootsuite/mastodon/pull/11214))
+- Remove deprecated REST API `GET /api/v1/timelines/direct` ([Gargron](https://github.com/tootsuite/mastodon/pull/11212))
+
+### Fixed
+
+- Fix manifest warning ([ykzts](https://github.com/tootsuite/mastodon/pull/11767))
+- Fix admin UI for custom emoji not respecting GIF autoplay preference ([ThibG](https://github.com/tootsuite/mastodon/pull/11801))
+- Fix page body not being scrollable in admin/settings layout ([Gargron](https://github.com/tootsuite/mastodon/pull/11893))
+- Fix placeholder colors for inputs not being explicitly defined ([Gargron](https://github.com/tootsuite/mastodon/pull/11890))
+- Fix incorrect enclosure length in RSS ([tsia](https://github.com/tootsuite/mastodon/pull/11889))
+- Fix TOTP codes not being filtered from logs during enabling/disabling ([Gargron](https://github.com/tootsuite/mastodon/pull/11877))
+- Fix webfinger response not returning 410 when account is suspended ([Gargron](https://github.com/tootsuite/mastodon/pull/11869))
+- Fix ActivityPub Move handler queuing jobs that will fail if account is suspended ([Gargron](https://github.com/tootsuite/mastodon/pull/11864))
+- Fix SSO login not using existing account when e-mail is verified ([Gargron](https://github.com/tootsuite/mastodon/pull/11862))
+- Fix web UI allowing uploads past status limit via drag & drop ([Gargron](https://github.com/tootsuite/mastodon/pull/11863))
+- Fix expiring polls not being displayed as such in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11835))
+- Fix 2FA challenge and password challenge for non-database users ([Gargron](https://github.com/tootsuite/mastodon/pull/11831), [Gargron](https://github.com/tootsuite/mastodon/pull/11943))
+- Fix profile fields overflowing page width in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11828))
+- Fix web push subscriptions being deleted on rate limit or timeout ([Gargron](https://github.com/tootsuite/mastodon/pull/11826))
+- Fix display of long poll options in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11717), [ThibG](https://github.com/tootsuite/mastodon/pull/11833))
+- Fix search API not resolving URL when `type` is given ([Gargron](https://github.com/tootsuite/mastodon/pull/11822))
+- Fix hashtags being split by ZWNJ character ([Gargron](https://github.com/tootsuite/mastodon/pull/11821))
+- Fix scroll position resetting when opening media modals in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11815))
+- Fix duplicate HTML IDs on about page ([ThibG](https://github.com/tootsuite/mastodon/pull/11803))
+- Fix admin UI showing superfluous reject media/reports on suspended domain blocks ([ThibG](https://github.com/tootsuite/mastodon/pull/11749))
+- Fix ActivityPub context not being dynamically computed ([ThibG](https://github.com/tootsuite/mastodon/pull/11746))
+- Fix Mastodon logo style on hover on public pages' footer ([ThibG](https://github.com/tootsuite/mastodon/pull/11735))
+- Fix height of dashboard counters ([ThibG](https://github.com/tootsuite/mastodon/pull/11736))
+- Fix custom emoji animation on hover in web UI directory bios ([ThibG](https://github.com/tootsuite/mastodon/pull/11716))
+- Fix non-numbers being passed to Redis and causing an error ([Gargron](https://github.com/tootsuite/mastodon/pull/11697))
+- Fix error in REST API for an account's statuses ([Gargron](https://github.com/tootsuite/mastodon/pull/11700))
+- Fix uncaught error when resource param is missing in Webfinger request ([Gargron](https://github.com/tootsuite/mastodon/pull/11701))
+- Fix uncaught domain normalization error in remote follow ([Gargron](https://github.com/tootsuite/mastodon/pull/11703))
+- Fix uncaught 422 and 500 errors ([Gargron](https://github.com/tootsuite/mastodon/pull/11590), [Gargron](https://github.com/tootsuite/mastodon/pull/11811))
+- Fix uncaught parameter missing exceptions and missing error templates ([Gargron](https://github.com/tootsuite/mastodon/pull/11702))
+- Fix encoding error when checking e-mail MX records ([Gargron](https://github.com/tootsuite/mastodon/pull/11696))
+- Fix items in StatusContent render list not all having a key ([ThibG](https://github.com/tootsuite/mastodon/pull/11645))
+- Fix remote and staff-removed statuses leaving media behind for a day ([Gargron](https://github.com/tootsuite/mastodon/pull/11638))
+- Fix CSP needlessly allowing blob URLs in script-src ([ThibG](https://github.com/tootsuite/mastodon/pull/11620))
+- Fix ignoring whole status because of one invalid hashtag ([Gargron](https://github.com/tootsuite/mastodon/pull/11621))
+- Fix hidden statuses losing focus ([ThibG](https://github.com/tootsuite/mastodon/pull/11208))
+- Fix loading bar being obscured by other elements in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11598))
+- Fix multiple issues with replies collection for pages further than self-replies ([ThibG](https://github.com/tootsuite/mastodon/pull/11582))
+- Fix blurhash and autoplay not working on public pages ([Gargron](https://github.com/tootsuite/mastodon/pull/11585))
+- Fix 422 being returned instead of 404 when POSTing to unmatched routes ([Gargron](https://github.com/tootsuite/mastodon/pull/11574), [Gargron](https://github.com/tootsuite/mastodon/pull/11704))
+- Fix client-side resizing of image uploads ([ThibG](https://github.com/tootsuite/mastodon/pull/11570))
+- Fix short number formatting for numbers above million in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11559))
+- Fix ActivityPub and REST API queries setting cookies and preventing caching ([ThibG](https://github.com/tootsuite/mastodon/pull/11539), [ThibG](https://github.com/tootsuite/mastodon/pull/11557), [ThibG](https://github.com/tootsuite/mastodon/pull/11336), [ThibG](https://github.com/tootsuite/mastodon/pull/11331))
+- Fix some emojis in profile metadata labels are not emojified. ([kedamaDQ](https://github.com/tootsuite/mastodon/pull/11534))
+- Fix account search always returning exact match on paginated results ([Gargron](https://github.com/tootsuite/mastodon/pull/11525))
+- Fix acct URIs with IDN domains not being resolved ([Gargron](https://github.com/tootsuite/mastodon/pull/11520))
+- Fix admin dashboard missing latest features ([Gargron](https://github.com/tootsuite/mastodon/pull/11505))
+- Fix jumping of toot date when clicking spoiler button ([ariasuni](https://github.com/tootsuite/mastodon/pull/11449))
+- Fix boost to original audience not working on mobile in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11371))
+- Fix handling of webfinger redirects in ResolveAccountService ([ThibG](https://github.com/tootsuite/mastodon/pull/11279))
+- Fix URLs appearing twice in errors of ActivityPub::DeliveryWorker ([Gargron](https://github.com/tootsuite/mastodon/pull/11231))
+- Fix support for HTTP proxies ([ThibG](https://github.com/tootsuite/mastodon/pull/11245))
+- Fix HTTP requests to IPv6 hosts ([ThibG](https://github.com/tootsuite/mastodon/pull/11240))
+- Fix error in ElasticSearch index import ([mayaeh](https://github.com/tootsuite/mastodon/pull/11192))
+- Fix duplicate account error when seeding development database ([ysksn](https://github.com/tootsuite/mastodon/pull/11366))
+- Fix performance of session clean-up scheduler ([abcang](https://github.com/tootsuite/mastodon/pull/11871))
+- Fix older migrations not running ([zunda](https://github.com/tootsuite/mastodon/pull/11377))
+- Fix URLs counting towards RTL detection ([ahangarha](https://github.com/tootsuite/mastodon/pull/11759))
+- Fix unnecessary status re-rendering in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11211))
+- Fix http_parser.rb gem not being compiled when no network available ([petabyteboy](https://github.com/tootsuite/mastodon/pull/11444))
+- Fix muted text color not applying to all text ([trwnh](https://github.com/tootsuite/mastodon/pull/11996))
+- Fix follower/following lists resetting on back-navigation in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11986))
+
+## [2.9.3] - 2019-08-10
+### Added
+
+- Add GIF and WebP support for custom emojis ([Gargron](https://github.com/tootsuite/mastodon/pull/11519))
+- Add logout link to dropdown menu in web UI ([koyuawsmbrtn](https://github.com/tootsuite/mastodon/pull/11353))
+- Add indication that text search is unavailable in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11112), [ThibG](https://github.com/tootsuite/mastodon/pull/11202))
+- Add `suffix` to `Mastodon::Version` to help forks ([clarfon](https://github.com/tootsuite/mastodon/pull/11407))
+- Add on-hover animation to animated custom emoji in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11348), [ThibG](https://github.com/tootsuite/mastodon/pull/11404), [ThibG](https://github.com/tootsuite/mastodon/pull/11522))
+- Add custom emoji support in profile metadata labels ([ThibG](https://github.com/tootsuite/mastodon/pull/11350))
+
+### Changed
+
+- Change default interface of web and streaming from 0.0.0.0 to 127.0.0.1 ([Gargron](https://github.com/tootsuite/mastodon/pull/11302), [zunda](https://github.com/tootsuite/mastodon/pull/11378), [Gargron](https://github.com/tootsuite/mastodon/pull/11351), [zunda](https://github.com/tootsuite/mastodon/pull/11326))
+- Change the retry limit of web push notifications ([highemerly](https://github.com/tootsuite/mastodon/pull/11292))
+- Change ActivityPub deliveries to not retry HTTP 501 errors ([Gargron](https://github.com/tootsuite/mastodon/pull/11233))
+- Change language detection to include hashtags as words ([Gargron](https://github.com/tootsuite/mastodon/pull/11341))
+- Change terms and privacy policy pages to always be accessible ([Gargron](https://github.com/tootsuite/mastodon/pull/11334))
+- Change robots tag to include `noarchive` when user opts out of indexing ([Kjwon15](https://github.com/tootsuite/mastodon/pull/11421))
+
+### Fixed
+
+- Fix account domain block not clearing out notifications ([Gargron](https://github.com/tootsuite/mastodon/pull/11393))
+- Fix incorrect locale sometimes being detected for browser ([Gargron](https://github.com/tootsuite/mastodon/pull/8657))
+- Fix crash when saving invalid domain name ([Gargron](https://github.com/tootsuite/mastodon/pull/11528))
+- Fix pinned statuses REST API returning pagination headers ([Gargron](https://github.com/tootsuite/mastodon/pull/11526))
+- Fix "cancel follow request" button having unreadable text in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11521))
+- Fix image uploads being blank when canvas read access is blocked ([ThibG](https://github.com/tootsuite/mastodon/pull/11499))
+- Fix avatars not being animated on hover when not logged in ([ThibG](https://github.com/tootsuite/mastodon/pull/11349))
+- Fix overzealous sanitization of HTML lists ([ThibG](https://github.com/tootsuite/mastodon/pull/11354))
+- Fix block crashing when a follow request exists ([ThibG](https://github.com/tootsuite/mastodon/pull/11288))
+- Fix backup service crashing when an attachment is missing ([ThibG](https://github.com/tootsuite/mastodon/pull/11241))
+- Fix account moderation action always sending e-mail notification ([Gargron](https://github.com/tootsuite/mastodon/pull/11242))
+- Fix swiping columns on mobile sometimes failing in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11200))
+- Fix wrong actor URI being serialized into poll updates ([ThibG](https://github.com/tootsuite/mastodon/pull/11194))
+- Fix statsd UDP sockets not being cleaned up in Sidekiq ([Gargron](https://github.com/tootsuite/mastodon/pull/11230))
+- Fix expiration date of filters being set to "never" when editing them ([ThibG](https://github.com/tootsuite/mastodon/pull/11204))
+- Fix support for MP4 files that are actually M4V files ([Gargron](https://github.com/tootsuite/mastodon/pull/11210))
+- Fix `alerts` not being typecast correctly in push subscription in REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/11343))
+- Fix some notices staying on unrelated pages ([ThibG](https://github.com/tootsuite/mastodon/pull/11364))
+- Fix unboosting sometimes preventing a boost from reappearing on feed ([ThibG](https://github.com/tootsuite/mastodon/pull/11405), [Gargron](https://github.com/tootsuite/mastodon/pull/11450))
+- Fix only one middle dot being recognized in hashtags ([Gargron](https://github.com/tootsuite/mastodon/pull/11345), [ThibG](https://github.com/tootsuite/mastodon/pull/11363))
+- Fix unnecessary SQL query performed on unauthenticated requests ([Gargron](https://github.com/tootsuite/mastodon/pull/11179))
+- Fix incorrect timestamp displayed on featured tags ([Kjwon15](https://github.com/tootsuite/mastodon/pull/11477))
+- Fix privacy dropdown active state when dropdown is placed on top of it ([ThibG](https://github.com/tootsuite/mastodon/pull/11495))
+- Fix filters not being applied to poll options ([ThibG](https://github.com/tootsuite/mastodon/pull/11174))
+- Fix keyboard navigation on various dropdowns ([ThibG](https://github.com/tootsuite/mastodon/pull/11511), [ThibG](https://github.com/tootsuite/mastodon/pull/11492), [ThibG](https://github.com/tootsuite/mastodon/pull/11491))
+- Fix keyboard navigation in modals ([ThibG](https://github.com/tootsuite/mastodon/pull/11493))
+- Fix image conversation being non-deterministic due to timestamps ([Gargron](https://github.com/tootsuite/mastodon/pull/11408))
+- Fix web UI performance ([ThibG](https://github.com/tootsuite/mastodon/pull/11211), [ThibG](https://github.com/tootsuite/mastodon/pull/11234))
+- Fix scrolling to compose form when not necessary in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11246), [ThibG](https://github.com/tootsuite/mastodon/pull/11182))
+- Fix save button being enabled when list title is empty in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11475))
+- Fix poll expiration not being pre-filled on delete & redraft in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11203))
+- Fix content warning sometimes being set when not requested in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11206))
+
+### Security
+
+- Fix invites not being disabled upon account suspension ([ThibG](https://github.com/tootsuite/mastodon/pull/11412))
+- Fix blocked domains still being able to fill database with account records ([Gargron](https://github.com/tootsuite/mastodon/pull/11219))
+
## [2.9.2] - 2019-06-22
### Added
diff --git a/Gemfile b/Gemfile
index 2505194c8..45a8444fd 100644
--- a/Gemfile
+++ b/Gemfile
@@ -5,7 +5,7 @@ ruby '>= 2.4.0', '< 2.7.0'
gem 'pkg-config', '~> 1.3'
-gem 'puma', '~> 4.1'
+gem 'puma', '~> 4.2'
gem 'rails', '~> 5.2.3'
gem 'thor', '~> 0.20'
@@ -29,7 +29,7 @@ gem 'bootsnap', '~> 1.4', require: false
gem 'browser'
gem 'charlock_holmes', '~> 0.7.6'
gem 'iso-639'
-gem 'chewy', '~> 5.0'
+gem 'chewy', '~> 5.1'
gem 'cld3', '~> 3.2.4'
gem 'devise', '~> 4.7'
gem 'devise-two-factor', '~> 3.1'
@@ -44,13 +44,13 @@ gem 'omniauth-saml', '~> 1.10'
gem 'omniauth', '~> 1.9'
gem 'discard', '~> 1.1'
-gem 'doorkeeper', '~> 5.1'
+gem 'doorkeeper', '~> 5.2'
gem 'fast_blank', '~> 1.0'
gem 'fastimage'
gem 'goldfinger', '~> 2.1'
gem 'hiredis', '~> 0.6'
gem 'redis-namespace', '~> 1.5'
-gem 'health_check', '~> 3.0'
+gem 'health_check', git: 'https://github.com/ianheggie/health_check', ref: '0b799ead604f900ed50685e9b2d469cd2befba5b'
gem 'html2text'
gem 'htmlentities', '~> 4.3'
gem 'http', '~> 3.3'
@@ -118,7 +118,7 @@ end
group :test do
gem 'capybara', '~> 3.29'
gem 'climate_control', '~> 0.2'
- gem 'faker', '~> 2.3'
+ gem 'faker', '~> 2.4'
gem 'microformats', '~> 4.1'
gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.0'
diff --git a/Gemfile.lock b/Gemfile.lock
index 092f69119..c05b85b2a 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,3 +1,11 @@
+GIT
+ remote: https://github.com/ianheggie/health_check
+ revision: 0b799ead604f900ed50685e9b2d469cd2befba5b
+ ref: 0b799ead604f900ed50685e9b2d469cd2befba5b
+ specs:
+ health_check (4.0.0.pre)
+ rails (>= 4.0)
+
GIT
remote: https://github.com/rtomayko/posix-spawn
revision: 58465d2e213991f8afb13b984854a49fcdcc980c
@@ -161,7 +169,7 @@ GEM
case_transform (0.2)
activesupport
charlock_holmes (0.7.6)
- chewy (5.0.0)
+ chewy (5.1.0)
activesupport (>= 4.0)
elasticsearch (>= 2.0.0)
elasticsearch-dsl
@@ -209,19 +217,19 @@ GEM
docile (1.3.2)
domain_name (0.5.20180417)
unf (>= 0.0.5, < 1.0.0)
- doorkeeper (5.1.0)
+ doorkeeper (5.2.1)
railties (>= 5)
dotenv (2.7.5)
dotenv-rails (2.7.5)
dotenv (= 2.7.5)
railties (>= 3.2, < 6.1)
- elasticsearch (6.0.2)
- elasticsearch-api (= 6.0.2)
- elasticsearch-transport (= 6.0.2)
- elasticsearch-api (6.0.2)
+ elasticsearch (7.3.0)
+ elasticsearch-api (= 7.3.0)
+ elasticsearch-transport (= 7.3.0)
+ elasticsearch-api (7.3.0)
multi_json
- elasticsearch-dsl (0.1.5)
- elasticsearch-transport (6.0.2)
+ elasticsearch-dsl (0.1.8)
+ elasticsearch-transport (7.3.0)
faraday
multi_json
encryptor (3.0.0)
@@ -231,9 +239,9 @@ GEM
tzinfo
excon (0.62.0)
fabrication (2.20.2)
- faker (2.3.0)
+ faker (2.4.0)
i18n (~> 1.6.0)
- faraday (0.15.0)
+ faraday (0.15.4)
multipart-post (>= 1.2, < 3)
fast_blank (1.0.0)
fastimage (2.1.7)
@@ -278,8 +286,6 @@ GEM
concurrent-ruby (~> 1.0)
hashdiff (1.0.0)
hashie (3.6.0)
- health_check (3.0.0)
- railties (>= 5.0)
heapy (0.1.4)
highline (2.0.1)
hiredis (0.6.3)
@@ -372,10 +378,10 @@ GEM
mimemagic (0.3.3)
mini_mime (1.0.2)
mini_portile2 (2.4.0)
- minitest (5.11.3)
+ minitest (5.12.0)
msgpack (1.3.1)
multi_json (1.13.1)
- multipart-post (2.0.0)
+ multipart-post (2.1.1)
necromancer (0.5.0)
net-ldap (0.16.1)
net-scp (2.0.0)
@@ -447,7 +453,7 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.1)
- puma (4.1.1)
+ puma (4.2.0)
nio4r (~> 2.0)
pundit (2.1.0)
activesupport (>= 3.0.0)
@@ -503,7 +509,7 @@ GEM
rdf-normalize (0.3.3)
rdf (>= 2.2, < 4.0)
redcarpet (3.4.0)
- redis (4.1.2)
+ redis (4.1.3)
redis-actionpack (5.0.2)
actionpack (>= 4.0, < 6)
redis-rack (>= 1, < 3)
@@ -593,7 +599,7 @@ GEM
simple_form (4.1.0)
actionpack (>= 5.0)
activemodel (>= 5.0)
- simplecov (0.17.0)
+ simplecov (0.17.1)
docile (~> 1.1)
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
@@ -649,7 +655,7 @@ GEM
uniform_notifier (1.12.1)
warden (1.2.8)
rack (>= 2.0.6)
- webmock (3.7.3)
+ webmock (3.7.5)
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@@ -690,7 +696,7 @@ DEPENDENCIES
capistrano-yarn (~> 2.0)
capybara (~> 3.29)
charlock_holmes (~> 0.7.6)
- chewy (~> 5.0)
+ chewy (~> 5.1)
cld3 (~> 3.2.4)
climate_control (~> 0.2)
concurrent-ruby
@@ -700,10 +706,10 @@ DEPENDENCIES
devise-two-factor (~> 3.1)
devise_pam_authenticatable2 (~> 9.2)
discard (~> 1.1)
- doorkeeper (~> 5.1)
+ doorkeeper (~> 5.2)
dotenv-rails (~> 2.7)
fabrication (~> 2.20)
- faker (~> 2.3)
+ faker (~> 2.4)
fast_blank (~> 1.0)
fastimage
fog-core (<= 2.1.0)
@@ -711,7 +717,7 @@ DEPENDENCIES
fuubar (~> 2.4)
goldfinger (~> 2.1)
hamlit-rails (~> 0.2)
- health_check (~> 3.0)
+ health_check!
hiredis (~> 0.6)
html2text
htmlentities (~> 4.3)
@@ -756,7 +762,7 @@ DEPENDENCIES
private_address_check (~> 0.5)
pry-byebug (~> 3.7)
pry-rails (~> 0.3)
- puma (~> 4.1)
+ puma (~> 4.2)
pundit (~> 2.1)
rack-attack (~> 6.1)
rack-cors (~> 1.0)
@@ -798,7 +804,7 @@ DEPENDENCIES
webpush
RUBY VERSION
- ruby 2.6.1p33
+ ruby 2.6.4p104
BUNDLED WITH
1.17.3
diff --git a/app.json b/app.json
index 09adaac2c..211f17d81 100644
--- a/app.json
+++ b/app.json
@@ -13,15 +13,6 @@
"description": "The domain that your Mastodon instance will run on (this can be appname.herokuapp.com or a custom domain)",
"required": true
},
- "LOCAL_HTTPS": {
- "description": "Will your domain support HTTPS? (Automatic for herokuapp, requires manual configuration for custom domains)",
- "value": "false",
- "required": true
- },
- "PAPERCLIP_SECRET": {
- "description": "The secret key for storing media files",
- "generator": "secret"
- },
"SECRET_KEY_BASE": {
"description": "The secret key base",
"generator": "secret"
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb
index 7b0438127..5d5db937c 100644
--- a/app/controllers/about_controller.rb
+++ b/app/controllers/about_controller.rb
@@ -4,9 +4,7 @@ class AboutController < ApplicationController
before_action :set_pack
layout 'public'
- before_action :require_open_federation!, only: [:show, :more, :blocks]
- before_action :check_blocklist_enabled, only: [:blocks]
- before_action :authenticate_user!, only: [:blocks], if: :blocklist_account_required?
+ before_action :require_open_federation!, only: [:show, :more]
before_action :set_body_classes, only: :show
before_action :set_instance_presenter
before_action :set_expires_in, only: [:show, :more, :terms]
@@ -17,15 +15,20 @@ class AboutController < ApplicationController
def more
flash.now[:notice] = I18n.t('about.instance_actor_flash') if params[:instance_actor]
+
+ toc_generator = TOCGenerator.new(@instance_presenter.site_extended_description)
+
+ @contents = toc_generator.html
+ @table_of_contents = toc_generator.toc
+ @blocks = DomainBlock.with_user_facing_limitations.by_severity if display_blocks?
end
def terms; end
- def blocks
- @show_rationale = Setting.show_domain_blocks_rationale == 'all'
- @show_rationale |= Setting.show_domain_blocks_rationale == 'users' && !current_user.nil? && current_user.functional?
- @blocks = DomainBlock.with_user_facing_limitations.order('(CASE severity WHEN 0 THEN 1 WHEN 1 THEN 2 WHEN 2 THEN 0 END), reject_media, domain').to_a
- end
+ helper_method :display_blocks?
+ helper_method :display_blocks_rationale?
+ helper_method :public_fetch_mode?
+ helper_method :new_user
private
@@ -33,28 +36,14 @@ class AboutController < ApplicationController
not_found if whitelist_mode?
end
- def check_blocklist_enabled
- not_found if Setting.show_domain_blocks == 'disabled'
+ def display_blocks?
+ Setting.show_domain_blocks == 'all' || (Setting.show_domain_blocks == 'users' && user_signed_in?)
end
- def blocklist_account_required?
- Setting.show_domain_blocks == 'users'
+ def display_blocks_rationale?
+ Setting.show_domain_blocks_rationale == 'all' || (Setting.show_domain_blocks_rationale == 'users' && user_signed_in?)
end
- def block_severity_text(block)
- if block.severity == 'suspend'
- I18n.t('domain_blocks.suspension')
- else
- limitations = []
- limitations << I18n.t('domain_blocks.media_block') if block.reject_media?
- limitations << I18n.t('domain_blocks.silence') if block.severity == 'silence'
- limitations.join(', ')
- end
- end
-
- helper_method :block_severity_text
- helper_method :public_fetch_mode?
-
def new_user
User.new.tap do |user|
user.build_account
@@ -62,8 +51,6 @@ class AboutController < ApplicationController
end
end
- helper_method :new_user
-
def set_pack
use_pack 'public'
end
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 817e5e832..1c36813d0 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -10,6 +10,7 @@ class AccountsController < ApplicationController
before_action :set_body_classes
skip_around_action :set_locale, if: -> { request.format == :json }
+ skip_before_action :require_functional!
def show
respond_to do |format|
diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb
index 989fee385..910fefb1c 100644
--- a/app/controllers/activitypub/collections_controller.rb
+++ b/app/controllers/activitypub/collections_controller.rb
@@ -33,9 +33,9 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
def scope_for_collection
case params[:id]
when 'featured'
- @account.statuses.permitted_for(@account, signed_request_account).tap do |scope|
- scope.merge!(@account.pinned_statuses)
- end
+ return Status.none if @account.blocking?(signed_request_account)
+
+ @account.pinned_statuses
else
raise ActiveRecord::RecordNotFound
end
diff --git a/app/controllers/admin/relays_controller.rb b/app/controllers/admin/relays_controller.rb
index 1b02d3c36..6fbb6e063 100644
--- a/app/controllers/admin/relays_controller.rb
+++ b/app/controllers/admin/relays_controller.rb
@@ -3,6 +3,7 @@
module Admin
class RelaysController < BaseController
before_action :set_relay, except: [:index, :new, :create]
+ before_action :require_signatures_enabled!, only: [:new, :create, :enable]
def index
authorize :relay, :update?
@@ -11,7 +12,7 @@ module Admin
def new
authorize :relay, :update?
- @relay = Relay.new(inbox_url: Relay::PRESET_RELAY)
+ @relay = Relay.new
end
def create
@@ -54,5 +55,9 @@ module Admin
def resource_params
params.require(:relay).permit(:inbox_url)
end
+
+ def require_signatures_enabled!
+ redirect_to admin_relays_path, alert: I18n.t('admin.relays.signatures_not_enabled') if authorized_fetch_mode?
+ end
end
end
diff --git a/app/controllers/admin/two_factor_authentications_controller.rb b/app/controllers/admin/two_factor_authentications_controller.rb
index 2577a4b17..0652c3a7a 100644
--- a/app/controllers/admin/two_factor_authentications_controller.rb
+++ b/app/controllers/admin/two_factor_authentications_controller.rb
@@ -8,6 +8,7 @@ module Admin
authorize @user, :disable_2fa?
@user.disable_two_factor!
log_action :disable_2fa, @user
+ UserMailer.two_factor_disabled(@user).deliver_later!
redirect_to admin_accounts_path
end
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 0787cd636..333db9618 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -57,6 +57,8 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
end
def pinned_scope
+ return Status.none if @account.blocking?(current_account)
+
@account.pinned_statuses
end
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index b306e8e8c..c12e1c12e 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -33,7 +33,7 @@ class Api::V1::AccountsController < Api::BaseController
def follow
FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs))
- options = @account.locked? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } }
+ options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } }
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options)
end
diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb
index 7fdc030e5..76decdb25 100644
--- a/app/controllers/api/v2/search_controller.rb
+++ b/app/controllers/api/v2/search_controller.rb
@@ -22,7 +22,7 @@ class Api::V2::SearchController < Api::BaseController
params[:q],
current_account,
limit_param(RESULTS_LIMIT),
- search_params.merge(resolve: truthy_param?(:resolve))
+ search_params.merge(resolve: truthy_param?(:resolve), exclude_unreviewed: truthy_param?(:exclude_unreviewed))
)
end
diff --git a/app/controllers/auth/challenges_controller.rb b/app/controllers/auth/challenges_controller.rb
new file mode 100644
index 000000000..060944240
--- /dev/null
+++ b/app/controllers/auth/challenges_controller.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class Auth::ChallengesController < ApplicationController
+ include ChallengableConcern
+
+ layout 'auth'
+
+ before_action :authenticate_user!
+
+ skip_before_action :require_functional!
+
+ def create
+ if challenge_passed?
+ session[:challenge_passed_at] = Time.now.utc
+ redirect_to challenge_params[:return_to]
+ else
+ @challenge = Form::Challenge.new(return_to: challenge_params[:return_to])
+ flash.now[:alert] = I18n.t('challenge.invalid_password')
+ render_challenge
+ end
+ end
+end
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index c2b38883b..efde02ac2 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -9,6 +9,7 @@ class Auth::SessionsController < Devise::SessionsController
skip_before_action :require_functional!
prepend_before_action :set_pack
+ prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
before_action :set_instance_presenter, only: [:new]
before_action :set_body_classes
@@ -22,34 +23,32 @@ class Auth::SessionsController < Devise::SessionsController
end
def create
- self.resource = begin
- if user_params[:email].blank? && session[:otp_user_id].present?
- User.find(session[:otp_user_id])
- else
- warden.authenticate!(auth_options)
- end
- end
-
- if resource.otp_required_for_login?
- if user_params[:otp_attempt].present? && session[:otp_user_id].present?
- authenticate_with_two_factor_via_otp(resource)
- else
- prompt_for_two_factor(resource)
- end
- else
- authenticate_and_respond(resource)
+ super do |resource|
+ remember_me(resource)
+ flash.delete(:notice)
end
end
def destroy
tmp_stored_location = stored_location_for(:user)
super
+ session.delete(:challenge_passed_at)
flash.delete(:notice)
store_location_for(:user, tmp_stored_location) if continue_after?
end
protected
+ def find_user
+ if session[:otp_user_id]
+ User.find(session[:otp_user_id])
+ else
+ user = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication
+ user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication
+ user ||= User.find_for_authentication(email: user_params[:email])
+ end
+ end
+
def user_params
params.require(:user).permit(:email, :password, :otp_attempt)
end
@@ -72,6 +71,10 @@ class Auth::SessionsController < Devise::SessionsController
super
end
+ def two_factor_enabled?
+ find_user&.otp_required_for_login?
+ end
+
def valid_otp_attempt?(user)
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
@@ -79,10 +82,24 @@ class Auth::SessionsController < Devise::SessionsController
false
end
+ def authenticate_with_two_factor
+ user = self.resource = find_user
+
+ if user_params[:otp_attempt].present? && session[:otp_user_id]
+ authenticate_with_two_factor_via_otp(user)
+ elsif user.present? && (user.encrypted_password.blank? || user.valid_password?(user_params[:password]))
+ # If encrypted_password is blank, we got the user from LDAP or PAM,
+ # so credentials are already valid
+
+ prompt_for_two_factor(user)
+ end
+ end
+
def authenticate_with_two_factor_via_otp(user)
if valid_otp_attempt?(user)
session.delete(:otp_user_id)
- authenticate_and_respond(user)
+ remember_me(user)
+ sign_in(user)
else
flash.now[:alert] = I18n.t('users.invalid_otp_token')
prompt_for_two_factor(user)
@@ -91,16 +108,10 @@ class Auth::SessionsController < Devise::SessionsController
def prompt_for_two_factor(user)
session[:otp_user_id] = user.id
+ @body_classes = 'lighter'
render :two_factor
end
- def authenticate_and_respond(user)
- sign_in(user)
- remember_me(user)
-
- respond_with user, location: after_sign_in_path_for(user)
- end
-
private
def set_pack
@@ -117,11 +128,9 @@ class Auth::SessionsController < Devise::SessionsController
def home_paths(resource)
paths = [about_path]
-
if single_user_mode? && resource.is_a?(User)
paths << short_account_path(username: resource.account)
end
-
paths
end
diff --git a/app/controllers/concerns/challengable_concern.rb b/app/controllers/concerns/challengable_concern.rb
new file mode 100644
index 000000000..b29d90b3c
--- /dev/null
+++ b/app/controllers/concerns/challengable_concern.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+# This concern is inspired by "sudo mode" on GitHub. It
+# is a way to re-authenticate a user before allowing them
+# to see or perform an action.
+#
+# Add `before_action :require_challenge!` to actions you
+# want to protect.
+#
+# The user will be shown a page to enter the challenge (which
+# is either the password, or just the username when no
+# password exists). Upon passing, there is a grace period
+# during which no challenge will be asked from the user.
+#
+# Accessing challenge-protected resources during the grace
+# period will refresh the grace period.
+module ChallengableConcern
+ extend ActiveSupport::Concern
+
+ CHALLENGE_TIMEOUT = 1.hour.freeze
+
+ def require_challenge!
+ return if skip_challenge?
+
+ if challenge_passed_recently?
+ session[:challenge_passed_at] = Time.now.utc
+ return
+ end
+
+ @challenge = Form::Challenge.new(return_to: request.url)
+
+ if params.key?(:form_challenge)
+ if challenge_passed?
+ session[:challenge_passed_at] = Time.now.utc
+ return
+ else
+ flash.now[:alert] = I18n.t('challenge.invalid_password')
+ render_challenge
+ end
+ else
+ render_challenge
+ end
+ end
+
+ def render_challenge
+ @body_classes = 'lighter'
+ render template: 'auth/challenges/new', layout: 'auth'
+ end
+
+ def challenge_passed?
+ current_user.valid_password?(challenge_params[:current_password])
+ end
+
+ def skip_challenge?
+ current_user.encrypted_password.blank?
+ end
+
+ def challenge_passed_recently?
+ session[:challenge_passed_at].present? && session[:challenge_passed_at] >= CHALLENGE_TIMEOUT.ago
+ end
+
+ def challenge_params
+ params.require(:form_challenge).permit(:current_password, :return_to)
+ end
+end
diff --git a/app/controllers/concerns/export_controller_concern.rb b/app/controllers/concerns/export_controller_concern.rb
index e20b71a30..bfe990c82 100644
--- a/app/controllers/concerns/export_controller_concern.rb
+++ b/app/controllers/concerns/export_controller_concern.rb
@@ -5,7 +5,10 @@ module ExportControllerConcern
included do
before_action :authenticate_user!
+ before_action :require_not_suspended!
before_action :load_export
+
+ skip_before_action :require_functional!
end
private
@@ -27,4 +30,8 @@ module ExportControllerConcern
def export_filename
"#{controller_name}.csv"
end
+
+ def require_not_suspended!
+ forbidden if current_account.suspended?
+ end
end
diff --git a/app/controllers/custom_css_controller.rb b/app/controllers/custom_css_controller.rb
index e3f67bd14..0a667a6a6 100644
--- a/app/controllers/custom_css_controller.rb
+++ b/app/controllers/custom_css_controller.rb
@@ -2,6 +2,7 @@
class CustomCssController < ApplicationController
skip_before_action :store_current_location
+ skip_before_action :require_functional!
before_action :set_cache_headers
diff --git a/app/controllers/directories_controller.rb b/app/controllers/directories_controller.rb
index bbfdde8af..adf2bd014 100644
--- a/app/controllers/directories_controller.rb
+++ b/app/controllers/directories_controller.rb
@@ -10,6 +10,8 @@ class DirectoriesController < ApplicationController
before_action :set_accounts
before_action :set_pack
+ skip_before_action :require_functional!
+
def index
render :index
end
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index 4641a8bb9..df46f5f72 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -8,6 +8,7 @@ class FollowerAccountsController < ApplicationController
before_action :set_cache_headers
skip_around_action :set_locale, if: -> { request.format == :json }
+ skip_before_action :require_functional!
def index
respond_to do |format|
diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb
index 6e80554fb..8cab67ff5 100644
--- a/app/controllers/following_accounts_controller.rb
+++ b/app/controllers/following_accounts_controller.rb
@@ -8,6 +8,7 @@ class FollowingAccountsController < ApplicationController
before_action :set_cache_headers
skip_around_action :set_locale, if: -> { request.format == :json }
+ skip_before_action :require_functional!
def index
respond_to do |format|
diff --git a/app/controllers/manifests_controller.rb b/app/controllers/manifests_controller.rb
index 491cde745..960510f60 100644
--- a/app/controllers/manifests_controller.rb
+++ b/app/controllers/manifests_controller.rb
@@ -2,6 +2,7 @@
class ManifestsController < ApplicationController
skip_before_action :store_current_location
+ skip_before_action :require_functional!
def show
expires_in 3.minutes, public: true
diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb
index 1f693de32..05cf09c28 100644
--- a/app/controllers/media_controller.rb
+++ b/app/controllers/media_controller.rb
@@ -4,6 +4,7 @@ class MediaController < ApplicationController
include Authorization
skip_before_action :store_current_location
+ skip_before_action :require_functional!
before_action :authenticate_user!, if: :whitelist_mode?
before_action :set_media_attachment
diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb
index 47544f21c..014b89de1 100644
--- a/app/controllers/media_proxy_controller.rb
+++ b/app/controllers/media_proxy_controller.rb
@@ -4,6 +4,7 @@ class MediaProxyController < ApplicationController
include RoutingHelper
skip_before_action :store_current_location
+ skip_before_action :require_functional!
before_action :authenticate_user!, if: :whitelist_mode?
diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb
index 65dfa35db..93a0a7476 100644
--- a/app/controllers/remote_follow_controller.rb
+++ b/app/controllers/remote_follow_controller.rb
@@ -8,6 +8,8 @@ class RemoteFollowController < ApplicationController
before_action :set_pack
before_action :set_body_classes
+ skip_before_action :require_functional!
+
def new
@remote_follow = RemoteFollow.new(session_params)
end
diff --git a/app/controllers/remote_interaction_controller.rb b/app/controllers/remote_interaction_controller.rb
index 6b797b10f..e058d0ed5 100644
--- a/app/controllers/remote_interaction_controller.rb
+++ b/app/controllers/remote_interaction_controller.rb
@@ -11,6 +11,8 @@ class RemoteInteractionController < ApplicationController
before_action :set_body_classes
before_action :set_pack
+ skip_before_action :require_functional!
+
def new
@remote_follow = RemoteFollow.new(session_params)
end
diff --git a/app/controllers/settings/aliases_controller.rb b/app/controllers/settings/aliases_controller.rb
new file mode 100644
index 000000000..b7c9a409d
--- /dev/null
+++ b/app/controllers/settings/aliases_controller.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+class Settings::AliasesController < Settings::BaseController
+ layout 'admin'
+
+ before_action :authenticate_user!
+ before_action :set_aliases, except: :destroy
+ before_action :set_alias, only: :destroy
+
+ def index
+ @alias = current_account.aliases.build
+ end
+
+ def create
+ @alias = current_account.aliases.build(resource_params)
+
+ if @alias.save
+ ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
+ redirect_to settings_aliases_path, notice: I18n.t('aliases.created_msg')
+ else
+ render :index
+ end
+ end
+
+ def destroy
+ @alias.destroy!
+ redirect_to settings_aliases_path, notice: I18n.t('aliases.deleted_msg')
+ end
+
+ private
+
+ def resource_params
+ params.require(:account_alias).permit(:acct)
+ end
+
+ def set_alias
+ @alias = current_account.aliases.find(params[:id])
+ end
+
+ def set_aliases
+ @aliases = current_account.aliases.order(id: :desc).reject(&:new_record?)
+ end
+end
diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb
index 3012fbf77..0e93d07a9 100644
--- a/app/controllers/settings/exports_controller.rb
+++ b/app/controllers/settings/exports_controller.rb
@@ -6,6 +6,9 @@ class Settings::ExportsController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
+ before_action :require_not_suspended!
+
+ skip_before_action :require_functional!
def show
@export = Export.new(current_account)
@@ -34,4 +37,8 @@ class Settings::ExportsController < Settings::BaseController
def lock_options
{ redis: Redis.current, key: "backup:#{current_user.id}" }
end
+
+ def require_not_suspended!
+ forbidden if current_account.suspended?
+ end
end
diff --git a/app/controllers/settings/migration/redirects_controller.rb b/app/controllers/settings/migration/redirects_controller.rb
new file mode 100644
index 000000000..6e5b72ffb
--- /dev/null
+++ b/app/controllers/settings/migration/redirects_controller.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class Settings::Migration::RedirectsController < Settings::BaseController
+ layout 'admin'
+
+ before_action :authenticate_user!
+ before_action :require_not_suspended!
+
+ skip_before_action :require_functional!
+
+ def new
+ @redirect = Form::Redirect.new
+ end
+
+ def create
+ @redirect = Form::Redirect.new(resource_params.merge(account: current_account))
+
+ if @redirect.valid_with_challenge?(current_user)
+ current_account.update!(moved_to_account: @redirect.target_account)
+ ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
+ redirect_to settings_migration_path, notice: I18n.t('migrations.moved_msg', acct: current_account.moved_to_account.acct)
+ else
+ render :new
+ end
+ end
+
+ def destroy
+ if current_account.moved_to_account_id.present?
+ current_account.update!(moved_to_account: nil)
+ ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
+ end
+
+ redirect_to settings_migration_path, notice: I18n.t('migrations.cancelled_msg')
+ end
+
+ private
+
+ def resource_params
+ params.require(:form_redirect).permit(:acct, :current_password, :current_username)
+ end
+
+ def require_not_suspended!
+ forbidden if current_account.suspended?
+ end
+end
diff --git a/app/controllers/settings/migrations_controller.rb b/app/controllers/settings/migrations_controller.rb
index 59eb48779..68304bb51 100644
--- a/app/controllers/settings/migrations_controller.rb
+++ b/app/controllers/settings/migrations_controller.rb
@@ -4,31 +4,48 @@ class Settings::MigrationsController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
+ before_action :require_not_suspended!
+ before_action :set_migrations
+ before_action :set_cooldown
+
+ skip_before_action :require_functional!
def show
- @migration = Form::Migration.new(account: current_account.moved_to_account)
+ @migration = current_account.migrations.build
end
- def update
- @migration = Form::Migration.new(resource_params)
+ def create
+ @migration = current_account.migrations.build(resource_params)
- if @migration.valid? && migration_account_changed?
- current_account.update!(moved_to_account: @migration.account)
- ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
- redirect_to settings_migration_path, notice: I18n.t('migrations.updated_msg')
+ if @migration.save_with_challenge(current_user)
+ MoveService.new.call(@migration)
+ redirect_to settings_migration_path, notice: I18n.t('migrations.moved_msg', acct: current_account.moved_to_account.acct)
else
render :show
end
end
+ helper_method :on_cooldown?
+
private
def resource_params
- params.require(:migration).permit(:acct)
+ params.require(:account_migration).permit(:acct, :current_password, :current_username)
end
- def migration_account_changed?
- current_account.moved_to_account_id != @migration.account&.id &&
- current_account.id != @migration.account&.id
+ def set_migrations
+ @migrations = current_account.migrations.includes(:target_account).order(id: :desc).reject(&:new_record?)
+ end
+
+ def set_cooldown
+ @cooldown = current_account.migrations.within_cooldown.first
+ end
+
+ def on_cooldown?
+ @cooldown.present?
+ end
+
+ def require_not_suspended!
+ forbidden if current_account.suspended?
end
end
diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
index 46c90bf74..ef4df3339 100644
--- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
@@ -3,9 +3,12 @@
module Settings
module TwoFactorAuthentication
class ConfirmationsController < BaseController
+ include ChallengableConcern
+
layout 'admin'
before_action :authenticate_user!
+ before_action :require_challenge!
before_action :ensure_otp_secret
skip_before_action :require_functional!
@@ -22,6 +25,8 @@ module Settings
@recovery_codes = current_user.generate_otp_backup_codes!
current_user.save!
+ UserMailer.two_factor_enabled(current_user).deliver_later!
+
render 'settings/two_factor_authentication/recovery_codes/index'
else
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
diff --git a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb
index 09a759860..0c4f5bff7 100644
--- a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb
@@ -3,16 +3,22 @@
module Settings
module TwoFactorAuthentication
class RecoveryCodesController < BaseController
+ include ChallengableConcern
+
layout 'admin'
before_action :authenticate_user!
+ before_action :require_challenge!, on: :create
skip_before_action :require_functional!
def create
@recovery_codes = current_user.generate_otp_backup_codes!
current_user.save!
+
+ UserMailer.two_factor_recovery_codes_changed(current_user).deliver_later!
flash.now[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated')
+
render :index
end
end
diff --git a/app/controllers/settings/two_factor_authentications_controller.rb b/app/controllers/settings/two_factor_authentications_controller.rb
index c93b17577..9118a7933 100644
--- a/app/controllers/settings/two_factor_authentications_controller.rb
+++ b/app/controllers/settings/two_factor_authentications_controller.rb
@@ -2,10 +2,13 @@
module Settings
class TwoFactorAuthenticationsController < BaseController
+ include ChallengableConcern
+
layout 'admin'
before_action :authenticate_user!
before_action :verify_otp_required, only: [:create]
+ before_action :require_challenge!, only: [:create]
skip_before_action :require_functional!
@@ -23,6 +26,7 @@ module Settings
if acceptable_code?
current_user.otp_required_for_login = false
current_user.save!
+ UserMailer.two_factor_disabled(current_user).deliver_later!
redirect_to settings_two_factor_authentication_path
else
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index 83c412d5c..1b00d38c9 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -19,6 +19,7 @@ class StatusesController < ApplicationController
before_action :set_autoplay, only: :embed
skip_around_action :set_locale, if: -> { request.format == :json }
+ skip_before_action :require_functional!, only: [:show, :embed]
content_security_policy only: :embed do |p|
p.frame_ancestors(false)
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index c447a3a2b..ef61c980f 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -13,6 +13,8 @@ class TagsController < ApplicationController
before_action :set_body_classes
before_action :set_instance_presenter
+ skip_before_action :require_functional!
+
def show
respond_to do |format|
format.html do
diff --git a/app/controllers/well_known/nodeinfo_controller.rb b/app/controllers/well_known/nodeinfo_controller.rb
new file mode 100644
index 000000000..11a699ebc
--- /dev/null
+++ b/app/controllers/well_known/nodeinfo_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module WellKnown
+ class NodeInfoController < ActionController::Base
+ include CacheConcern
+
+ before_action { response.headers['Vary'] = 'Accept' }
+
+ def index
+ expires_in 3.days, public: true
+ render_with_cache json: {}, serializer: NodeInfo::DiscoverySerializer, adapter: NodeInfo::Adapter, expires_in: 3.days, root: 'nodeinfo'
+ end
+
+ def show
+ expires_in 30.minutes, public: true
+ render_with_cache json: {}, serializer: NodeInfo::Serializer, adapter: NodeInfo::Adapter, expires_in: 30.minutes, root: 'nodeinfo'
+ end
+ end
+end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index 2b3fd1263..ecc73baf5 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -87,4 +87,12 @@ module SettingsHelper
'desktop'
end
end
+
+ def compact_account_link_to(account)
+ return if account.nil?
+
+ link_to ActivityPub::TagManager.instance.url_for(account), class: 'name-tag', title: account.acct do
+ safe_join([image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'), content_tag(:span, account.acct, class: 'username')], ' ')
+ end
+ end
end
diff --git a/app/javascript/mastodon/actions/blocks.js b/app/javascript/mastodon/actions/blocks.js
index 7000f5a71..fd9881302 100644
--- a/app/javascript/mastodon/actions/blocks.js
+++ b/app/javascript/mastodon/actions/blocks.js
@@ -1,6 +1,7 @@
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
+import { openModal } from './modal';
export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';
export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS';
@@ -10,6 +11,8 @@ export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST';
export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS';
export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL';
+export const BLOCKS_INIT_MODAL = 'BLOCKS_INIT_MODAL';
+
export function fetchBlocks() {
return (dispatch, getState) => {
dispatch(fetchBlocksRequest());
@@ -83,3 +86,14 @@ export function expandBlocksFail(error) {
error,
};
};
+
+export function initBlockModal(account) {
+ return dispatch => {
+ dispatch({
+ type: BLOCKS_INIT_MODAL,
+ account,
+ });
+
+ dispatch(openModal('BLOCK'));
+ };
+}
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 061a36bb8..8e7906c73 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -234,7 +234,7 @@ export function uploadCompose(files) {
progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
},
- }).then(({ data }) => dispatch(uploadComposeSuccess(data)));
+ }).then(({ data }) => dispatch(uploadComposeSuccess(data, f)));
}).catch(error => dispatch(uploadComposeFail(error)));
};
};
@@ -289,10 +289,11 @@ export function uploadComposeProgress(loaded, total) {
};
};
-export function uploadComposeSuccess(media) {
+export function uploadComposeSuccess(media, file) {
return {
type: COMPOSE_UPLOAD_SUCCESS,
media: media,
+ file: file,
skipLoading: true,
};
};
@@ -368,6 +369,7 @@ const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
q: token.slice(1),
resolve: false,
limit: 4,
+ exclude_unreviewed: true,
},
}).then(({ data }) => {
dispatch(readyComposeSuggestionsTags(token, data.hashtags));
diff --git a/app/javascript/mastodon/actions/conversations.js b/app/javascript/mastodon/actions/conversations.js
index c6e062ef7..4ef654b1f 100644
--- a/app/javascript/mastodon/actions/conversations.js
+++ b/app/javascript/mastodon/actions/conversations.js
@@ -15,6 +15,10 @@ export const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE';
export const CONVERSATIONS_READ = 'CONVERSATIONS_READ';
+export const CONVERSATIONS_DELETE_REQUEST = 'CONVERSATIONS_DELETE_REQUEST';
+export const CONVERSATIONS_DELETE_SUCCESS = 'CONVERSATIONS_DELETE_SUCCESS';
+export const CONVERSATIONS_DELETE_FAIL = 'CONVERSATIONS_DELETE_FAIL';
+
export const mountConversations = () => ({
type: CONVERSATIONS_MOUNT,
});
@@ -82,3 +86,27 @@ export const updateConversations = conversation => dispatch => {
conversation,
});
};
+
+export const deleteConversation = conversationId => (dispatch, getState) => {
+ dispatch(deleteConversationRequest(conversationId));
+
+ api(getState).delete(`/api/v1/conversations/${conversationId}`)
+ .then(() => dispatch(deleteConversationSuccess(conversationId)))
+ .catch(error => dispatch(deleteConversationFail(conversationId, error)));
+};
+
+export const deleteConversationRequest = id => ({
+ type: CONVERSATIONS_DELETE_REQUEST,
+ id,
+});
+
+export const deleteConversationSuccess = id => ({
+ type: CONVERSATIONS_DELETE_SUCCESS,
+ id,
+});
+
+export const deleteConversationFail = (id, error) => ({
+ type: CONVERSATIONS_DELETE_FAIL,
+ id,
+ error,
+});
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index 5e7e78e69..f7108fdb9 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -73,8 +73,9 @@ export function normalizePoll(poll) {
const emojiMap = makeEmojiMap(normalPoll);
- normalPoll.options = poll.options.map(option => ({
+ normalPoll.options = poll.options.map((option, index) => ({
...option,
+ voted: poll.own_votes && poll.own_votes.includes(index),
title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap),
}));
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index ea76255e3..58803d1ae 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -28,6 +28,9 @@ export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
+export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
+export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
+
defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
@@ -215,3 +218,11 @@ export function setFilter (filterType) {
dispatch(saveSettings());
};
};
+
+export const mountNotifications = () => ({
+ type: NOTIFICATIONS_MOUNT,
+});
+
+export const unmountNotifications = () => ({
+ type: NOTIFICATIONS_UNMOUNT,
+});
diff --git a/app/javascript/mastodon/components/avatar_composite.js b/app/javascript/mastodon/components/avatar_composite.js
index 4a9a73c51..5d5b89749 100644
--- a/app/javascript/mastodon/components/avatar_composite.js
+++ b/app/javascript/mastodon/components/avatar_composite.js
@@ -35,35 +35,35 @@ export default class AvatarComposite extends React.PureComponent {
if (size === 2) {
if (index === 0) {
- right = '2px';
+ right = '1px';
} else {
- left = '2px';
+ left = '1px';
}
} else if (size === 3) {
if (index === 0) {
- right = '2px';
+ right = '1px';
} else if (index > 0) {
- left = '2px';
+ left = '1px';
}
if (index === 1) {
- bottom = '2px';
+ bottom = '1px';
} else if (index > 1) {
- top = '2px';
+ top = '1px';
}
} else if (size === 4) {
if (index === 0 || index === 2) {
- right = '2px';
+ right = '1px';
}
if (index === 1 || index === 3) {
- left = '2px';
+ left = '1px';
}
if (index < 2) {
- bottom = '2px';
+ bottom = '1px';
} else {
- top = '2px';
+ top = '1px';
}
}
@@ -88,7 +88,13 @@ export default class AvatarComposite extends React.PureComponent {
return (
- {accounts.take(4).map((account, i) => this.renderItem(account, accounts.size, i))}
+ {accounts.take(4).map((account, i) => this.renderItem(account, Math.min(accounts.size, 4), i))}
+
+ {accounts.size > 4 && (
+
+ +{accounts.size - 4}
+
+ )}
);
}
diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js
index 8a26742b5..0038995c8 100644
--- a/app/javascript/mastodon/components/column_header.js
+++ b/app/javascript/mastodon/components/column_header.js
@@ -120,7 +120,7 @@ class ColumnHeader extends React.PureComponent {
);
- } else if (multiColumn) {
+ } else if (multiColumn && this.props.onPin) {
pinButton = ;
}
@@ -142,7 +142,7 @@ class ColumnHeader extends React.PureComponent {
collapsedContent.push(pinButton);
}
- if (children || multiColumn) {
+ if (children || (multiColumn && this.props.onPin)) {
collapseButton = ;
}
diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js
index 373f710d3..cdbcf8f70 100644
--- a/app/javascript/mastodon/components/poll.js
+++ b/app/javascript/mastodon/components/poll.js
@@ -10,9 +10,11 @@ import spring from 'react-motion/lib/spring';
import escapeTextContentForBrowser from 'escape-html';
import emojify from 'mastodon/features/emoji/emoji';
import RelativeTimestamp from './relative_timestamp';
+import Icon from 'mastodon/components/icon';
const messages = defineMessages({
closed: { id: 'poll.closed', defaultMessage: 'Closed' },
+ voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', description: 'Tooltip of the "voted" checkmark in polls' },
});
const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
@@ -99,10 +101,12 @@ class Poll extends ImmutablePureComponent {
};
renderOption (option, optionIndex, showResults) {
- const { poll, disabled } = this.props;
- const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100;
- const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count'));
- const active = !!this.state.selected[`${optionIndex}`];
+ const { poll, disabled, intl } = this.props;
+ const pollVotesCount = poll.get('voters_count') || poll.get('votes_count');
+ const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100;
+ const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count'));
+ const active = !!this.state.selected[`${optionIndex}`];
+ const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex));
let titleEmojified = option.get('title_emojified');
if (!titleEmojified) {
@@ -131,7 +135,10 @@ class Poll extends ImmutablePureComponent {
/>
{!showResults && }
- {showResults && {Math.round(percent)}%}
+ {showResults &&
+ {!!voted && }
+ {Math.round(percent)}%
+ }
@@ -151,6 +158,14 @@ class Poll extends ImmutablePureComponent {
const showResults = poll.get('voted') || expired;
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
+ let votesCount = null;
+
+ if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) {
+ votesCount = ;
+ } else {
+ votesCount = ;
+ }
+
return (
@@ -160,7 +175,7 @@ class Poll extends ImmutablePureComponent {
{!showResults && }
{showResults && !this.props.disabled && · }
-
+ {votesCount}
{poll.get('expires_at') && · {timeRemaining}}
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
index 253646ed0..421756803 100644
--- a/app/javascript/mastodon/components/scrollable_list.js
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -199,6 +199,7 @@ export default class ScrollableList extends PureComponent {
this.clearMouseIdleTimer();
this.detachScrollListener();
this.detachIntersectionObserver();
+
detachFullscreenListener(this.onFullScreenChange);
}
diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js
index db340032a..ba55ecbc7 100644
--- a/app/javascript/mastodon/containers/media_container.js
+++ b/app/javascript/mastodon/containers/media_container.js
@@ -2,17 +2,17 @@ import React, { PureComponent, Fragment } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import { IntlProvider, addLocaleData } from 'react-intl';
-import { getLocale } from '../locales';
-import MediaGallery from '../components/media_gallery';
-import Video from '../features/video';
-import Card from '../features/status/components/card';
+import { List as ImmutableList, fromJS } from 'immutable';
+import { getLocale } from 'mastodon/locales';
+import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
+import MediaGallery from 'mastodon/components/media_gallery';
import Poll from 'mastodon/components/poll';
import Hashtag from 'mastodon/components/hashtag';
+import ModalRoot from 'mastodon/components/modal_root';
+import MediaModal from 'mastodon/features/ui/components/media_modal';
+import Video from 'mastodon/features/video';
+import Card from 'mastodon/features/status/components/card';
import Audio from 'mastodon/features/audio';
-import ModalRoot from '../components/modal_root';
-import { getScrollbarWidth } from '../features/ui/components/modal_root';
-import MediaModal from '../features/ui/components/media_modal';
-import { List as ImmutableList, fromJS } from 'immutable';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index fa58589a6..fb22676e0 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -1,4 +1,3 @@
-import React from 'react';
import { connect } from 'react-redux';
import Status from '../components/status';
import { makeGetStatus } from '../selectors';
@@ -15,7 +14,6 @@ import {
pin,
unpin,
} from '../actions/interactions';
-import { blockAccount } from '../actions/accounts';
import {
muteStatus,
unmuteStatus,
@@ -24,9 +22,10 @@ import {
revealStatus,
} from '../actions/statuses';
import { initMuteModal } from '../actions/mutes';
+import { initBlockModal } from '../actions/blocks';
import { initReport } from '../actions/reports';
import { openModal } from '../actions/modal';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { defineMessages, injectIntl } from 'react-intl';
import { boostModal, deleteModal } from '../initial_state';
import { showAlertForError } from '../actions/alerts';
@@ -35,10 +34,8 @@ const messages = defineMessages({
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
- blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
- blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
});
const makeMapStateToProps = () => {
@@ -56,6 +53,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
onReply (status, router) {
dispatch((_, getState) => {
let state = getState();
+
if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),
@@ -137,16 +135,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
onBlock (status) {
const account = status.get('account');
- dispatch(openModal('CONFIRM', {
- message: @{account.get('acct')} }} />,
- confirm: intl.formatMessage(messages.blockConfirm),
- onConfirm: () => dispatch(blockAccount(account.get('id'))),
- secondary: intl.formatMessage(messages.blockAndReport),
- onSecondary: () => {
- dispatch(blockAccount(account.get('id')));
- dispatch(initReport(account, status));
- },
- }));
+ dispatch(initBlockModal(account));
},
onReport (status) {
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
index 4d4ae6e82..8728b4806 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -5,7 +5,6 @@ import Header from '../components/header';
import {
followAccount,
unfollowAccount,
- blockAccount,
unblockAccount,
unmuteAccount,
pinAccount,
@@ -16,6 +15,7 @@ import {
directCompose,
} from '../../../actions/compose';
import { initMuteModal } from '../../../actions/mutes';
+import { initBlockModal } from '../../../actions/blocks';
import { initReport } from '../../../actions/reports';
import { openModal } from '../../../actions/modal';
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
@@ -25,9 +25,7 @@ import { List as ImmutableList } from 'immutable';
const messages = defineMessages({
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
- blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
- blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
});
const makeMapStateToProps = () => {
@@ -64,16 +62,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id')));
} else {
- dispatch(openModal('CONFIRM', {
- message: @{account.get('acct')} }} />,
- confirm: intl.formatMessage(messages.blockConfirm),
- onConfirm: () => dispatch(blockAccount(account.get('id'))),
- secondary: intl.formatMessage(messages.blockAndReport),
- onSecondary: () => {
- dispatch(blockAccount(account.get('id')));
- dispatch(initReport(account));
- },
- }));
+ dispatch(initBlockModal(account));
}
},
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.js b/app/javascript/mastodon/features/direct_timeline/components/conversation.js
index ffcd6d281..cc3faf0de 100644
--- a/app/javascript/mastodon/features/direct_timeline/components/conversation.js
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.js
@@ -2,9 +2,28 @@ import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
-import StatusContainer from '../../../containers/status_container';
+import StatusContent from 'mastodon/components/status_content';
+import AttachmentList from 'mastodon/components/attachment_list';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
+import AvatarComposite from 'mastodon/components/avatar_composite';
+import Permalink from 'mastodon/components/permalink';
+import IconButton from 'mastodon/components/icon_button';
+import RelativeTimestamp from 'mastodon/components/relative_timestamp';
+import { HotKeys } from 'react-hotkeys';
-export default class Conversation extends ImmutablePureComponent {
+const messages = defineMessages({
+ more: { id: 'status.more', defaultMessage: 'More' },
+ open: { id: 'conversation.open', defaultMessage: 'View conversation' },
+ reply: { id: 'status.reply', defaultMessage: 'Reply' },
+ markAsRead: { id: 'conversation.mark_as_read', defaultMessage: 'Mark as read' },
+ delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
+ muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+ unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+});
+
+export default @injectIntl
+class Conversation extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
@@ -13,11 +32,12 @@ export default class Conversation extends ImmutablePureComponent {
static propTypes = {
conversationId: PropTypes.string.isRequired,
accounts: ImmutablePropTypes.list.isRequired,
- lastStatusId: PropTypes.string,
+ lastStatus: ImmutablePropTypes.map,
unread:PropTypes.bool.isRequired,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
markRead: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
};
handleClick = () => {
@@ -25,13 +45,25 @@ export default class Conversation extends ImmutablePureComponent {
return;
}
- const { lastStatusId, unread, markRead } = this.props;
+ const { lastStatus, unread, markRead } = this.props;
if (unread) {
markRead();
}
- this.context.router.history.push(`/statuses/${lastStatusId}`);
+ this.context.router.history.push(`/statuses/${lastStatus.get('id')}`);
+ }
+
+ handleMarkAsRead = () => {
+ this.props.markRead();
+ }
+
+ handleReply = () => {
+ this.props.reply(this.props.lastStatus, this.context.router.history);
+ }
+
+ handleDelete = () => {
+ this.props.delete();
}
handleHotkeyMoveUp = () => {
@@ -42,22 +74,88 @@ export default class Conversation extends ImmutablePureComponent {
this.props.onMoveDown(this.props.conversationId);
}
- render () {
- const { accounts, lastStatusId, unread } = this.props;
+ handleConversationMute = () => {
+ this.props.onMute(this.props.lastStatus);
+ }
- if (lastStatusId === null) {
+ handleShowMore = () => {
+ this.props.onToggleHidden(this.props.lastStatus);
+ }
+
+ render () {
+ const { accounts, lastStatus, unread, intl } = this.props;
+
+ if (lastStatus === null) {
return null;
}
+ const menu = [
+ { text: intl.formatMessage(messages.open), action: this.handleClick },
+ null,
+ ];
+
+ menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute });
+
+ if (unread) {
+ menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead });
+ menu.push(null);
+ }
+
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
+
+ const names = accounts.map(a => ).reduce((prev, cur) => [prev, ', ', cur]);
+
+ const handlers = {
+ reply: this.handleReply,
+ open: this.handleClick,
+ moveUp: this.handleHotkeyMoveUp,
+ moveDown: this.handleHotkeyMoveDown,
+ toggleHidden: this.handleShowMore,
+ };
+
return (
-
+
+
+
+
+
+
+
+
+
+
+
+ {names} }} />
+
+
+
+
+
+ {lastStatus.get('media_attachments').size > 0 && (
+
+ )}
+
+
+
+
+
);
}
diff --git a/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js b/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
index bd6f6bfb0..94cef81a7 100644
--- a/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
+++ b/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
@@ -1,19 +1,74 @@
import { connect } from 'react-redux';
import Conversation from '../components/conversation';
-import { markConversationRead } from '../../../actions/conversations';
+import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
+import { makeGetStatus } from 'mastodon/selectors';
+import { replyCompose } from 'mastodon/actions/compose';
+import { openModal } from 'mastodon/actions/modal';
+import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'mastodon/actions/statuses';
+import { defineMessages, injectIntl } from 'react-intl';
-const mapStateToProps = (state, { conversationId }) => {
- const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
+const messages = defineMessages({
+ replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+ replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+});
- return {
- accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
- unread: conversation.get('unread'),
- lastStatusId: conversation.get('last_status', null),
+const mapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ return (state, { conversationId }) => {
+ const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
+ const lastStatusId = conversation.get('last_status', null);
+
+ return {
+ accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
+ unread: conversation.get('unread'),
+ lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }),
+ };
};
};
-const mapDispatchToProps = (dispatch, { conversationId }) => ({
- markRead: () => dispatch(markConversationRead(conversationId)),
+const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({
+
+ markRead () {
+ dispatch(markConversationRead(conversationId));
+ },
+
+ reply (status, router) {
+ dispatch((_, getState) => {
+ let state = getState();
+
+ if (state.getIn(['compose', 'text']).trim().length !== 0) {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.replyMessage),
+ confirm: intl.formatMessage(messages.replyConfirm),
+ onConfirm: () => dispatch(replyCompose(status, router)),
+ }));
+ } else {
+ dispatch(replyCompose(status, router));
+ }
+ });
+ },
+
+ delete () {
+ dispatch(deleteConversation(conversationId));
+ },
+
+ onMute (status) {
+ if (status.get('muted')) {
+ dispatch(unmuteStatus(status.get('id')));
+ } else {
+ dispatch(muteStatus(status.get('id')));
+ }
+ },
+
+ onToggleHidden (status) {
+ if (status.get('hidden')) {
+ dispatch(revealStatus(status.get('id')));
+ } else {
+ dispatch(hideStatus(status.get('id')));
+ }
+ },
+
});
-export default connect(mapStateToProps, mapDispatchToProps)(Conversation);
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Conversation));
diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js
index 359bb7ffd..cd10e20b7 100644
--- a/app/javascript/mastodon/features/emoji/emoji.js
+++ b/app/javascript/mastodon/features/emoji/emoji.js
@@ -99,4 +99,4 @@ export const buildCustomEmojis = (customEmojis) => {
return emojis;
};
-export const categoriesFromEmojis = customEmojis => customEmojis.reduce((set, emoji) => set.add(emoji.get('category') ? `custom-${emoji.get('category')}` : 'custom'), new Set());
+export const categoriesFromEmojis = customEmojis => customEmojis.reduce((set, emoji) => set.add(emoji.get('category') ? `custom-${emoji.get('category')}` : 'custom'), new Set(['custom']));
diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js
index 62d3c2f06..90c26f0c3 100644
--- a/app/javascript/mastodon/features/favourites/index.js
+++ b/app/javascript/mastodon/features/favourites/index.js
@@ -27,7 +27,9 @@ class Favourites extends ImmutablePureComponent {
};
componentWillMount () {
- this.props.dispatch(fetchFavourites(this.props.params.statusId));
+ if (!this.props.accountIds) {
+ this.props.dispatch(fetchFavourites(this.props.params.statusId));
+ }
}
componentWillReceiveProps (nextProps) {
diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js
index 3913bf8d0..9e635d250 100644
--- a/app/javascript/mastodon/features/followers/index.js
+++ b/app/javascript/mastodon/features/followers/index.js
@@ -40,8 +40,10 @@ class Followers extends ImmutablePureComponent {
};
componentWillMount () {
- this.props.dispatch(fetchAccount(this.props.params.accountId));
- this.props.dispatch(fetchFollowers(this.props.params.accountId));
+ if (!this.props.accountIds) {
+ this.props.dispatch(fetchAccount(this.props.params.accountId));
+ this.props.dispatch(fetchFollowers(this.props.params.accountId));
+ }
}
componentWillReceiveProps (nextProps) {
diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js
index 8e126f4c3..284ae2c11 100644
--- a/app/javascript/mastodon/features/following/index.js
+++ b/app/javascript/mastodon/features/following/index.js
@@ -40,8 +40,10 @@ class Following extends ImmutablePureComponent {
};
componentWillMount () {
- this.props.dispatch(fetchAccount(this.props.params.accountId));
- this.props.dispatch(fetchFollowing(this.props.params.accountId));
+ if (!this.props.accountIds) {
+ this.props.dispatch(fetchAccount(this.props.params.accountId));
+ this.props.dispatch(fetchFollowing(this.props.params.accountId));
+ }
}
componentWillReceiveProps (nextProps) {
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index f6d90580b..67ec7665b 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -77,16 +77,14 @@ class GettingStarted extends ImmutablePureComponent {
};
componentDidMount () {
- const { myAccount, fetchFollowRequests, multiColumn } = this.props;
+ const { fetchFollowRequests, multiColumn } = this.props;
if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) {
this.context.router.history.replace('/timelines/home');
return;
}
- if (myAccount.get('locked')) {
- fetchFollowRequests();
- }
+ fetchFollowRequests();
}
render () {
@@ -134,7 +132,7 @@ class GettingStarted extends ImmutablePureComponent {
height += 48*3;
- if (myAccount.get('locked')) {
+ if (myAccount.get('locked') || unreadFollowRequests > 0) {
navItems.push();
height += 48;
}
diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.js b/app/javascript/mastodon/features/notifications/components/filter_bar.js
index 3f3e6ab7d..2fd28d832 100644
--- a/app/javascript/mastodon/features/notifications/components/filter_bar.js
+++ b/app/javascript/mastodon/features/notifications/components/filter_bar.js
@@ -64,7 +64,7 @@ class FilterBar extends React.PureComponent {
onClick={this.onClick('mention')}
title={intl.formatMessage(tooltips.mentions)}
>
-
+