Compare commits

...

64 commits

Author SHA1 Message Date
Eugen Rochko
9bace2dd88 Bump version to 2.9.4 2020-02-27 09:03:01 +01:00
Eugen Rochko
821b860f86 Fix leak of arbitrary statuses through unfavourite action in REST API (#13161) 2020-02-27 09:01:49 +01:00
Eugen Rochko
06f906acac Bump version to 2.9.3 2019-08-10 00:23:40 +02:00
ThibG
80d8ff97e4 Fix “read more” button behing hidden (regression from #11404) (#11522)
* Fix “read more” button behing hidden (regression from #11404)

This has the side-effect of putting the “Read more” button below possibly
trunctated polls instead of putting the poll below the “Read more”

* Remove dead code
2019-08-10 00:18:22 +02:00
Eugen Rochko
d5963d9401 Fix crash when saving invalid domain name (#11528)
Fix #7629
2019-08-09 21:35:57 +02:00
Eugen Rochko
b95281b533 Fix pinned statuses API returning pagination headers (#11526)
Fix #10227
2019-08-09 21:35:35 +02:00
Eugen Rochko
5432edb5a7 Add GIF and WebP support for custom emojis (#11519)
Fix #11466
2019-08-09 21:35:33 +02:00
Eugen Rochko
6861534d9c Fix "cancel follow request" button having unreadable text in web UI (#11521)
Fix #11478
2019-08-09 21:34:54 +02:00
ThibG
80e391afcd Improve focus handling with dropdown menus (#11511)
- Focus first item when activated via keyboard
- When the dropdown menu closes, give back the focus to
  the actual element which was focused prior to opening the menu
2019-08-09 21:34:54 +02:00
ThibG
c69f190af9 Fix image uploads being perfectly white when canvas read access is blocked (#11499)
Fixes #11496
2019-08-09 21:34:54 +02:00
ThibG
cec93c35d8 Improve keyboard navigation in privacy dropdown (#11492)
* Trap tab in privacy dropdown

* Give focus back to last focused element when privacy dropdown menu closes

* Actually give back focus to the element that had it before clicking the dropdown
2019-08-09 21:34:54 +02:00
ThibG
ad6fcb2d9c Improve dropdown menu keyboard navigation (#11491)
* Allow selecting menu items with the space bar in status dropdown menus

* Fix modals opened by keyboard navigation being immediately closed

* Fix menu items triggering modal actions

* Add Tab trapping inside dropdown menu

* Give focus back to last focused element when status dropdown menu closes
2019-08-09 21:34:54 +02:00
ThibG
d8cf2a0fb6 Fix privacy dropdown active state when dropdown is placed on top of it (#11495) 2019-08-09 21:34:54 +02:00
ThibG
21e3671e32 Trap tab in modals (#11493) 2019-08-09 21:34:54 +02:00
Jeong Arm
68da55e50c Fix timestamp on featured tag (#11477)
It resolves #11338
2019-08-09 21:34:53 +02:00
ThibG
3f7614f98a Disable list title validation button when list title is empty (#11475) 2019-08-09 21:34:53 +02:00
Jeong Arm
c1bc34da04 Prevent archiving when user set "noindex" (#11421) 2019-08-09 21:34:52 +02:00
Eugen Rochko
a0896ae4bf Remove timestamps from converted images to make them deterministic (#11408) 2019-08-09 21:34:25 +02:00
Clar Fon
91fb945b0e Remove pre from version, add extra suffix variable (#11407) 2019-08-09 21:34:24 +02:00
Eugen Rochko
ed27803822 Change account domain block to clear out notifications and follows (#11393) 2019-08-09 21:34:23 +02:00
Daigo 3 Dango
4e4f73b231 Bind servers to 0.0.0.0 in Procfile (#11378)
* Bind to 0.0.0.0

* Make Procfile common to main and streaming apps
2019-08-09 21:33:19 +02:00
Eugen Rochko
9bb23b8d19 Change locale detection to run once per session (#8657)
Fix #6462
2019-08-09 21:33:19 +02:00
ThibG
dead24a773 Disallow numeric-only hashtags (#11363)
* Add spec covering numeric-only hashtags

* Fix hashtag regex
2019-08-09 21:33:19 +02:00
koyu
d8b8c88c22 Added logout to dropdown menu (#11353)
* Added logout to dropdown menu

* Triggering build-and-test with empty commit as it seems it failed due to some internal failure

* Looks fine, ready to review

* Added changes from review

* method can be null without any problems

* Also target can be null
2019-08-09 21:33:18 +02:00
ThibG
ad0866804e Fix avatar animation on hover when not logged in (#11349) 2019-08-09 21:33:18 +02:00
ThibG
6c4a196b53 Fix sanitizing lists contents (#11354)
* Add test

* Fix code for sanitizing nested lists stripping all tags
2019-08-09 21:33:18 +02:00
Eugen Rochko
28f3b13c63 Change Dockerfile to bind to 0.0.0.0 instead of docker-compose.yml (#11351) 2019-08-09 21:33:18 +02:00
Eugen Rochko
8c445c80b5 Fix only one middle dot being recognized in hashtags (#11345)
Fix #10934
2019-08-09 21:33:18 +02:00
Eugen Rochko
212848b66e Change language detection to include hashtags as words (#11341) 2019-08-09 21:33:18 +02:00
Eugen Rochko
227c561064 Change terms and privacy policy pages to always be accessible (#11334)
Fix #11328
2019-08-09 21:33:17 +02:00
Daigo 3 Dango
2e244b7401 Make puma bind address configurable with BIND env var (#11326) 2019-08-09 21:32:48 +02:00
Eugen Rochko
291d868773 Change default interface of web and streaming from 0.0.0.0 to 127.0.0.1 (#11302) 2019-08-09 21:32:48 +02:00
han@highemelry
b21c630043 Change the retry limit in error of web push notification (#11292)
- Change the maximum count of retry for web push notification (Default -> 5).
   - In case of high load of subscribe server, the retries will be repeated many times.
   - Because the retries occupy the default queue, maximum retry count should be reduced.
2019-08-09 21:32:48 +02:00
Eugen Rochko
f2795699dd Change ActivityPub::DeliveryWorker to not retry HTTP 501 errors (#11233) 2019-08-09 21:32:40 +02:00
Eugen Rochko
d9a024840e Change domain block behaviour to prevent creation of accounts from suspended domains (#11219) 2019-08-09 21:32:40 +02:00
ThibG
c8bf30df92 Fix animate on hover in poll options without CW (#11404) 2019-08-06 21:53:23 +02:00
ThibG
7f9431c306 Play animated custom emoji on hover (#11348)
* Play animated custom emoji on hover in status

* Play animated custom emoji on hover in display names

* Play animated custom emoji on hover in bios/bio fields

* Add support for animation on hover on public pages emojis too

* Fix tests

* Code style cleanup
2019-08-06 21:53:21 +02:00
ThibG
af410c0706 Display custom emoji in bio field names (#11350)
Already displayed in public pages, but not WebUI
2019-08-06 21:52:25 +02:00
ThibG
16f348431b Only scroll to the compose form if it's not horizontally in the viewport (#11246)
Avoids jumping the scroll around vertically when giving it focus and
editing long toots.
2019-08-06 21:52:25 +02:00
ThibG
6abd849803 When deleting & redrafting a poll, fill in closest expires_in (#11203)
Use the smallest preset expires_in such that the new poll would
not expire before the old one.

In the typical case of a quick delete & redraft, this results in
using the same poll duration.

Fixes #10567
2019-08-06 21:52:25 +02:00
ThibG
99b27a8b4b When sending a toot, ensure a CW is only set if the CW field is visible (#11206)
In some occasions, such as the browser or a browser extension auto-filling
the existing but disabled/hidden CW field, a CW can be set without the user
knowing.
2019-08-06 21:52:25 +02:00
ThibG
39741fa2cd Scroll to compose form rather than reply indicator on focus (#11182) 2019-08-06 21:52:25 +02:00
ThibG
5b3d70ffa7 Display FTS warning based on actual search term, not the one being typed (#11202)
Follow-up to #11112
2019-08-06 21:52:00 +02:00
ThibG
011909262a Add message telling FTS is disabled when no toot can be found because of this (#11112)
* Add message telling FTS is disabled when no toot can be found because of this

Fixes #11082

* Remove info icon and reword message
2019-08-06 21:52:00 +02:00
Eugen Rochko
69680db8a2 Fix unnecessary SQL query performed on unauthenticated requests (#11179) 2019-08-06 21:52:00 +02:00
ThibG
6e28da2139 Apply filters to poll options (#11174)
* Apply filters to poll options in WebUI

Fixes #11128

* Apply filters to poll options server-side

* Add poll options to searchable text
2019-08-06 21:52:00 +02:00
Eugen Rochko
74982c71b0 Fix delete regression (#11450)
Regression from ff789a751a
2019-08-06 21:51:29 +02:00
ThibG
c83c87fbe2 Fix boosting & unboosting preventing a boost from appearing in the TL (#11405)
* Fix boosting & unboosting preventing a boost from appearing in the TL

* Add tests

* Avoids side effects when aggregate_reblogs isn't true
2019-08-06 21:51:29 +02:00
ThibG
363afe5e05 Memoize ancestorIds and descendantIds in detailed status view (#11234) 2019-08-06 21:51:02 +02:00
ThibG
d588173ab3 Optimize makeGetStatus (#11211)
* Optimize makeGetStatus

Because `ImmutableList.filter` always returns a new object and `createSelector`
memoizes based on object identity, the selector returned by `makeGetStatus`
would *always* execute.

To avoid that, we wrap `getFilters` into a new memoizer that memoizes based on
deep equality, thus returning the same object as long as the filters haven't
changed, allowing the memoization of `makeGetStatus` to work.

Furthermore, we memoize the compiled regexs instead of recomputing them each
time the selector is called.

* Fix memoized result being cleared too often

* Make notifications use memoized getFiltersRegex
2019-08-06 21:51:02 +02:00
Eugen Rochko
d1d3684fb5 Fix alerts booleans not being typecast correctly in push subscription (#11343)
* Fix `alerts` booleans not being typecast correctly in push subscription

Fix #10789

* Fix typo
2019-08-06 21:50:45 +02:00
ThibG
6a3876bdaa Fix some flash notices/alerts staying on unrelated pages (#11364) 2019-08-06 21:50:45 +02:00
Eugen Rochko
5cd97c62a0 Remove unused StatsD code and expose StatsD as a global variable (#11232)
The instrumentation code was used for StatsD metrics collection
prior to the switch to the nsa gem and should have been removed
at that point as it no longer does anything at all
2019-08-06 21:50:45 +02:00
Eugen Rochko
769bbd511f Fix statsd UDP sockets not being cleaned up in Sidekiq (#11230) 2019-08-06 21:50:45 +02:00
ThibG
5d79df0273 Fix expiration date of filters being set to “Never” when editing them (#11204)
When editing a custom filter, select the shortest preset duration that
still covers the remaining time of that filter.

Fixes #9506
2019-08-06 21:50:45 +02:00
Eugen Rochko
0367ddb62c Fix support for MP4 files that are actually M4V files (#11210)
Resolve #11187
2019-08-06 21:50:45 +02:00
Georg Gadinger
221110c5d7 Update fuubar dependency to 2.4.1 (#11248)
See also: thekompanee/fuubar#111
2019-08-06 21:50:19 +02:00
ThibG
8904487324 Fix invites not being disabled upon account suspension (#11412)
* Disable invite links from disabled/suspended users

* Add has_many invites relationship to users

* Destroy unused invites when suspending an account
2019-08-06 21:50:06 +02:00
ThibG
6782922584 Fix BlockService trying to reject incorrect follow request (#11288)
Fixes #11148
2019-08-06 21:50:05 +02:00
ThibG
8066717558 Fix Status.remote scope matching *all* statuses (#11265) 2019-08-06 21:50:05 +02:00
ThibG
5a06f68f0e Fix BackupService crashing when an attachment is missing (#11241)
* Fix BackupService crashing when an attachment is missing

For various reasons such as admin error or out-of-sync media and
database backups, it might be possible for local attachments to be lost.

This commit allows the BackupService to continue its work even if some media
file is missing.

* Change error message
2019-08-06 21:50:05 +02:00
Eugen Rochko
aef567cb9d Fix option to send e-mail notification about account action always being true (#11242) 2019-08-06 21:50:05 +02:00
ThibG
de747948a1 Fix swiping columns on mobile sometimes failing (#11200)
Fixes #9779
2019-08-06 21:50:05 +02:00
ThibG
c95ce1f3ac Fix account URI in UpdatePollSerializer (#11194)
* Fix account URI in UpdatePollSerializer

Fixes #11185

* Add specs
2019-08-06 21:50:05 +02:00
104 changed files with 999 additions and 357 deletions

View file

@ -3,6 +3,70 @@ Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [2.9.4] - 2020-02-27
### Security
- Fix leak of arbitrary statuses through unfavourite action in REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/13161))
## [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 ## [2.9.2] - 2019-06-22
### Added ### Added

View file

@ -112,6 +112,7 @@ ENV NODE_ENV="production"
# Tell rails to serve static files # Tell rails to serve static files
ENV RAILS_SERVE_STATIC_FILES="true" ENV RAILS_SERVE_STATIC_FILES="true"
ENV BIND="0.0.0.0"
# Set the run user # Set the run user
USER mastodon USER mastodon

View file

@ -231,7 +231,7 @@ GEM
fugit (1.1.6) fugit (1.1.6)
et-orbi (~> 1.1, >= 1.1.6) et-orbi (~> 1.1, >= 1.1.6)
raabro (~> 1.1) raabro (~> 1.1)
fuubar (2.4.0) fuubar (2.4.1)
rspec-core (~> 3.0) rspec-core (~> 3.0)
ruby-progressbar (~> 1.4) ruby-progressbar (~> 1.4)
get_process_mem (0.2.3) get_process_mem (0.2.3)

View file

@ -1,2 +1,14 @@
web: bundle exec puma -C config/puma.rb web: if [ "$RUN_STREAMING" != "true" ]; then BIND=0.0.0.0 bundle exec puma -C config/puma.rb; else BIND=0.0.0.0 node ./streaming; fi
worker: bundle exec sidekiq worker: bundle exec sidekiq
# For the streaming API, you need a separate app that shares Postgres and Redis:
#
# heroku create
# heroku buildpacks:add heroku/nodejs
# heroku config:set RUN_STREAMING=true
# heroku addons:attach <main-app>::DATABASE
# heroku addons:attach <main-app>::REDIS
#
# and let the main app use the separate app:
#
# heroku config:set STREAMING_API_BASE_URL=wss://<streaming-app>.herokuapp.com -a <main-app>

View file

@ -51,7 +51,7 @@ class StatusesIndex < Chewy::Index
field :id, type: 'long' field :id, type: 'long'
field :account_id, type: 'long' field :account_id, type: 'long'
field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).join("\n\n") } do field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).concat(status.preloadable_poll ? status_preloadable_poll.options : []).join("\n\n") } do
field :stemmed, type: 'text', analyzer: 'content' field :stemmed, type: 'text', analyzer: 'content'
end end

View file

@ -5,6 +5,8 @@ class AboutController < ApplicationController
before_action :set_instance_presenter, only: [:show, :more, :terms] before_action :set_instance_presenter, only: [:show, :more, :terms]
skip_before_action :check_user_permissions, only: [:more, :terms]
def show def show
@hide_navbar = true @hide_navbar = true
end end

View file

@ -17,7 +17,7 @@ module Admin
if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block) if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
@domain_block.save @domain_block.save
flash[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
@domain_block.errors[:domain].clear @domain_block.errors[:domain].clear
render :new render :new
else else

View file

@ -3,7 +3,8 @@
class Api::V1::Accounts::StatusesController < Api::BaseController class Api::V1::Accounts::StatusesController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' } before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :set_account before_action :set_account
after_action :insert_pagination_headers
after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) }
respond_to :json respond_to :json

View file

@ -67,8 +67,7 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController
@status = Status.find(params[:status_id]) @status = Status.find(params[:status_id])
authorize @status, :show? authorize @status, :show?
rescue Mastodon::NotPermittedError rescue Mastodon::NotPermittedError
# Reraise in order to get a 404 instead of a 403 error code not_found
raise ActiveRecord::RecordNotFound
end end
def pagination_params(core_params) def pagination_params(core_params)

View file

@ -5,34 +5,26 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:favourites' } before_action -> { doorkeeper_authorize! :write, :'write:favourites' }
before_action :require_user! before_action :require_user!
before_action :set_status
respond_to :json respond_to :json
def create def create
@status = favourited_status FavouriteService.new.call(current_account, @status)
render json: @status, serializer: REST::StatusSerializer render json: @status, serializer: REST::StatusSerializer
end end
def destroy def destroy
@status = requested_status UnfavouriteWorker.perform_async(current_account.id, @status.id)
@favourites_map = { @status.id => false } render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false })
UnfavouriteWorker.perform_async(current_user.account_id, @status.id)
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, favourites_map: @favourites_map)
end end
private private
def favourited_status def set_status
service_result.status.reload @status = Status.find(params[:status_id])
end authorize @status, :show?
rescue Mastodon::NotPermittedError
def service_result not_found
FavouriteService.new.call(current_user.account, requested_status)
end
def requested_status
Status.find(params[:status_id])
end end
end end

View file

@ -64,8 +64,7 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController
@status = Status.find(params[:status_id]) @status = Status.find(params[:status_id])
authorize @status, :show? authorize @status, :show?
rescue Mastodon::NotPermittedError rescue Mastodon::NotPermittedError
# Reraise in order to get a 404 instead of a 403 error code not_found
raise ActiveRecord::RecordNotFound
end end
def pagination_params(core_params) def pagination_params(core_params)

View file

@ -5,32 +5,33 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:statuses' } before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
before_action :require_user! before_action :require_user!
before_action :set_reblog
respond_to :json respond_to :json
def create def create
@status = ReblogService.new.call(current_user.account, status_for_reblog, reblog_params) @status = ReblogService.new.call(current_account, @reblog, reblog_params)
render json: @status, serializer: REST::StatusSerializer render json: @status, serializer: REST::StatusSerializer
end end
def destroy def destroy
@status = status_for_destroy.reblog @status = current_account.statuses.find_by(reblog_of_id: @reblog.id)
@reblogs_map = { @status.id => false }
authorize status_for_destroy, :unreblog? if @status
RemovalWorker.perform_async(status_for_destroy.id) authorize @status, :unreblog?
RemovalWorker.perform_async(@status.id)
end
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map) render json: @reblog, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false })
end end
private private
def status_for_reblog def set_reblog
Status.find params[:status_id] @reblog = Status.find(params[:status_id])
end authorize @reblog, :show?
rescue Mastodon::NotPermittedError
def status_for_destroy not_found
current_user.account.statuses.where(reblog_of_id: params[:status_id]).first!
end end
def reblog_params def reblog_params

View file

@ -91,11 +91,15 @@ class ApplicationController < ActionController::Base
end end
def current_account def current_account
@current_account ||= current_user.try(:account) return @current_account if defined?(@current_account)
@current_account = current_user&.account
end end
def current_session def current_session
@current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id']) return @current_session if defined?(@current_session)
@current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present?
end end
def current_theme def current_theme
@ -126,11 +130,7 @@ class ApplicationController < ActionController::Base
def respond_with_error(code) def respond_with_error(code)
respond_to do |format| respond_to do |format|
format.any { head code } format.any { head code }
format.html { render "errors/#{code}", layout: 'error', status: code }
format.html do
set_locale
render "errors/#{code}", layout: 'error', status: code
end
end end
end end

View file

@ -4,16 +4,19 @@ module Localized
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
before_action :set_locale around_action :set_locale
end end
private private
def set_locale def set_locale
I18n.locale = default_locale locale = current_user.locale if respond_to?(:user_signed_in?) && user_signed_in?
I18n.locale = current_user.locale if user_signed_in? locale ||= session[:locale] ||= default_locale
rescue I18n::InvalidLocale locale = default_locale unless I18n.available_locales.include?(locale.to_sym)
I18n.locale = default_locale
I18n.with_locale(locale) do
yield
end
end end
def default_locale def default_locale

View file

@ -39,7 +39,7 @@ class InvitesController < ApplicationController
private private
def invites def invites
Invite.where(user: current_user).order(id: :desc) current_user.invites.order(id: :desc)
end end
def resource_params def resource_params

View file

@ -14,7 +14,7 @@ module Settings
def create def create
if current_user.validate_and_consume_otp!(confirmation_params[:code]) if current_user.validate_and_consume_otp!(confirmation_params[:code])
flash[:notice] = I18n.t('two_factor_authentication.enabled_success') flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success')
current_user.otp_required_for_login = true current_user.otp_required_for_login = true
@recovery_codes = current_user.generate_otp_backup_codes! @recovery_codes = current_user.generate_otp_backup_codes!

View file

@ -10,7 +10,7 @@ module Settings
def create def create
@recovery_codes = current_user.generate_otp_backup_codes! @recovery_codes = current_user.generate_otp_backup_codes!
current_user.save! current_user.save!
flash[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated') flash.now[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated')
render :index render :index
end end
end end

View file

@ -139,7 +139,7 @@ export function submitCompose(routerHistory) {
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
media_ids: media.map(item => item.get('id')), media_ids: media.map(item => item.get('id')),
sensitive: getState().getIn(['compose', 'sensitive']), sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
visibility: getState().getIn(['compose', 'privacy']), visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null), poll: getState().getIn(['compose', 'poll'], null),
}, { }, {

View file

@ -23,6 +23,7 @@ export function blockDomain(domain) {
api(getState).post('/api/v1/domain_blocks', { domain }).then(() => { api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
const at_domain = '@' + domain; const at_domain = '@' + domain;
const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
dispatch(blockDomainSuccess(domain, accounts)); dispatch(blockDomainSuccess(domain, accounts));
}).catch(err => { }).catch(err => {
dispatch(blockDomainFail(domain, err)); dispatch(blockDomainFail(domain, err));

View file

@ -22,7 +22,7 @@ export function normalizeAccount(account) {
if (account.fields) { if (account.fields) {
account.fields = account.fields.map(pair => ({ account.fields = account.fields.map(pair => ({
...pair, ...pair,
name_emojified: emojify(escapeTextContentForBrowser(pair.name)), name_emojified: emojify(escapeTextContentForBrowser(pair.name), emojiMap),
value_emojified: emojify(pair.value, emojiMap), value_emojified: emojify(pair.value, emojiMap),
value_plain: unescapeHTML(pair.value), value_plain: unescapeHTML(pair.value),
})); }));
@ -56,7 +56,7 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.hidden = normalOldStatus.get('hidden'); normalStatus.hidden = normalOldStatus.get('hidden');
} else { } else {
const spoilerText = normalStatus.spoiler_text || ''; const spoilerText = normalStatus.spoiler_text || '';
const searchContent = [spoilerText, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const emojiMap = makeEmojiMap(normalStatus); const emojiMap = makeEmojiMap(normalStatus);
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;

View file

@ -9,8 +9,9 @@ export function openModal(type, props) {
}; };
}; };
export function closeModal() { export function closeModal(type) {
return { return {
type: MODAL_CLOSE, type: MODAL_CLOSE,
modalType: type,
}; };
}; };

View file

@ -11,7 +11,7 @@ import { saveSettings } from './settings';
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { unescapeHTML } from '../utils/html'; import { unescapeHTML } from '../utils/html';
import { getFilters, regexFromFilters } from '../selectors'; import { getFiltersRegex } from '../selectors';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@ -43,13 +43,13 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true); const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
const filters = getFilters(getState(), { contextType: 'notifications' }); const filters = getFiltersRegex(getState(), { contextType: 'notifications' });
let filtered = false; let filtered = false;
if (notification.type === 'mention') { if (notification.type === 'mention') {
const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible'))); const dropRegex = filters[0];
const regex = regexFromFilters(filters); const regex = filters[1];
const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content); const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
if (dropRegex && dropRegex.test(searchIndex)) { if (dropRegex && dropRegex.test(searchIndex)) {

View file

@ -48,7 +48,7 @@ export function submitSearch() {
dispatch(importFetchedStatuses(response.data.statuses)); dispatch(importFetchedStatuses(response.data.statuses));
} }
dispatch(fetchSearchSuccess(response.data)); dispatch(fetchSearchSuccess(response.data, value));
dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
}).catch(error => { }).catch(error => {
dispatch(fetchSearchFail(error)); dispatch(fetchSearchFail(error));
@ -62,10 +62,11 @@ export function fetchSearchRequest() {
}; };
}; };
export function fetchSearchSuccess(results) { export function fetchSearchSuccess(results, searchTerm) {
return { return {
type: SEARCH_FETCH_SUCCESS, type: SEARCH_FETCH_SUCCESS,
results, results,
searchTerm,
}; };
}; };

View file

@ -12,6 +12,7 @@ export default class Button extends React.PureComponent {
secondary: PropTypes.bool, secondary: PropTypes.bool,
size: PropTypes.number, size: PropTypes.number,
className: PropTypes.string, className: PropTypes.string,
title: PropTypes.string,
style: PropTypes.object, style: PropTypes.object,
children: PropTypes.node, children: PropTypes.node,
}; };
@ -54,6 +55,7 @@ export default class Button extends React.PureComponent {
onClick={this.handleClick} onClick={this.handleClick}
ref={this.setRef} ref={this.setRef}
style={style} style={style}
title={this.props.title}
> >
{this.props.text || this.props.children} {this.props.text || this.props.children}
</button> </button>

View file

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { autoPlayGif } from 'mastodon/initial_state';
export default class DisplayName extends React.PureComponent { export default class DisplayName extends React.PureComponent {
@ -10,6 +11,47 @@ export default class DisplayName extends React.PureComponent {
localDomain: PropTypes.string, localDomain: PropTypes.string,
}; };
_updateEmojis () {
const node = this.node;
if (!node || autoPlayGif) {
return;
}
const emojis = node.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
if (emoji.classList.contains('status-emoji')) {
continue;
}
emoji.classList.add('status-emoji');
emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
}
}
componentDidMount () {
this._updateEmojis();
}
componentDidUpdate () {
this._updateEmojis();
}
handleEmojiMouseEnter = ({ target }) => {
target.src = target.getAttribute('data-original');
}
handleEmojiMouseLeave = ({ target }) => {
target.src = target.getAttribute('data-static');
}
setRef = (c) => {
this.node = c;
}
render () { render () {
const { others, localDomain } = this.props; const { others, localDomain } = this.props;
@ -39,7 +81,7 @@ export default class DisplayName extends React.PureComponent {
} }
return ( return (
<span className='display-name'> <span className='display-name' ref={this.setRef}>
{displayName} {suffix} {displayName} {suffix}
</span> </span>
); );

View file

@ -45,7 +45,9 @@ class DropdownMenu extends React.PureComponent {
document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('keydown', this.handleKeyDown, false); document.addEventListener('keydown', this.handleKeyDown, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) this.focusedItem.focus(); if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus();
}
this.setState({ mounted: true }); this.setState({ mounted: true });
} }
@ -81,6 +83,18 @@ class DropdownMenu extends React.PureComponent {
element.focus(); element.focus();
} }
break; break;
case 'Tab':
if (e.shiftKey) {
element = items[index-1] || items[items.length-1];
} else {
element = items[index+1] || items[0];
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
break;
case 'Home': case 'Home':
element = items[0]; element = items[0];
if (element) { if (element) {
@ -93,11 +107,14 @@ class DropdownMenu extends React.PureComponent {
element.focus(); element.focus();
} }
break; break;
case 'Escape':
this.props.onClose();
break;
} }
} }
handleItemKeyDown = e => { handleItemKeyPress = e => {
if (e.key === 'Enter') { if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e); this.handleClick(e);
} }
} }
@ -122,11 +139,11 @@ class DropdownMenu extends React.PureComponent {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />; return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
} }
const { text, href = '#' } = option; const { text, href = '#', target = '_blank', method } = option;
return ( return (
<li className='dropdown-menu__item' key={`${text}-${i}`}> <li className='dropdown-menu__item' key={`${text}-${i}`}>
<a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyDown={this.handleItemKeyDown} data-index={i}> <a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
{text} {text}
</a> </a>
</li> </li>
@ -193,25 +210,41 @@ export default class Dropdown extends React.PureComponent {
} else { } else {
const { top } = target.getBoundingClientRect(); const { top } = target.getBoundingClientRect();
const placement = top * 2 < innerHeight ? 'bottom' : 'top'; const placement = top * 2 < innerHeight ? 'bottom' : 'top';
this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click'); this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click');
} }
} }
handleClose = () => { handleClose = () => {
if (this.activeElement) {
this.activeElement.focus();
this.activeElement = null;
}
this.props.onClose(this.state.id); this.props.onClose(this.state.id);
} }
handleKeyDown = e => { handleMouseDown = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
}
handleButtonKeyDown = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleMouseDown();
break;
}
}
handleKeyPress = (e) => {
switch(e.key) { switch(e.key) {
case ' ': case ' ':
case 'Enter': case 'Enter':
this.handleClick(e); this.handleClick(e);
e.stopPropagation();
e.preventDefault(); e.preventDefault();
break; break;
case 'Escape':
this.handleClose();
break;
} }
} }
@ -249,7 +282,7 @@ export default class Dropdown extends React.PureComponent {
const open = this.state.id === openDropdownId; const open = this.state.id === openDropdownId;
return ( return (
<div onKeyDown={this.handleKeyDown}> <div>
<IconButton <IconButton
icon={icon} icon={icon}
title={title} title={title}
@ -258,6 +291,9 @@ export default class Dropdown extends React.PureComponent {
size={size} size={size}
ref={this.setTargetRef} ref={this.setTargetRef}
onClick={this.handleClick} onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
/> />
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}> <Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>

View file

@ -12,6 +12,9 @@ export default class IconButton extends React.PureComponent {
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired, icon: PropTypes.string.isRequired,
onClick: PropTypes.func, onClick: PropTypes.func,
onMouseDown: PropTypes.func,
onKeyDown: PropTypes.func,
onKeyPress: PropTypes.func,
size: PropTypes.number, size: PropTypes.number,
active: PropTypes.bool, active: PropTypes.bool,
pressed: PropTypes.bool, pressed: PropTypes.bool,
@ -42,6 +45,24 @@ export default class IconButton extends React.PureComponent {
} }
} }
handleKeyPress = (e) => {
if (this.props.onKeyPress && !this.props.disabled) {
this.props.onKeyPress(e);
}
}
handleMouseDown = (e) => {
if (!this.props.disabled && this.props.onMouseDown) {
this.props.onMouseDown(e);
}
}
handleKeyDown = (e) => {
if (!this.props.disabled && this.props.onKeyDown) {
this.props.onKeyDown(e);
}
}
render () { render () {
const style = { const style = {
fontSize: `${this.props.size}px`, fontSize: `${this.props.size}px`,
@ -84,6 +105,9 @@ export default class IconButton extends React.PureComponent {
title={title} title={title}
className={classes} className={classes}
onClick={this.handleClick} onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
onKeyPress={this.handleKeyPress}
style={style} style={style}
tabIndex={tabIndex} tabIndex={tabIndex}
disabled={disabled} disabled={disabled}
@ -103,6 +127,9 @@ export default class IconButton extends React.PureComponent {
title={title} title={title}
className={classes} className={classes}
onClick={this.handleClick} onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
onKeyPress={this.handleKeyPress}
style={style} style={style}
tabIndex={tabIndex} tabIndex={tabIndex}
disabled={disabled} disabled={disabled}

View file

@ -21,8 +21,30 @@ export default class ModalRoot extends React.PureComponent {
} }
} }
handleKeyDown = (e) => {
if (e.key === 'Tab') {
const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none');
const index = focusable.indexOf(e.target);
let element;
if (e.shiftKey) {
element = focusable[index - 1] || focusable[focusable.length - 1];
} else {
element = focusable[index + 1] || focusable[0];
}
if (element) {
element.focus();
e.stopPropagation();
e.preventDefault();
}
}
}
componentDidMount () { componentDidMount () {
window.addEventListener('keyup', this.handleKeyUp, false); window.addEventListener('keyup', this.handleKeyUp, false);
window.addEventListener('keydown', this.handleKeyDown, false);
} }
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
@ -52,6 +74,7 @@ export default class ModalRoot extends React.PureComponent {
componentWillUnmount () { componentWillUnmount () {
window.removeEventListener('keyup', this.handleKeyUp); window.removeEventListener('keyup', this.handleKeyUp);
window.removeEventListener('keydown', this.handleKeyDown);
} }
getSiblings = () => { getSiblings = () => {

View file

@ -7,6 +7,7 @@ import Permalink from './permalink';
import classnames from 'classnames'; import classnames from 'classnames';
import PollContainer from 'mastodon/containers/poll_container'; import PollContainer from 'mastodon/containers/poll_container';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import { autoPlayGif } from 'mastodon/initial_state';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
@ -71,12 +72,35 @@ export default class StatusContent extends React.PureComponent {
} }
} }
_updateStatusEmojis () {
const node = this.node;
if (!node || autoPlayGif) {
return;
}
const emojis = node.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
if (emoji.classList.contains('status-emoji')) {
continue;
}
emoji.classList.add('status-emoji');
emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
}
}
componentDidMount () { componentDidMount () {
this._updateStatusLinks(); this._updateStatusLinks();
this._updateStatusEmojis();
} }
componentDidUpdate () { componentDidUpdate () {
this._updateStatusLinks(); this._updateStatusLinks();
this._updateStatusEmojis();
} }
onMentionClick = (mention, e) => { onMentionClick = (mention, e) => {
@ -95,6 +119,14 @@ export default class StatusContent extends React.PureComponent {
} }
} }
handleEmojiMouseEnter = ({ target }) => {
target.src = target.getAttribute('data-original');
}
handleEmojiMouseLeave = ({ target }) => {
target.src = target.getAttribute('data-static');
}
handleMouseDown = (e) => { handleMouseDown = (e) => {
this.startXY = [e.clientX, e.clientY]; this.startXY = [e.clientX, e.clientY];
} }
@ -133,11 +165,6 @@ export default class StatusContent extends React.PureComponent {
} }
} }
handleCollapsedClick = (e) => {
e.preventDefault();
this.setState({ collapsed: !this.state.collapsed });
}
setRef = (c) => { setRef = (c) => {
this.node = c; this.node = c;
} }
@ -202,45 +229,26 @@ export default class StatusContent extends React.PureComponent {
); );
} else if (this.props.onClick) { } else if (this.props.onClick) {
const output = [ const output = [
<div <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
ref={this.setRef} <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
tabIndex='0'
key='content' {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
className={classNames} </div>,
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.get('language')}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
/>,
]; ];
if (this.state.collapsed) { if (this.state.collapsed) {
output.push(readMoreButton); output.push(readMoreButton);
} }
if (status.get('poll')) {
output.push(<PollContainer pollId={status.get('poll')} />);
}
return output; return output;
} else { } else {
const output = [ return (
<div <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}>
tabIndex='0' <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
ref={this.setRef}
className='status__content'
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.get('language')}
/>,
];
if (status.get('poll')) { {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
output.push(<PollContainer pollId={status.get('poll')} />); </div>
} );
return output;
} }
} }

View file

@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { status, items }) => ({
}) : openDropdownMenu(id, dropdownPlacement, keyboard)); }) : openDropdownMenu(id, dropdownPlacement, keyboard));
}, },
onClose(id) { onClose(id) {
dispatch(closeModal()); dispatch(closeModal('ACTIONS'));
dispatch(closeDropdownMenu(id)); dispatch(closeDropdownMenu(id));
}, },
}); });

View file

@ -15,6 +15,7 @@ import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
const messages = defineMessages({ const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Cancel follow request' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
@ -79,6 +80,47 @@ class Header extends ImmutablePureComponent {
return !location.pathname.match(/\/(followers|following)\/?$/); return !location.pathname.match(/\/(followers|following)\/?$/);
} }
_updateEmojis () {
const node = this.node;
if (!node || autoPlayGif) {
return;
}
const emojis = node.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
if (emoji.classList.contains('status-emoji')) {
continue;
}
emoji.classList.add('status-emoji');
emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
}
}
componentDidMount () {
this._updateEmojis();
}
componentDidUpdate () {
this._updateEmojis();
}
handleEmojiMouseEnter = ({ target }) => {
target.src = target.getAttribute('data-original');
}
handleEmojiMouseLeave = ({ target }) => {
target.src = target.getAttribute('data-static');
}
setRef = (c) => {
this.node = c;
}
render () { render () {
const { account, intl, domain, identity_proofs } = this.props; const { account, intl, domain, identity_proofs } = this.props;
@ -107,7 +149,7 @@ class Header extends ImmutablePureComponent {
if (!account.get('relationship')) { // Wait until the relationship is loaded if (!account.get('relationship')) { // Wait until the relationship is loaded
actionBtn = ''; actionBtn = '';
} else if (account.getIn(['relationship', 'requested'])) { } else if (account.getIn(['relationship', 'requested'])) {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />; actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
} else if (!account.getIn(['relationship', 'blocking'])) { } else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />; actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />;
} else if (account.getIn(['relationship', 'blocking'])) { } else if (account.getIn(['relationship', 'blocking'])) {
@ -200,7 +242,7 @@ class Header extends ImmutablePureComponent {
const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct'); const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
return ( return (
<div className={classNames('account__header', { inactive: !!account.get('moved') })}> <div className={classNames('account__header', { inactive: !!account.get('moved') })} ref={this.setRef}>
<div className='account__header__image'> <div className='account__header__image'>
<div className='account__header__info'> <div className='account__header__info'>
{info} {info}

View file

@ -15,6 +15,7 @@ const messages = defineMessages({
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' }, domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' }, filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
}); });
export default @injectIntl export default @injectIntl
@ -42,6 +43,8 @@ class ActionBar extends React.PureComponent {
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' }); menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' }); menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.logout), href: '/auth/sign_out', target: null, method: 'delete' });
return ( return (
<div className='compose__action-bar'> <div className='compose__action-bar'>

View file

@ -117,7 +117,10 @@ class ComposeForm extends ImmutablePureComponent {
handleFocus = () => { handleFocus = () => {
if (this.composeForm && !this.props.singleColumn) { if (this.composeForm && !this.props.singleColumn) {
this.composeForm.scrollIntoView(); const { left, right } = this.composeForm.getBoundingClientRect();
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
this.composeForm.scrollIntoView();
}
} }
} }
@ -188,12 +191,12 @@ class ComposeForm extends ImmutablePureComponent {
} }
return ( return (
<div className='compose-form' ref={this.setRef}> <div className='compose-form'>
<WarningContainer /> <WarningContainer />
<ReplyIndicatorContainer /> <ReplyIndicatorContainer />
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`}> <div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef}>
<AutosuggestInput <AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)} placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={this.props.spoilerText} value={this.props.spoilerText}

View file

@ -73,6 +73,19 @@ class PrivacyDropdownMenu extends React.PureComponent {
this.props.onChange(element.getAttribute('data-index')); this.props.onChange(element.getAttribute('data-index'));
} }
break; break;
case 'Tab':
if (e.shiftKey) {
element = this.node.childNodes[index - 1] || this.node.lastChild;
} else {
element = this.node.childNodes[index + 1] || this.node.firstChild;
}
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
e.preventDefault();
e.stopPropagation();
}
break;
case 'Home': case 'Home':
element = this.node.firstChild; element = this.node.firstChild;
if (element) { if (element) {
@ -180,6 +193,9 @@ class PrivacyDropdown extends React.PureComponent {
} }
} else { } else {
const { top } = target.getBoundingClientRect(); const { top } = target.getBoundingClientRect();
if (this.state.open && this.activeElement) {
this.activeElement.focus();
}
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
this.setState({ open: !this.state.open }); this.setState({ open: !this.state.open });
} }
@ -202,7 +218,25 @@ class PrivacyDropdown extends React.PureComponent {
} }
} }
handleMouseDown = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
}
handleButtonKeyDown = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleMouseDown();
break;
}
}
handleClose = () => { handleClose = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus();
}
this.setState({ open: false }); this.setState({ open: false });
} }
@ -229,7 +263,7 @@ class PrivacyDropdown extends React.PureComponent {
return ( return (
<div className={classNames('privacy-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}> <div className={classNames('privacy-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}>
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}> <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === (placement === 'bottom' ? 0 : (this.options.length - 1)) })}>
<IconButton <IconButton
className='privacy-dropdown__value-icon' className='privacy-dropdown__value-icon'
icon={valueOption.icon} icon={valueOption.icon}
@ -239,6 +273,8 @@ class PrivacyDropdown extends React.PureComponent {
active={open} active={open}
inverted inverted
onClick={this.handleToggle} onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
style={{ height: null, lineHeight: '27px' }} style={{ height: null, lineHeight: '27px' }}
/> />
</div> </div>

View file

@ -7,6 +7,7 @@ import StatusContainer from '../../../containers/status_container';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Hashtag from '../../../components/hashtag'; import Hashtag from '../../../components/hashtag';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import { searchEnabled } from '../../../initial_state';
const messages = defineMessages({ const messages = defineMessages({
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' }, dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
@ -20,6 +21,7 @@ class SearchResults extends ImmutablePureComponent {
suggestions: ImmutablePropTypes.list.isRequired, suggestions: ImmutablePropTypes.list.isRequired,
fetchSuggestions: PropTypes.func.isRequired, fetchSuggestions: PropTypes.func.isRequired,
dismissSuggestion: PropTypes.func.isRequired, dismissSuggestion: PropTypes.func.isRequired,
searchTerm: PropTypes.string,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -28,7 +30,7 @@ class SearchResults extends ImmutablePureComponent {
} }
render () { render () {
const { intl, results, suggestions, dismissSuggestion } = this.props; const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
if (results.isEmpty() && !suggestions.isEmpty()) { if (results.isEmpty() && !suggestions.isEmpty()) {
return ( return (
@ -76,6 +78,16 @@ class SearchResults extends ImmutablePureComponent {
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)} {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
</div> </div>
); );
} else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
statuses = (
<div className='search-results__section'>
<h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
<div className='search-results__info'>
<FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching toots by their content is not enabled on this Mastodon server.' />
</div>
</div>
);
} }
if (results.get('hashtags') && results.get('hashtags').size > 0) { if (results.get('hashtags') && results.get('hashtags').size > 0) {

View file

@ -5,6 +5,7 @@ import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestion
const mapStateToProps = state => ({ const mapStateToProps = state => ({
results: state.getIn(['search', 'results']), results: state.getIn(['search', 'results']),
suggestions: state.getIn(['suggestions', 'items']), suggestions: state.getIn(['suggestions', 'items']),
searchTerm: state.getIn(['search', 'searchTerm']),
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({

View file

@ -29,7 +29,7 @@ const emojify = (str, customEmojis = {}) => {
// if you want additional emoji handler, add statements below which set replacement and return true. // if you want additional emoji handler, add statements below which set replacement and return true.
if (shortname in customEmojis) { if (shortname in customEmojis) {
const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url; const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url;
replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`; replacement = `<img draggable="false" class="emojione custom-emoji" alt="${shortname}" title="${shortname}" src="${filename}" data-original="${customEmojis[shortname].url}" data-static="${customEmojis[shortname].static_url}" />`;
return true; return true;
} }
return false; return false;

View file

@ -11,7 +11,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'title']), value: state.getIn(['listEditor', 'title']),
disabled: !state.getIn(['listEditor', 'isChanged']), disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({

View file

@ -66,7 +66,7 @@ class NewListForm extends React.PureComponent {
</label> </label>
<IconButton <IconButton
disabled={disabled} disabled={disabled || !value}
icon='plus' icon='plus'
title={title} title={title}
onClick={this.handleClick} onClick={this.handleClick}

View file

@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { createSelector } from 'reselect';
import { fetchStatus } from '../../actions/statuses'; import { fetchStatus } from '../../actions/statuses';
import MissingIndicator from '../../components/missing_indicator'; import MissingIndicator from '../../components/missing_indicator';
import DetailedStatus from './components/detailed_status'; import DetailedStatus from './components/detailed_status';
@ -63,39 +64,58 @@ const messages = defineMessages({
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const getAncestorsIds = createSelector([
(_, { id }) => id,
state => state.getIn(['contexts', 'inReplyTos']),
], (statusId, inReplyTos) => {
let ancestorsIds = Immutable.List();
ancestorsIds = ancestorsIds.withMutations(mutable => {
let id = statusId;
while (id) {
mutable.unshift(id);
id = inReplyTos.get(id);
}
});
return ancestorsIds;
});
const getDescendantsIds = createSelector([
(_, { id }) => id,
state => state.getIn(['contexts', 'replies']),
], (statusId, contextReplies) => {
let descendantsIds = Immutable.List();
descendantsIds = descendantsIds.withMutations(mutable => {
const ids = [statusId];
while (ids.length > 0) {
let id = ids.shift();
const replies = contextReplies.get(id);
if (statusId !== id) {
mutable.push(id);
}
if (replies) {
replies.reverse().forEach(reply => {
ids.unshift(reply);
});
}
}
});
return descendantsIds;
});
const mapStateToProps = (state, props) => { const mapStateToProps = (state, props) => {
const status = getStatus(state, { id: props.params.statusId }); const status = getStatus(state, { id: props.params.statusId });
let ancestorsIds = Immutable.List(); let ancestorsIds = Immutable.List();
let descendantsIds = Immutable.List(); let descendantsIds = Immutable.List();
if (status) { if (status) {
ancestorsIds = ancestorsIds.withMutations(mutable => { ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
let id = status.get('in_reply_to_id'); descendantsIds = getDescendantsIds(state, { id: status.get('id') });
while (id) {
mutable.unshift(id);
id = state.getIn(['contexts', 'inReplyTos', id]);
}
});
descendantsIds = descendantsIds.withMutations(mutable => {
const ids = [status.get('id')];
while (ids.length > 0) {
let id = ids.shift();
const replies = state.getIn(['contexts', 'replies', id]);
if (status.get('id') !== id) {
mutable.push(id);
}
if (replies) {
replies.reverse().forEach(reply => {
ids.unshift(reply);
});
}
}
});
} }
return { return {

View file

@ -110,6 +110,11 @@ class ColumnsArea extends ImmutablePureComponent {
// React-router does this for us, but too late, feeling laggy. // React-router does this for us, but too late, feeling laggy.
document.querySelector(currentLinkSelector).classList.remove('active'); document.querySelector(currentLinkSelector).classList.remove('active');
document.querySelector(nextLinkSelector).classList.add('active'); document.querySelector(nextLinkSelector).classList.add('active');
if (!this.state.shouldAnimate && typeof this.pendingIndex === 'number') {
this.context.router.history.push(getLink(this.pendingIndex));
this.pendingIndex = null;
}
} }
handleAnimationEnd = () => { handleAnimationEnd = () => {
@ -160,7 +165,6 @@ class ColumnsArea extends ImmutablePureComponent {
const { shouldAnimate } = this.state; const { shouldAnimate } = this.state;
const columnIndex = getIndex(this.context.router.history.location.pathname); const columnIndex = getIndex(this.context.router.history.location.pathname);
this.pendingIndex = null;
if (singleColumn) { if (singleColumn) {
const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>; const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;

View file

@ -195,6 +195,12 @@ const expandMentions = status => {
return fragment.innerHTML; return fragment.innerHTML;
}; };
const expiresInFromExpiresAt = expires_at => {
if (!expires_at) return 24 * 3600;
const delta = (new Date(expires_at).getTime() - Date.now()) / 1000;
return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
};
export default function compose(state = initialState, action) { export default function compose(state = initialState, action) {
switch(action.type) { switch(action.type) {
case STORE_HYDRATE: case STORE_HYDRATE:
@ -224,6 +230,7 @@ export default function compose(state = initialState, action) {
} }
}); });
case COMPOSE_SPOILER_TEXT_CHANGE: case COMPOSE_SPOILER_TEXT_CHANGE:
if (!state.get('spoiler')) return state;
return state return state
.set('spoiler_text', action.text) .set('spoiler_text', action.text)
.set('idempotencyKey', uuid()); .set('idempotencyKey', uuid());
@ -352,7 +359,7 @@ export default function compose(state = initialState, action) {
map.set('poll', ImmutableMap({ map.set('poll', ImmutableMap({
options: action.status.getIn(['poll', 'options']).map(x => x.get('title')), options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
multiple: action.status.getIn(['poll', 'multiple']), multiple: action.status.getIn(['poll', 'multiple']),
expires_in: 24 * 3600, expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])),
})); }));
} }
}); });

View file

@ -8,6 +8,8 @@ import {
CONVERSATIONS_UPDATE, CONVERSATIONS_UPDATE,
CONVERSATIONS_READ, CONVERSATIONS_READ,
} from '../actions/conversations'; } from '../actions/conversations';
import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts';
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
import compareId from '../compare_id'; import compareId from '../compare_id';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
@ -74,6 +76,10 @@ const expandNormalizedConversations = (state, conversations, next, isLoadingRece
}); });
}; };
const filterConversations = (state, accountIds) => {
return state.update('items', list => list.filterNot(item => item.get('accounts').some(accountId => accountIds.includes(accountId))));
};
export default function conversations(state = initialState, action) { export default function conversations(state = initialState, action) {
switch (action.type) { switch (action.type) {
case CONVERSATIONS_FETCH_REQUEST: case CONVERSATIONS_FETCH_REQUEST:
@ -96,6 +102,11 @@ export default function conversations(state = initialState, action) {
return item; return item;
})); }));
case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS:
return filterConversations(state, [action.relationship.id]);
case DOMAIN_BLOCK_SUCCESS:
return filterConversations(state, action.accounts);
default: default:
return state; return state;
} }

View file

@ -10,7 +10,7 @@ export default function modal(state = initialState, action) {
case MODAL_OPEN: case MODAL_OPEN:
return { modalType: action.modalType, modalProps: action.modalProps }; return { modalType: action.modalType, modalProps: action.modalProps };
case MODAL_CLOSE: case MODAL_CLOSE:
return initialState; return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state;
default: default:
return state; return state;
} }

View file

@ -11,6 +11,7 @@ import {
ACCOUNT_BLOCK_SUCCESS, ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS, ACCOUNT_MUTE_SUCCESS,
} from '../actions/accounts'; } from '../actions/accounts';
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines'; import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from '../compare_id'; import compareId from '../compare_id';
@ -77,8 +78,8 @@ const expandNormalizedNotifications = (state, notifications, next) => {
}); });
}; };
const filterNotifications = (state, relationship) => { const filterNotifications = (state, accountIds) => {
return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id)); return state.update('items', list => list.filterNot(item => item !== null && accountIds.includes(item.get('account'))));
}; };
const updateTop = (state, top) => { const updateTop = (state, top) => {
@ -108,9 +109,11 @@ export default function notifications(state = initialState, action) {
case NOTIFICATIONS_EXPAND_SUCCESS: case NOTIFICATIONS_EXPAND_SUCCESS:
return expandNormalizedNotifications(state, action.notifications, action.next); return expandNormalizedNotifications(state, action.notifications, action.next);
case ACCOUNT_BLOCK_SUCCESS: case ACCOUNT_BLOCK_SUCCESS:
return filterNotifications(state, action.relationship); return filterNotifications(state, [action.relationship.id]);
case ACCOUNT_MUTE_SUCCESS: case ACCOUNT_MUTE_SUCCESS:
return action.relationship.muting_notifications ? filterNotifications(state, action.relationship) : state; return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state;
case DOMAIN_BLOCK_SUCCESS:
return filterNotifications(state, action.accounts);
case NOTIFICATIONS_CLEAR: case NOTIFICATIONS_CLEAR:
return state.set('items', ImmutableList()).set('hasMore', false); return state.set('items', ImmutableList()).set('hasMore', false);
case TIMELINE_DELETE: case TIMELINE_DELETE:

View file

@ -16,6 +16,7 @@ const initialState = ImmutableMap({
submitted: false, submitted: false,
hidden: false, hidden: false,
results: ImmutableMap(), results: ImmutableMap(),
searchTerm: '',
}); });
export default function search(state = initialState, action) { export default function search(state = initialState, action) {
@ -40,7 +41,7 @@ export default function search(state = initialState, action) {
accounts: ImmutableList(action.results.accounts.map(item => item.id)), accounts: ImmutableList(action.results.accounts.map(item => item.id)),
statuses: ImmutableList(action.results.statuses.map(item => item.id)), statuses: ImmutableList(action.results.statuses.map(item => item.id)),
hashtags: fromJS(action.results.hashtags), hashtags: fromJS(action.results.hashtags),
})).set('submitted', true); })).set('submitted', true).set('searchTerm', action.searchTerm);
default: default:
return state; return state;
} }

View file

@ -4,6 +4,8 @@ import {
SUGGESTIONS_FETCH_FAIL, SUGGESTIONS_FETCH_FAIL,
SUGGESTIONS_DISMISS, SUGGESTIONS_DISMISS,
} from '../actions/suggestions'; } from '../actions/suggestions';
import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts';
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
@ -24,6 +26,11 @@ export default function suggestionsReducer(state = initialState, action) {
return state.set('isLoading', false); return state.set('isLoading', false);
case SUGGESTIONS_DISMISS: case SUGGESTIONS_DISMISS:
return state.update('items', list => list.filterNot(id => id === action.id)); return state.update('items', list => list.filterNot(id => id === action.id));
case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS:
return state.update('items', list => list.filterNot(id => id === action.relationship.id));
case DOMAIN_BLOCK_SUCCESS:
return state.update('items', list => list.filterNot(id => action.accounts.includes(id)));
default: default:
return state; return state;
} }

View file

@ -1,5 +1,5 @@
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList, is } from 'immutable';
import { me } from '../initial_state'; import { me } from '../initial_state';
const getAccountBase = (state, id) => state.getIn(['accounts', id], null); const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
@ -36,12 +36,10 @@ const toServerSideType = columnType => {
} }
}; };
export const getFilters = (state, { contextType }) => state.get('filters', ImmutableList()).filter(filter => contextType && filter.get('context').includes(toServerSideType(contextType)) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date())));
const escapeRegExp = string => const escapeRegExp = string =>
string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
export const regexFromFilters = filters => { const regexFromFilters = filters => {
if (filters.size === 0) { if (filters.size === 0) {
return null; return null;
} }
@ -63,6 +61,27 @@ export const regexFromFilters = filters => {
}).join('|'), 'i'); }).join('|'), 'i');
}; };
// Memoize the filter regexps for each valid server contextType
const makeGetFiltersRegex = () => {
let memo = {};
return (state, { contextType }) => {
if (!contextType) return ImmutableList();
const serverSideType = toServerSideType(contextType);
const filters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date())));
if (!memo[serverSideType] || !is(memo[serverSideType].filters, filters)) {
const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
const regex = regexFromFilters(filters);
memo[serverSideType] = { filters: filters, results: [dropRegex, regex] };
}
return memo[serverSideType].results;
};
};
export const getFiltersRegex = makeGetFiltersRegex();
export const makeGetStatus = () => { export const makeGetStatus = () => {
return createSelector( return createSelector(
[ [
@ -70,10 +89,10 @@ export const makeGetStatus = () => {
(state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
getFilters, getFiltersRegex,
], ],
(statusBase, statusReblog, accountBase, accountReblog, filters) => { (statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => {
if (!statusBase) { if (!statusBase) {
return null; return null;
} }
@ -84,12 +103,12 @@ export const makeGetStatus = () => {
statusReblog = null; statusReblog = null;
} }
const dropRegex = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters.filter(filter => filter.get('irreversible'))); const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0];
if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) { if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) {
return null; return null;
} }
const regex = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters); const regex = (accountReblog || accountBase).get('id') !== me && filtersRegex[1];
const filtered = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index')); const filtered = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'));
return statusBase.withMutations(map => { return statusBase.withMutations(map => {

View file

@ -67,6 +67,14 @@ const processImage = (img, { width, height, orientation, type = 'image/png' }) =
context.drawImage(img, 0, 0, width, height); context.drawImage(img, 0, 0, width, height);
// The Tor Browser and maybe other browsers may prevent reading from canvas
// and return an all-white image instead. Assume reading failed if the resized
// image is perfectly white.
const imageData = context.getImageData(0, 0, width, height);
if (imageData.every(value => value === 255)) {
throw 'Failed to read from canvas';
}
canvas.toBlob(resolve, type); canvas.toBlob(resolve, type);
}); });

View file

@ -44,6 +44,12 @@ function main() {
} }
}; };
const getEmojiAnimationHandler = (swapTo) => {
return ({ target }) => {
target.src = target.getAttribute(swapTo);
};
};
ready(() => { ready(() => {
const locale = document.documentElement.lang; const locale = document.documentElement.lang;
@ -116,6 +122,9 @@ function main() {
document.head.appendChild(scrollbarWidthStyle); document.head.appendChild(scrollbarWidthStyle);
scrollbarWidthStyle.sheet.insertRule(`body.with-modals--active { margin-right: ${scrollbarWidth}px; }`, 0); scrollbarWidthStyle.sheet.insertRule(`body.with-modals--active { margin-right: ${scrollbarWidth}px; }`, 0);
} }
delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));
}); });
delegate(document, '.webapp-btn', 'click', ({ target, button }) => { delegate(document, '.webapp-btn', 'click', ({ target, button }) => {
@ -178,7 +187,7 @@ function main() {
return ({ target }) => { return ({ target }) => {
const swapSrc = target.getAttribute(swapTo); const swapSrc = target.getAttribute(swapTo);
//only change the img source if autoplay is off and the image src is actually different //only change the img source if autoplay is off and the image src is actually different
if(target.getAttribute('data-autoplay') === 'false' && target.src !== swapSrc) { if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) {
target.src = swapSrc; target.src = swapSrc;
} }
}; };

View file

@ -3996,6 +3996,11 @@ a.status-card.compact:hover {
} }
} }
.search-results__info {
padding: 10px;
color: $secondary-text-color;
}
.modal-root { .modal-root {
position: relative; position: relative;
transition: opacity 0.3s linear; transition: opacity 0.3s linear;

View file

@ -35,7 +35,7 @@ class FeedManager
end end
def unpush_from_home(account, status) def unpush_from_home(account, status)
return false unless remove_from_feed(:home, account.id, status) return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
true true
end end
@ -53,7 +53,7 @@ class FeedManager
end end
def unpush_from_list(list, status) def unpush_from_list(list, status)
return false unless remove_from_feed(:list, list.id, status) return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
true true
end end
@ -105,7 +105,7 @@ class FeedManager
oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0 oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status| from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status|
remove_from_feed(:home, into_account.id, status) remove_from_feed(:home, into_account.id, status, into_account.user&.aggregates_reblogs?)
end end
end end
@ -220,7 +220,8 @@ class FeedManager
status = status.reblog if status.reblog? status = status.reblog if status.reblog?
!combined_regex.match(Formatter.instance.plaintext(status)).nil? || !combined_regex.match(Formatter.instance.plaintext(status)).nil? ||
(status.spoiler_text.present? && !combined_regex.match(status.spoiler_text).nil?) (status.spoiler_text.present? && !combined_regex.match(status.spoiler_text).nil?) ||
(status.preloadable_poll && !combined_regex.match(status.preloadable_poll.options.join("\n\n")).nil?)
end end
# Adds a status to an account's feed, returning true if a status was # Adds a status to an account's feed, returning true if a status was
@ -274,10 +275,11 @@ class FeedManager
# with reblogs, and returning true if a status was removed. As with # with reblogs, and returning true if a status was removed. As with
# `add_to_feed`, this does not trigger push updates, so callers must # `add_to_feed`, this does not trigger push updates, so callers must
# do so if appropriate. # do so if appropriate.
def remove_from_feed(timeline_type, account_id, status) def remove_from_feed(timeline_type, account_id, status, aggregate_reblogs = true)
timeline_key = key(timeline_type, account_id) timeline_key = key(timeline_type, account_id)
reblog_key = key(timeline_type, account_id, 'reblogs')
if status.reblog? if status.reblog? && (aggregate_reblogs.nil? || aggregate_reblogs)
# 1. If the reblogging status is not in the feed, stop. # 1. If the reblogging status is not in the feed, stop.
status_rank = redis.zrevrank(timeline_key, status.id) status_rank = redis.zrevrank(timeline_key, status.id)
return false if status_rank.nil? return false if status_rank.nil?
@ -286,6 +288,7 @@ class FeedManager
reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}") reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}")
redis.srem(reblog_set_key, status.id) redis.srem(reblog_set_key, status.id)
redis.zrem(reblog_key, status.reblog_of_id)
# 3. Re-insert another reblog or original into the feed if one # 3. Re-insert another reblog or original into the feed if one
# remains in the set. We could pick a random element, but this # remains in the set. We could pick a random element, but this
# set should generally be small, and it seems ideal to show the # set should generally be small, and it seems ideal to show the
@ -293,12 +296,14 @@ class FeedManager
other_reblog = redis.smembers(reblog_set_key).map(&:to_i).min other_reblog = redis.smembers(reblog_set_key).map(&:to_i).min
redis.zadd(timeline_key, other_reblog, other_reblog) if other_reblog redis.zadd(timeline_key, other_reblog, other_reblog) if other_reblog
redis.zadd(reblog_key, other_reblog, status.reblog_of_id) if other_reblog
# 4. Remove the reblogging status from the feed (as normal) # 4. Remove the reblogging status from the feed (as normal)
# (outside conditional) # (outside conditional)
else else
# If the original is getting deleted, no use for reblog references # If the original is getting deleted, no use for reblog references
redis.del(key(timeline_type, account_id, "reblogs:#{status.id}")) redis.del(key(timeline_type, account_id, "reblogs:#{status.id}"))
redis.zrem(reblog_key, status.id)
end end
redis.zrem(timeline_key, status.id) redis.zrem(timeline_key, status.id)

View file

@ -137,11 +137,7 @@ class Formatter
def encode_custom_emojis(html, emojis, animate = false) def encode_custom_emojis(html, emojis, animate = false)
return html if emojis.empty? return html if emojis.empty?
emoji_map = if animate emoji_map = emojis.each_with_object({}) { |e, h| h[e.shortcode] = [full_asset_url(e.image.url), full_asset_url(e.image.url(:static))] }
emojis.each_with_object({}) { |e, h| h[e.shortcode] = full_asset_url(e.image.url) }
else
emojis.each_with_object({}) { |e, h| h[e.shortcode] = full_asset_url(e.image.url(:static)) }
end
i = -1 i = -1
tag_open_index = nil tag_open_index = nil
@ -157,7 +153,14 @@ class Formatter
emoji = emoji_map[shortcode] emoji = emoji_map[shortcode]
if emoji if emoji
replacement = "<img draggable=\"false\" class=\"emojione\" alt=\":#{encode(shortcode)}:\" title=\":#{encode(shortcode)}:\" src=\"#{encode(emoji)}\" />" original_url, static_url = emoji
replacement = begin
if animate
"<img draggable=\"false\" class=\"emojione\" alt=\":#{encode(shortcode)}:\" title=\":#{encode(shortcode)}:\" src=\"#{encode(original_url)}\" />"
else
"<img draggable=\"false\" class=\"emojione custom-emoji\" alt=\":#{encode(shortcode)}:\" title=\":#{encode(shortcode)}:\" src=\"#{encode(static_url)}\" data-original=\"#{original_url}\" data-static=\"#{static_url}\" />"
end
end
before_html = shortname_start_index.positive? ? html[0..shortname_start_index - 1] : '' before_html = shortname_start_index.positive? ? html[0..shortname_start_index - 1] : ''
html = before_html + replacement + html[i + 1..-1] html = before_html + replacement + html[i + 1..-1]
i += replacement.size - (shortcode.size + 2) - 1 i += replacement.size - (shortcode.size + 2) - 1

View file

@ -69,7 +69,7 @@ class LanguageDetector
new_text = remove_html(text) new_text = remove_html(text)
new_text.gsub!(FetchLinkCardService::URL_PATTERN, '') new_text.gsub!(FetchLinkCardService::URL_PATTERN, '')
new_text.gsub!(Account::MENTION_RE, '') new_text.gsub!(Account::MENTION_RE, '')
new_text.gsub!(Tag::HASHTAG_RE, '') new_text.gsub!(Tag::HASHTAG_RE) { |string| string.gsub(/[#_]/, '#' => '', '_' => ' ').gsub(/[a-z][A-Z]|[a-zA-Z][\d]/) { |s| s.insert(1, ' ') }.downcase }
new_text.gsub!(/:#{CustomEmoji::SHORTCODE_RE_FRAGMENT}:/, '') new_text.gsub!(/:#{CustomEmoji::SHORTCODE_RE_FRAGMENT}:/, '')
new_text.gsub!(/\s+/, ' ') new_text.gsub!(/\s+/, ' ')
new_text new_text

View file

@ -25,6 +25,8 @@ class Sanitize
case env[:node_name] case env[:node_name]
when 'li' when 'li'
env[:node].traverse do |node| env[:node].traverse do |node|
next unless %w(p ul ol li).include?(node.name)
node.add_next_sibling('<br>') if node.next_sibling node.add_next_sibling('<br>') if node.next_sibling
node.replace(node.children) unless node.text? node.replace(node.children) unless node.text?
end end

View file

@ -3,9 +3,11 @@
class SidekiqErrorHandler class SidekiqErrorHandler
def call(*) def call(*)
yield yield
rescue Mastodon::HostValidationError => e rescue Mastodon::HostValidationError
Rails.logger.error "#{e.class}: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
# Do not retry # Do not retry
ensure
socket = Thread.current[:statsd_socket]
socket&.close
Thread.current[:statsd_socket] = nil
end end
end end

View file

@ -15,7 +15,7 @@ class AccountDomainBlock < ApplicationRecord
include DomainNormalizable include DomainNormalizable
belongs_to :account belongs_to :account
validates :domain, presence: true, uniqueness: { scope: :account_id } validates :domain, presence: true, uniqueness: { scope: :account_id }, domain: true
after_commit :remove_blocking_cache after_commit :remove_blocking_cache
after_commit :remove_relationship_cache after_commit :remove_relationship_cache

View file

@ -17,10 +17,13 @@ class Admin::AccountAction
:type, :type,
:text, :text,
:report_id, :report_id,
:warning_preset_id, :warning_preset_id
:send_email_notification
attr_reader :warning attr_reader :warning, :send_email_notification
def send_email_notification=(value)
@send_email_notification = ActiveModel::Type::Boolean.new.cast(value)
end
def save! def save!
ApplicationRecord.transaction do ApplicationRecord.transaction do

View file

@ -60,7 +60,9 @@ module Attachmentable
end end
def calculated_content_type(attachment) def calculated_content_type(attachment)
Paperclip.run('file', '-b --mime :file', file: attachment.queued_for_write[:original].path).split(/[:;\s]+/).first.chomp content_type = Paperclip.run('file', '-b --mime :file', file: attachment.queued_for_write[:original].path).split(/[:;\s]+/).first.chomp
content_type = 'video/mp4' if content_type == 'video/x-m4v'
content_type
rescue Terrapin::CommandLineError rescue Terrapin::CommandLineError
'' ''
end end

View file

@ -4,7 +4,7 @@ module DomainNormalizable
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
before_validation :normalize_domain before_save :normalize_domain
end end
private private

View file

@ -27,13 +27,15 @@ class CustomEmoji < ApplicationRecord
:(#{SHORTCODE_RE_FRAGMENT}): :(#{SHORTCODE_RE_FRAGMENT}):
(?=[^[:alnum:]:]|$)/x (?=[^[:alnum:]:]|$)/x
IMAGE_MIME_TYPES = %w(image/png image/gif image/webp).freeze
has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode
has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } } has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }
before_validation :downcase_domain before_validation :downcase_domain
validates_attachment :image, content_type: { content_type: 'image/png' }, presence: true, size: { less_than: LIMIT } validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true, size: { less_than: LIMIT }
validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 } validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 }
scope :local, -> { where(domain: nil) } scope :local, -> { where(domain: nil) }

View file

@ -35,6 +35,13 @@ class CustomFilter < ApplicationRecord
before_validation :clean_up_contexts before_validation :clean_up_contexts
after_commit :remove_cache after_commit :remove_cache
def expires_in
return @expires_in if defined?(@expires_in)
return nil if expires_at.nil?
[30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].find { |expires_in| expires_in.from_now >= expires_at }
end
private private
def clean_up_contexts def clean_up_contexts

View file

@ -17,7 +17,7 @@ class DomainBlock < ApplicationRecord
enum severity: [:silence, :suspend, :noop] enum severity: [:silence, :suspend, :noop]
validates :domain, presence: true, uniqueness: true validates :domain, presence: true, uniqueness: true, domain: true
has_many :accounts, foreign_key: :domain, primary_key: :domain has_many :accounts, foreign_key: :domain, primary_key: :domain
delegate :count, to: :accounts, prefix: true delegate :count, to: :accounts, prefix: true

View file

@ -12,7 +12,7 @@
class EmailDomainBlock < ApplicationRecord class EmailDomainBlock < ApplicationRecord
include DomainNormalizable include DomainNormalizable
validates :domain, presence: true, uniqueness: true validates :domain, presence: true, uniqueness: true, domain: true
def self.block?(email) def self.block?(email)
_, domain = email.split('@', 2) _, domain = email.split('@', 2)

View file

@ -17,7 +17,7 @@
class Invite < ApplicationRecord class Invite < ApplicationRecord
include Expireable include Expireable
belongs_to :user belongs_to :user, inverse_of: :invites
has_many :users, inverse_of: :invite has_many :users, inverse_of: :invite
scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) } scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) }
@ -25,7 +25,7 @@ class Invite < ApplicationRecord
before_validation :set_code before_validation :set_code
def valid_for_use? def valid_for_use?
(max_uses.nil? || uses < max_uses) && !expired? (max_uses.nil? || uses < max_uses) && !expired? && !(user.nil? || user.disabled?)
end end
private private

View file

@ -113,7 +113,7 @@ class MediaAttachment < ApplicationRecord
has_attached_file :file, has_attached_file :file,
styles: ->(f) { file_styles f }, styles: ->(f) { file_styles f },
processors: ->(f) { file_processors f }, processors: ->(f) { file_processors f },
convert_options: { all: '-quality 90 -strip' } convert_options: { all: '-quality 90 -strip +set modify-date +set create-date' }
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :larger_media_format? validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :larger_media_format?

View file

@ -77,7 +77,7 @@ class Status < ApplicationRecord
default_scope { recent } default_scope { recent }
scope :recent, -> { reorder(id: :desc) } scope :recent, -> { reorder(id: :desc) }
scope :remote, -> { where(local: false).or(where.not(uri: nil)) } scope :remote, -> { where(local: false).where.not(uri: nil) }
scope :local, -> { where(local: true).or(where(uri: nil)) } scope :local, -> { where(local: true).or(where(uri: nil)) }
scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') } scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }

View file

@ -17,10 +17,10 @@ class Tag < ApplicationRecord
has_many :featured_tags, dependent: :destroy, inverse_of: :tag has_many :featured_tags, dependent: :destroy, inverse_of: :tag
has_one :account_tag_stat, dependent: :destroy has_one :account_tag_stat, dependent: :destroy
HASHTAG_NAME_RE = '[[:word:]_]*[[:alpha:]_·][[:word:]_]*' HASHTAG_NAME_RE = '([[:word:]_][[:word:]_·]*[[:alpha:]_·][[:word:]_·]*[[:word:]_])|([[:word:]_]*[[:alpha:]][[:word:]_]*)'
HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
validates :name, presence: true, uniqueness: true, format: { with: /\A#{HASHTAG_NAME_RE}\z/i } validates :name, presence: true, uniqueness: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i }
scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) } scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) }
scope :hidden, -> { where(account_tag_stats: { hidden: true }) } scope :hidden, -> { where(account_tag_stats: { hidden: true }) }

View file

@ -73,6 +73,7 @@ class User < ApplicationRecord
has_many :applications, class_name: 'Doorkeeper::Application', as: :owner has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
has_many :backups, inverse_of: :user has_many :backups, inverse_of: :user
has_many :invites, inverse_of: :user
has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? } accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }

View file

@ -14,7 +14,7 @@ class ActivityPub::UpdatePollSerializer < ActivityPub::Serializer
end end
def actor def actor
ActivityPub::TagManager.instance.uri_for(object) ActivityPub::TagManager.instance.uri_for(object.account)
end end
def to def to

View file

@ -4,7 +4,7 @@ class REST::WebPushSubscriptionSerializer < ActiveModel::Serializer
attributes :id, :endpoint, :alerts, :server_key attributes :id, :endpoint, :alerts, :server_key
def alerts def alerts
object.data&.dig('alerts') || {} (object.data&.dig('alerts') || {}).each_with_object({}) { |(k, v), h| h[k] = ActiveModel::Type::Boolean.new.cast(v) }
end end
def server_key def server_key

View file

@ -15,6 +15,8 @@ class ActivityPub::ProcessAccountService < BaseService
@domain = domain @domain = domain
@collections = {} @collections = {}
return if auto_suspend?
RedisLock.acquire(lock_options) do |lock| RedisLock.acquire(lock_options) do |lock|
if lock.acquired? if lock.acquired?
@account = Account.find_remote(@username, @domain) @account = Account.find_remote(@username, @domain)
@ -55,7 +57,7 @@ class ActivityPub::ProcessAccountService < BaseService
@account.domain = @domain @account.domain = @domain
@account.private_key = nil @account.private_key = nil
@account.suspended_at = domain_block.created_at if auto_suspend? @account.suspended_at = domain_block.created_at if auto_suspend?
@account.silenced_at = domain_block.created_at if auto_silence? @account.silenced_at = domain_block.created_at if auto_silence?
end end
def update_account def update_account

View file

@ -10,12 +10,24 @@ class AfterBlockDomainFromAccountService < BaseService
@account = account @account = account
@domain = domain @domain = domain
clear_notifications!
remove_follows!
reject_existing_followers! reject_existing_followers!
reject_pending_follow_requests! reject_pending_follow_requests!
end end
private private
def remove_follows!
@account.active_relationships.where(account: Account.where(domain: @domain)).includes(:target_account).reorder(nil).find_each do |follow|
UnfollowService.new.call(@account, follow.target_account)
end
end
def clear_notifications!
Notification.where(account: @account).where(from_account: Account.where(domain: @domain)).in_batches.delete_all
end
def reject_existing_followers! def reject_existing_followers!
@account.passive_relationships.where(account: Account.where(domain: @domain)).includes(:account).reorder(nil).find_each do |follow| @account.passive_relationships.where(account: Account.where(domain: @domain)).includes(:account).reorder(nil).find_each do |follow|
reject_follow!(follow) reject_follow!(follow)

View file

@ -2,43 +2,25 @@
class AfterBlockService < BaseService class AfterBlockService < BaseService
def call(account, target_account) def call(account, target_account)
clear_home_feed(account, target_account) @account = account
clear_notifications(account, target_account) @target_account = target_account
clear_conversations(account, target_account)
clear_home_feed!
clear_notifications!
clear_conversations!
end end
private private
def clear_home_feed(account, target_account) def clear_home_feed!
FeedManager.instance.clear_from_timeline(account, target_account) FeedManager.instance.clear_from_timeline(@account, @target_account)
end end
def clear_conversations(account, target_account) def clear_conversations!
AccountConversation.where(account: account) AccountConversation.where(account: @account).where('? = ANY(participant_account_ids)', @target_account.id).in_batches.destroy_all
.where('? = ANY(participant_account_ids)', target_account.id)
.in_batches
.destroy_all
end end
def clear_notifications(account, target_account) def clear_notifications!
Notification.where(account: account) Notification.where(account: @account).where(from_account: @target_account).in_batches.delete_all
.joins(:follow)
.where(activity_type: 'Follow', follows: { account_id: target_account.id })
.delete_all
Notification.where(account: account)
.joins(mention: :status)
.where(activity_type: 'Mention', statuses: { account_id: target_account.id })
.delete_all
Notification.where(account: account)
.joins(:favourite)
.where(activity_type: 'Favourite', favourites: { account_id: target_account.id })
.delete_all
Notification.where(account: account)
.joins(:status)
.where(activity_type: 'Status', statuses: { account_id: target_account.id })
.delete_all
end end
end end

View file

@ -142,5 +142,7 @@ class BackupService < BaseService
io.write(buffer) io.write(buffer)
end end
end end
rescue Errno::ENOENT
Rails.logger.warn "Could not backup file #{filename}: file not found"
end end
end end

View file

@ -8,7 +8,7 @@ class BlockService < BaseService
UnfollowService.new.call(account, target_account) if account.following?(target_account) UnfollowService.new.call(account, target_account) if account.following?(target_account)
UnfollowService.new.call(target_account, account) if target_account.following?(account) UnfollowService.new.call(target_account, account) if target_account.following?(account)
RejectFollowService.new.call(account, target_account) if target_account.requested?(account) RejectFollowService.new.call(target_account, account) if target_account.requested?(account)
block = account.block!(target_account) block = account.block!(target_account)

View file

@ -13,7 +13,7 @@ class FollowService < BaseService
target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true) target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended? raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account) || target_account.moved? raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account) || target_account.moved? || source_account.domain_blocking?(target_account.domain)
if source_account.following?(target_account) if source_account.following?(target_account)
# We're already following this account, but we'll call follow! again to # We're already following this account, but we'll call follow! again to

View file

@ -48,7 +48,7 @@ class ResolveAccountService < BaseService
return return
end end
return if links_missing? return if links_missing? || auto_suspend?
return Account.find_local(@username) if TagManager.instance.local_domain?(@domain) return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
RedisLock.acquire(lock_options) do |lock| RedisLock.acquire(lock_options) do |lock|

View file

@ -66,6 +66,7 @@ class SuspendAccountService < BaseService
@account.user.destroy @account.user.destroy
else else
@account.user.disable! @account.user.disable!
@account.user.invites.where(uses: 0).destroy_all
end end
end end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class DomainValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
record.errors.add(attribute, I18n.t('domain_validator.invalid_domain')) unless compliant?(value)
end
private
def compliant?(value)
Addressable::URI.new.tap { |uri| uri.host = value }
rescue Addressable::URI::InvalidURIError
false
end
end

View file

@ -5,7 +5,7 @@
%meta{ name: 'description', content: account_description(@account) }/ %meta{ name: 'description', content: account_description(@account) }/
- if @account.user&.setting_noindex - if @account.user&.setting_noindex
%meta{ name: 'robots', content: 'noindex' }/ %meta{ name: 'robots', content: 'noindex, noarchive' }/
%link{ rel: 'salmon', href: api_salmon_url(@account.id) }/ %link{ rel: 'salmon', href: api_salmon_url(@account.id) }/
%link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/ %link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/
@ -75,7 +75,7 @@
- if featured_tag.last_status_at.nil? - if featured_tag.last_status_at.nil?
= t('accounts.nothing_here') = t('accounts.nothing_here')
- else - else
%time{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at %time.formatted{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
.trends__item__current= number_to_human featured_tag.statuses_count, strip_insignificant_zeros: true .trends__item__current= number_to_human featured_tag.statuses_count, strip_insignificant_zeros: true
= render 'application/sidebar' = render 'application/sidebar'

View file

@ -0,0 +1,24 @@
- content_for :page_title do
= t('statuses.title', name: display_name(@account), quote: truncate(@status.spoiler_text.presence || @status.text, length: 50, omission: '…', escape: false))
- content_for :header_tags do
- if @account.user&.setting_noindex
%meta{ name: 'robots', content: 'noindex, noarchive' }/
%link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: short_account_status_url(@account, @status), format: 'json') }/
%link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@status) }/
= opengraph 'og:site_name', site_title
= opengraph 'og:type', 'article'
= opengraph 'og:title', "#{display_name(@account)} (@#{@account.local_username_and_domain})"
= opengraph 'og:url', short_account_status_url(@account, @status)
= render 'og_description', activity: @status
= render 'og_image', activity: @status, account: @account
.grid
.column-0
.activity-stream.h-entry
= render partial: 'status', locals: { status: @status, include_threads: true }
.column-1
= render 'application/sidebar'

View file

@ -3,7 +3,7 @@
- content_for :header_tags do - content_for :header_tags do
- if @account.user&.setting_noindex - if @account.user&.setting_noindex
%meta{ name: 'robots', content: 'noindex' }/ %meta{ name: 'robots', content: 'noindex, noarchive' }/
%link{ rel: 'alternate', type: 'application/atom+xml', href: account_stream_entry_url(@account, @stream_entry, format: 'atom') }/ %link{ rel: 'alternate', type: 'application/atom+xml', href: account_stream_entry_url(@account, @stream_entry, format: 'atom') }/
%link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: account_stream_entry_url(@account, @stream_entry), format: 'json') }/ %link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: account_stream_entry_url(@account, @stream_entry), format: 'json') }/

View file

@ -51,7 +51,7 @@ class ActivityPub::DeliveryWorker
end end
def response_error_unsalvageable?(response) def response_error_unsalvageable?(response)
(400...500).cover?(response.code) && ![401, 408, 429].include?(response.code) response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code))
end end
def failure_tracker def failure_tracker

View file

@ -3,7 +3,7 @@
class Web::PushNotificationWorker class Web::PushNotificationWorker
include Sidekiq::Worker include Sidekiq::Worker
sidekiq_options backtrace: true sidekiq_options backtrace: true, retry: 5
def perform(subscription_id, notification_id) def perform(subscription_id, notification_id)
subscription = ::Web::PushSubscription.find(subscription_id) subscription = ::Web::PushSubscription.find(subscription_id)

View file

@ -114,6 +114,9 @@ module Mastodon
Doorkeeper::AuthorizationsController.layout 'modal' Doorkeeper::AuthorizationsController.layout 'modal'
Doorkeeper::AuthorizedApplicationsController.layout 'admin' Doorkeeper::AuthorizedApplicationsController.layout 'admin'
Doorkeeper::Application.send :include, ApplicationExtension Doorkeeper::Application.send :include, ApplicationExtension
Devise::FailureApp.send :include, AbstractController::Callbacks
Devise::FailureApp.send :include, HttpAcceptLanguage::EasyAccess
Devise::FailureApp.send :include, Localized
end end
end end
end end

View file

@ -1,18 +0,0 @@
# frozen_string_literal: true
instrumentation_hostname = ENV.fetch('INSTRUMENTATION_HOSTNAME') { 'localhost' }
ActiveSupport::Notifications.subscribe(/process_action.action_controller/) do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
controller = event.payload[:controller]
action = event.payload[:action]
format = event.payload[:format] || 'all'
format = 'all' if format == '*/*'
status = event.payload[:status]
key = "#{controller}.#{action}.#{format}.#{instrumentation_hostname}"
ActiveSupport::Notifications.instrument :performance, action: :measure, measurement: "#{key}.total_duration", value: event.duration
ActiveSupport::Notifications.instrument :performance, action: :measure, measurement: "#{key}.db_time", value: event.payload[:db_runtime]
ActiveSupport::Notifications.instrument :performance, action: :measure, measurement: "#{key}.view_time", value: event.payload[:view_runtime]
ActiveSupport::Notifications.instrument :performance, measurement: "#{key}.status.#{status}"
end

View file

@ -3,10 +3,10 @@
if ENV['STATSD_ADDR'].present? if ENV['STATSD_ADDR'].present?
host, port = ENV['STATSD_ADDR'].split(':') host, port = ENV['STATSD_ADDR'].split(':')
statsd = ::Statsd.new(host, port) $statsd = ::Statsd.new(host, port)
statsd.namespace = ENV.fetch('STATSD_NAMESPACE') { ['Mastodon', Rails.env].join('.') } $statsd.namespace = ENV.fetch('STATSD_NAMESPACE') { ['Mastodon', Rails.env].join('.') }
::NSA.inform_statsd(statsd) do |informant| ::NSA.inform_statsd($statsd) do |informant|
informant.collect(:action_controller, :web) informant.collect(:action_controller, :web)
informant.collect(:active_record, :db) informant.collect(:active_record, :db)
informant.collect(:active_support_cache, :cache) informant.collect(:active_support_cache, :cache)

View file

@ -588,6 +588,8 @@ en:
people: people:
one: "%{count} person" one: "%{count} person"
other: "%{count} people" other: "%{count} people"
domain_validator:
invalid_domain: is not a valid domain name
errors: errors:
'403': You don't have permission to view this page. '403': You don't have permission to view this page.
'404': The page you are looking for isn't here. '404': The page you are looking for isn't here.

View file

@ -2,9 +2,9 @@ threads_count = ENV.fetch('MAX_THREADS') { 5 }.to_i
threads threads_count, threads_count threads threads_count, threads_count
if ENV['SOCKET'] if ENV['SOCKET']
bind 'unix://' + ENV['SOCKET'] bind "unix://#{ENV['SOCKET']}"
else else
port ENV.fetch('PORT') { 3000 } bind "tcp://#{ENV.fetch('BIND', '127.0.0.1')}:#{ENV.fetch('PORT', 3000)}"
end end
environment ENV.fetch('RAILS_ENV') { 'development' } environment ENV.fetch('RAILS_ENV') { 'development' }

View file

@ -38,7 +38,7 @@ services:
image: tootsuite/mastodon image: tootsuite/mastodon
restart: always restart: always
env_file: .env.production env_file: .env.production
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000 -b '0.0.0.0'" command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
networks: networks:
- external_network - external_network
- internal_network - internal_network
@ -58,7 +58,7 @@ services:
image: tootsuite/mastodon image: tootsuite/mastodon
restart: always restart: always
env_file: .env.production env_file: .env.production
command: yarn start command: node ./streaming
networks: networks:
- external_network - external_network
- internal_network - internal_network

View file

@ -13,23 +13,23 @@ module Mastodon
end end
def patch def patch
2 4
end
def pre
nil
end end
def flags def flags
'' ''
end end
def suffix
''
end
def to_a def to_a
[major, minor, patch, pre].compact [major, minor, patch].compact
end end
def to_s def to_s
[to_a.join('.'), flags].join [to_a.join('.'), flags, suffix].join
end end
def repository def repository

View file

@ -21,45 +21,77 @@ describe Api::V1::Statuses::FavouritesController do
post :create, params: { status_id: status.id } post :create, params: { status_id: status.id }
end end
it 'returns http success' do context 'with public status' do
expect(response).to have_http_status(200) it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'updates the favourites count' do
expect(status.favourites.count).to eq 1
end
it 'updates the favourited attribute' do
expect(user.account.favourited?(status)).to be true
end
it 'returns json with updated attributes' do
hash_body = body_as_json
expect(hash_body[:id]).to eq status.id.to_s
expect(hash_body[:favourites_count]).to eq 1
expect(hash_body[:favourited]).to be true
end
end end
it 'updates the favourites count' do context 'with private status of not-followed account' do
expect(status.favourites.count).to eq 1 let(:status) { Fabricate(:status, visibility: :private) }
end
it 'updates the favourited attribute' do it 'returns http not found' do
expect(user.account.favourited?(status)).to be true expect(response).to have_http_status(404)
end end
it 'return json with updated attributes' do
hash_body = body_as_json
expect(hash_body[:id]).to eq status.id.to_s
expect(hash_body[:favourites_count]).to eq 1
expect(hash_body[:favourited]).to be true
end end
end end
describe 'POST #destroy' do describe 'POST #destroy' do
let(:status) { Fabricate(:status, account: user.account) } context 'with public status' do
let(:status) { Fabricate(:status, account: user.account) }
before do before do
FavouriteService.new.call(user.account, status) FavouriteService.new.call(user.account, status)
post :destroy, params: { status_id: status.id } post :destroy, params: { status_id: status.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'updates the favourites count' do
expect(status.favourites.count).to eq 0
end
it 'updates the favourited attribute' do
expect(user.account.favourited?(status)).to be false
end
it 'returns json with updated attributes' do
hash_body = body_as_json
expect(hash_body[:id]).to eq status.id.to_s
expect(hash_body[:favourites_count]).to eq 0
expect(hash_body[:favourited]).to be false
end
end end
it 'returns http success' do context 'with private status that was not favourited' do
expect(response).to have_http_status(200) let(:status) { Fabricate(:status, visibility: :private) }
end
it 'updates the favourites count' do before do
expect(status.favourites.count).to eq 0 post :destroy, params: { status_id: status.id }
end end
it 'updates the favourited attribute' do it 'returns http not found' do
expect(user.account.favourited?(status)).to be false expect(response).to have_http_status(404)
end
end end
end end
end end

View file

@ -21,45 +21,77 @@ describe Api::V1::Statuses::ReblogsController do
post :create, params: { status_id: status.id } post :create, params: { status_id: status.id }
end end
it 'returns http success' do context 'with public status' do
expect(response).to have_http_status(200) it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'updates the reblogs count' do
expect(status.reblogs.count).to eq 1
end
it 'updates the reblogged attribute' do
expect(user.account.reblogged?(status)).to be true
end
it 'returns json with updated attributes' do
hash_body = body_as_json
expect(hash_body[:reblog][:id]).to eq status.id.to_s
expect(hash_body[:reblog][:reblogs_count]).to eq 1
expect(hash_body[:reblog][:reblogged]).to be true
end
end end
it 'updates the reblogs count' do context 'with private status of not-followed account' do
expect(status.reblogs.count).to eq 1 let(:status) { Fabricate(:status, visibility: :private) }
end
it 'updates the reblogged attribute' do it 'returns http not found' do
expect(user.account.reblogged?(status)).to be true expect(response).to have_http_status(404)
end end
it 'return json with updated attributes' do
hash_body = body_as_json
expect(hash_body[:reblog][:id]).to eq status.id.to_s
expect(hash_body[:reblog][:reblogs_count]).to eq 1
expect(hash_body[:reblog][:reblogged]).to be true
end end
end end
describe 'POST #destroy' do describe 'POST #destroy' do
let(:status) { Fabricate(:status, account: user.account) } context 'with public status' do
let(:status) { Fabricate(:status, account: user.account) }
before do before do
ReblogService.new.call(user.account, status) ReblogService.new.call(user.account, status)
post :destroy, params: { status_id: status.id } post :destroy, params: { status_id: status.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'updates the reblogs count' do
expect(status.reblogs.count).to eq 0
end
it 'updates the reblogged attribute' do
expect(user.account.reblogged?(status)).to be false
end
it 'returns json with updated attributes' do
hash_body = body_as_json
expect(hash_body[:id]).to eq status.id.to_s
expect(hash_body[:reblogs_count]).to eq 0
expect(hash_body[:reblogged]).to be false
end
end end
it 'returns http success' do context 'with private status that was not reblogged' do
expect(response).to have_http_status(200) let(:status) { Fabricate(:status, visibility: :private) }
end
it 'updates the reblogs count' do before do
expect(status.reblogs.count).to eq 0 post :destroy, params: { status_id: status.id }
end end
it 'updates the reblogged attribute' do it 'returns http not found' do
expect(user.account.reblogged?(status)).to be false expect(response).to have_http_status(404)
end
end end
end end
end end

View file

@ -7,16 +7,10 @@ describe ApplicationController, type: :controller do
include Localized include Localized
def success def success
head 200 render plain: I18n.locale, status: 200
end end
end end
around do |example|
current_locale = I18n.locale
example.run
I18n.locale = current_locale
end
before do before do
routes.draw { get 'success' => 'anonymous#success' } routes.draw { get 'success' => 'anonymous#success' }
end end
@ -25,19 +19,19 @@ describe ApplicationController, type: :controller do
it 'sets available and preferred language' do it 'sets available and preferred language' do
request.headers['Accept-Language'] = 'ca-ES, fa' request.headers['Accept-Language'] = 'ca-ES, fa'
get 'success' get 'success'
expect(I18n.locale).to eq :fa expect(response.body).to eq 'fa'
end end
it 'sets available and compatible language if none of available languages are preferred' do it 'sets available and compatible language if none of available languages are preferred' do
request.headers['Accept-Language'] = 'fa-IR' request.headers['Accept-Language'] = 'fa-IR'
get 'success' get 'success'
expect(I18n.locale).to eq :fa expect(response.body).to eq 'fa'
end end
it 'sets default locale if none of available languages are compatible' do it 'sets default locale if none of available languages are compatible' do
request.headers['Accept-Language'] = '' request.headers['Accept-Language'] = ''
get 'success' get 'success'
expect(I18n.locale).to eq :en expect(response.body).to eq 'en'
end end
end end
@ -48,7 +42,7 @@ describe ApplicationController, type: :controller do
sign_in(user) sign_in(user)
get 'success' get 'success'
expect(I18n.locale).to eq :ca expect(response.body).to eq 'ca'
end end
end end

View file

@ -149,6 +149,14 @@ RSpec.describe FeedManager do
status = Fabricate(:status, text: 'shiitake', account: jeff) status = Fabricate(:status, text: 'shiitake', account: jeff)
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
end end
it 'returns true if phrase is contained in a poll option' do
alice.custom_filters.create!(phrase: 'farts', context: %w(home public), irreversible: true)
alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true)
alice.follow!(jeff)
status = Fabricate(:status, text: 'what do you prefer', poll: Fabricate(:poll, options: %w(farts POP TARts)), account: jeff)
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
end
end end
end end
@ -239,6 +247,23 @@ RSpec.describe FeedManager do
expect(FeedManager.instance.push_to_home(account, reblogs.last)).to be false expect(FeedManager.instance.push_to_home(account, reblogs.last)).to be false
end end
it 'saves a new reblog of a recently-reblogged status when previous reblog has been deleted' do
account = Fabricate(:account)
reblogged = Fabricate(:status)
old_reblog = Fabricate(:status, reblog: reblogged)
# The first reblog should be accepted
expect(FeedManager.instance.push_to_home(account, old_reblog)).to be true
# The first reblog should be successfully removed
expect(FeedManager.instance.unpush_from_home(account, old_reblog)).to be true
reblog = Fabricate(:status, reblog: reblogged)
# The second reblog should be accepted
expect(FeedManager.instance.push_to_home(account, reblog)).to be true
end
it 'does not save a new reblog of a multiply-reblogged-then-unreblogged status' do it 'does not save a new reblog of a multiply-reblogged-then-unreblogged status' do
account = Fabricate(:account) account = Fabricate(:account)
reblogged = Fabricate(:status) reblogged = Fabricate(:status)

View file

@ -261,7 +261,7 @@ RSpec.describe Formatter do
let(:text) { ':coolcat: Beep boop' } let(:text) { ':coolcat: Beep boop' }
it 'converts the shortcode to an image tag' do it 'converts the shortcode to an image tag' do
is_expected.to match(/<img draggable="false" class="emojione" alt=":coolcat:"/) is_expected.to match(/<img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
end end
@ -330,7 +330,7 @@ RSpec.describe Formatter do
let(:text) { ':coolcat: Beep boop' } let(:text) { ':coolcat: Beep boop' }
it 'converts the shortcode to an image tag' do it 'converts the shortcode to an image tag' do
is_expected.to match(/<p><img draggable="false" class="emojione" alt=":coolcat:"/) is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
@ -338,7 +338,7 @@ RSpec.describe Formatter do
let(:text) { 'Beep :coolcat: boop' } let(:text) { 'Beep :coolcat: boop' }
it 'converts the shortcode to an image tag' do it 'converts the shortcode to an image tag' do
is_expected.to match(/Beep <img draggable="false" class="emojione" alt=":coolcat:"/) is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
@ -354,7 +354,7 @@ RSpec.describe Formatter do
let(:text) { 'Beep boop :coolcat:' } let(:text) { 'Beep boop :coolcat:' }
it 'converts the shortcode to an image tag' do it 'converts the shortcode to an image tag' do
is_expected.to match(/boop <img draggable="false" class="emojione" alt=":coolcat:"/) is_expected.to match(/boop <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
end end
@ -377,7 +377,7 @@ RSpec.describe Formatter do
let(:text) { '<p>:coolcat: Beep boop<br />' } let(:text) { '<p>:coolcat: Beep boop<br />' }
it 'converts the shortcode to an image tag' do it 'converts the shortcode to an image tag' do
is_expected.to match(/<p><img draggable="false" class="emojione" alt=":coolcat:"/) is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
@ -385,7 +385,7 @@ RSpec.describe Formatter do
let(:text) { '<p>Beep :coolcat: boop</p>' } let(:text) { '<p>Beep :coolcat: boop</p>' }
it 'converts the shortcode to an image tag' do it 'converts the shortcode to an image tag' do
is_expected.to match(/Beep <img draggable="false" class="emojione" alt=":coolcat:"/) is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
@ -401,7 +401,7 @@ RSpec.describe Formatter do
let(:text) { '<p>Beep boop<br />:coolcat:</p>' } let(:text) { '<p>Beep boop<br />:coolcat:</p>' }
it 'converts the shortcode to an image tag' do it 'converts the shortcode to an image tag' do
is_expected.to match(/<br><img draggable="false" class="emojione" alt=":coolcat:"/) is_expected.to match(/<br><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
end end
@ -500,7 +500,7 @@ RSpec.describe Formatter do
let(:text) { ':coolcat: Beep boop' } let(:text) { ':coolcat: Beep boop' }
it 'converts the shortcode to an image tag' do it 'converts the shortcode to an image tag' do
is_expected.to match(/<p><img draggable="false" class="emojione" alt=":coolcat:"/) is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
@ -508,7 +508,7 @@ RSpec.describe Formatter do
let(:text) { 'Beep :coolcat: boop' } let(:text) { 'Beep :coolcat: boop' }
it 'converts the shortcode to an image tag' do it 'converts the shortcode to an image tag' do
is_expected.to match(/Beep <img draggable="false" class="emojione" alt=":coolcat:"/) is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
@ -524,7 +524,7 @@ RSpec.describe Formatter do
let(:text) { 'Beep boop :coolcat:' } let(:text) { 'Beep boop :coolcat:' }
it 'converts the shortcode to an image tag' do it 'converts the shortcode to an image tag' do
is_expected.to match(/boop <img draggable="false" class="emojione" alt=":coolcat:"/) is_expected.to match(/boop <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
end end
@ -551,7 +551,7 @@ RSpec.describe Formatter do
let(:text) { '<p>:coolcat: Beep boop<br />' } let(:text) { '<p>:coolcat: Beep boop<br />' }
it 'converts shortcode to image tag' do it 'converts shortcode to image tag' do
is_expected.to match(/<p><img draggable="false" class="emojione" alt=":coolcat:"/) is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
@ -559,7 +559,7 @@ RSpec.describe Formatter do
let(:text) { '<p>Beep :coolcat: boop</p>' } let(:text) { '<p>Beep :coolcat: boop</p>' }
it 'converts shortcode to image tag' do it 'converts shortcode to image tag' do
is_expected.to match(/Beep <img draggable="false" class="emojione" alt=":coolcat:"/) is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
@ -575,7 +575,7 @@ RSpec.describe Formatter do
let(:text) { '<p>Beep boop<br />:coolcat:</p>' } let(:text) { '<p>Beep boop<br />:coolcat:</p>' }
it 'converts shortcode to image tag' do it 'converts shortcode to image tag' do
is_expected.to match(/<br><img draggable="false" class="emojione" alt=":coolcat:"/) is_expected.to match(/<br><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
end end

View file

@ -32,11 +32,11 @@ describe LanguageDetector do
expect(result).to eq 'Our website is and also' expect(result).to eq 'Our website is and also'
end end
it 'strips #hashtags from strings before detection' do it 'converts #hashtags back to normal text before detection' do
string = 'Hey look at all the #animals and #fish' string = 'Hey look at all the #animals and #FishAndChips'
result = described_class.instance.send(:prepare_text, string) result = described_class.instance.send(:prepare_text, string)
expect(result).to eq 'Hey look at all the and' expect(result).to eq 'Hey look at all the animals and fish and chips'
end end
end end

View file

@ -22,5 +22,9 @@ describe Sanitize::Config do
it 'converts ul inside ul' do it 'converts ul inside ul' do
expect(Sanitize.fragment('<ul><li>Foo</li><li><ul><li>Bar</li><li>Baz</li></ul></li></ul>', subject)).to eq '<p>Foo<br>Bar<br>Baz</p>' expect(Sanitize.fragment('<ul><li>Foo</li><li><ul><li>Bar</li><li>Baz</li></ul></li></ul>', subject)).to eq '<p>Foo<br>Bar<br>Baz</p>'
end end
it 'keep links in lists' do
expect(Sanitize.fragment('<p>Check out:</p><ul><li><a href="https://joinmastodon.org" rel="nofollow noopener" target="_blank">joinmastodon.org</a></li><li>Bar</li></ul>', subject)).to eq '<p>Check out:</p><p><a href="https://joinmastodon.org" rel="nofollow noopener" target="_blank">joinmastodon.org</a><br>Bar</p>'
end
end end
end end

View file

@ -3,27 +3,33 @@ require 'rails_helper'
RSpec.describe Invite, type: :model do RSpec.describe Invite, type: :model do
describe '#valid_for_use?' do describe '#valid_for_use?' do
it 'returns true when there are no limitations' do it 'returns true when there are no limitations' do
invite = Invite.new(max_uses: nil, expires_at: nil) invite = Fabricate(:invite, max_uses: nil, expires_at: nil)
expect(invite.valid_for_use?).to be true expect(invite.valid_for_use?).to be true
end end
it 'returns true when not expired' do it 'returns true when not expired' do
invite = Invite.new(max_uses: nil, expires_at: 1.hour.from_now) invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.from_now)
expect(invite.valid_for_use?).to be true expect(invite.valid_for_use?).to be true
end end
it 'returns false when expired' do it 'returns false when expired' do
invite = Invite.new(max_uses: nil, expires_at: 1.hour.ago) invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.ago)
expect(invite.valid_for_use?).to be false expect(invite.valid_for_use?).to be false
end end
it 'returns true when uses still available' do it 'returns true when uses still available' do
invite = Invite.new(max_uses: 250, uses: 249, expires_at: nil) invite = Fabricate(:invite, max_uses: 250, uses: 249, expires_at: nil)
expect(invite.valid_for_use?).to be true expect(invite.valid_for_use?).to be true
end end
it 'returns false when maximum uses reached' do it 'returns false when maximum uses reached' do
invite = Invite.new(max_uses: 250, uses: 250, expires_at: nil) invite = Fabricate(:invite, max_uses: 250, uses: 250, expires_at: nil)
expect(invite.valid_for_use?).to be false
end
it 'returns false when invite creator has been disabled' do
invite = Fabricate(:invite, max_uses: nil, expires_at: nil)
SuspendAccountService.new.call(invite.user.account)
expect(invite.valid_for_use?).to be false expect(invite.valid_for_use?).to be false
end end
end end

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