Jay Logan 2022-05-28 04:16:14 +00:00
commit 2fab999373
610 changed files with 18326 additions and 7107 deletions

7
.browserslistrc 100644
View File

@ -0,0 +1,7 @@
[production]
defaults
not IE 11
not dead
[development]
supports es6-module

View File

@ -11,7 +11,8 @@
"extensions": [
"EditorConfig.EditorConfig",
"dbaeumer.vscode-eslint",
"rebornix.Ruby"
"rebornix.Ruby",
"webben.browserslist"
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.

View File

@ -3,6 +3,105 @@ Changelog
All notable changes to this project will be documented in this file.
## [3.5.3] - 2022-05-26
### Added
- **Add language dropdown to compose form in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/18420), [ykzts](https://github.com/mastodon/mastodon/pull/18460))
- **Add warning for limited accounts in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/18344))
- Add `limited` attribute to accounts in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/18344))
### Changed
- **Change RSS feeds** ([Gargron](https://github.com/mastodon/mastodon/pull/18356), [tribela](https://github.com/mastodon/mastodon/pull/18406))
- Titles are now date and time of post
- Bodies now render all content faithfully, including polls and emojis
- All media attachments are included with Media RSS
- Change "dangerous" to "sensitive" in privacy policy and web UI ([Gargron](https://github.com/mastodon/mastodon/pull/18515))
- Change unconfirmed accounts to not be visible in REST API ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17530))
- Change `tootctl search deploy` to improve performance ([Gargron](https://github.com/mastodon/mastodon/pull/18463), [Gargron](https://github.com/mastodon/mastodon/pull/18514))
- Change search indexing to use batches to minimize resource usage ([Gargron](https://github.com/mastodon/mastodon/pull/18451))
### Fixed
- Fix follower and other counters being able to go negative ([Gargron](https://github.com/mastodon/mastodon/pull/18517))
- Fix unnecessary query on when creating a status ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17901))
- Fix warning an account outside of a report closing all reports for that account ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18387))
- Fix error when resolving a link that redirects to a local post ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18314))
- Fix preferred posting language returning unusable value in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/18428))
- Fix race condition error when external status is reblogged ([ykzts](https://github.com/mastodon/mastodon/pull/18424))
- Fix missing string for appeal validation error ([Gargron](https://github.com/mastodon/mastodon/pull/18410))
- Fix block/mute lists showing a follow button in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18364))
- Fix Redis configuration not being changed by `mastodon:setup` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18383))
- Fix streaming notifications not using quick filter logic in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18316))
- Fix ambiguous wording on appeal actions in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18328))
- Fix floating action button obscuring last element in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18332))
- Fix account warnings not being recorded in audit log ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18338))
- Fix leftover icons for direct visibility statuses ([Steffo99](https://github.com/mastodon/mastodon/pull/18305))
- Fix link verification requiring case sensitivity on links ([sgolemon](https://github.com/mastodon/mastodon/pull/18320))
- Fix embeds not setting their height correctly ([rinsuki](https://github.com/mastodon/mastodon/pull/18301))
### Security
- Fix concurrent unfollowing decrementing follower count more than once ([Gargron](https://github.com/mastodon/mastodon/pull/18527))
- Fix being able to appeal a strike unlimited times ([Gargron](https://github.com/mastodon/mastodon/pull/18529))
- Fix being able to report otherwise inaccessible statuses ([Gargron](https://github.com/mastodon/mastodon/pull/18528))
- Fix empty votes arbitrarily increasing voters count in polls ([Gargron](https://github.com/mastodon/mastodon/pull/18526))
- Fix moderator identity leak when approving appeal of sensitive marked statuses ([Gargron](https://github.com/mastodon/mastodon/pull/18525))
- Fix suspended users being able to access APIs that don't require a user ([Gargron](https://github.com/mastodon/mastodon/pull/18524))
- Fix confirmation redirect to app without `Location` header ([Gargron](https://github.com/mastodon/mastodon/pull/18523))
## [3.5.2] - 2022-05-04
### Added
- Add warning on direct messages screen in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/18289))
- We already had a warning when composing a direct message, it has now been reworded to be more clear
- Same warning is now displayed when viewing sent and received direct messages
- Add ability to set approval-based registration through tootctl ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18248))
- Add pre-filling of domain from search filter in domain allow/block admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18172))
## Changed
- Change name of “Direct” visibility to “Mentioned people only” in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/18146), [Gargron](https://github.com/mastodon/mastodon/pull/18289), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18291))
- Change trending posts to only show one post from each account ([Gargron](https://github.com/mastodon/mastodon/pull/18181))
- Change half-life of trending posts from 6 hours to 2 hours ([Gargron](https://github.com/mastodon/mastodon/pull/18182))
- Change full-text search feature to also include polls you have voted in ([tribela](https://github.com/mastodon/mastodon/pull/18070))
- Change Redis from using one connection per process, to using a connection pool ([Gargron](https://github.com/mastodon/mastodon/pull/18135), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18160), [Gargron](https://github.com/mastodon/mastodon/pull/18171))
- Different threads no longer have to wait on a mutex over a single connection
- However, this does increase the number of Redis connections by a fair amount
- We are planning to optimize Redis use so that the pool can be made smaller in the future
## Removed
- Remove IP matching from e-mail domain blocks ([Gargron](https://github.com/mastodon/mastodon/pull/18190))
- The IPs of the blocked e-mail domain or its MX records are no longer checked
- Previously it was too easy to block e-mail providers by mistake
## Fixed
- Fix compatibility with Friendica's pinned posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18254), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18260))
- Fix error when looking up handle with surrounding spaces in REST API ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18225))
- Fix double render error when authorizing interaction ([Gargron](https://github.com/mastodon/mastodon/pull/18203))
- Fix error when a post references an invalid media attachment ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18211))
- Fix error when trying to revoke OAuth token without supplying a token ([Gargron](https://github.com/mastodon/mastodon/pull/18205))
- Fix error caused by missing subject in Webfinger response ([Gargron](https://github.com/mastodon/mastodon/pull/18204))
- Fix error on attempting to delete an account moderation note ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18196))
- Fix light-mode emoji borders in web UI ([Gaelan](https://github.com/mastodon/mastodon/pull/18131))
- Fix being able to scroll away from the loading bar in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/18170))
- Fix error when a bookmark or favorite has been reported and deleted ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18174))
- Fix being offered empty “Server rules violation” report option in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18165))
- Fix temporary network errors preventing from authorizing interactions with remote accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18161))
- Fix incorrect link in "new trending tags" email ([cdzombak](https://github.com/mastodon/mastodon/pull/18156))
- Fix missing indexes on some foreign keys ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18157))
- Fix n+1 query on feed merge and populate operations ([Gargron](https://github.com/mastodon/mastodon/pull/18111))
- Fix feed unmerge worker being exceptionally slow in some conditions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18110))
- Fix PeerTube videos appearing with an erroneous “Edited at” marker ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18100))
- Fix instance actor being created incorrectly when running through migrations ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18109))
- Fix web push notifications containing HTML entities ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18071))
- Fix inconsistent parsing of `TRUSTED_PROXY_IP` ([ykzts](https://github.com/mastodon/mastodon/pull/18051))
- Fix error when fetching pinned posts ([tribela](https://github.com/mastodon/mastodon/pull/18030))
- Fix wrong optimization in feed populate operation ([dogelover911](https://github.com/mastodon/mastodon/pull/18009))
- Fix error in alias settings page ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18004))
## [3.5.1] - 2022-04-08
### Added

20
Gemfile
View File

@ -1,13 +1,13 @@
# frozen_string_literal: true
source 'https://rubygems.org'
ruby '>= 2.5.0', '< 3.1.0'
ruby '>= 2.6.0', '< 3.1.0'
gem 'pkg-config', '~> 1.4'
gem 'rexml', '~> 3.2'
gem 'puma', '~> 5.6'
gem 'rails', '~> 6.1.5'
gem 'rails', '~> 6.1.6'
gem 'sprockets', '~> 3.7.2'
gem 'thor', '~> 1.2'
gem 'rack', '~> 2.2.3'
@ -18,7 +18,7 @@ gem 'makara', '~> 0.5'
gem 'pghero', '~> 2.8'
gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.113', require: false
gem 'aws-sdk-s3', '~> 1.114', require: false
gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false
gem 'kt-paperclip', '~> 7.1'
@ -26,7 +26,7 @@ gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.8'
gem 'bootsnap', '~> 1.10.3', require: false
gem 'bootsnap', '~> 1.11.1', require: false
gem 'browser'
gem 'charlock_holmes', '~> 0.7.7'
gem 'chewy', '~> 7.2'
@ -79,13 +79,13 @@ gem 'ruby-progressbar', '~> 1.11'
gem 'sanitize', '~> 6.0'
gem 'scenic', '~> 1.6'
gem 'sidekiq', '~> 6.4'
gem 'sidekiq-scheduler', '~> 3.1'
gem 'sidekiq-scheduler', '~> 4.0'
gem 'sidekiq-unique-jobs', '~> 7.1'
gem 'sidekiq-bulk', '~>0.2.0'
gem 'sidekiq-bulk', '~> 0.2.0'
gem 'simple-navigation', '~> 4.3'
gem 'simple_form', '~> 5.1'
gem 'sprockets-rails', '~> 3.4', require: 'sprockets/railtie'
gem 'stoplight', '~> 2.2.1'
gem 'stoplight', '~> 3.0.0'
gem 'strong_migrations', '~> 0.7'
gem 'tty-prompt', '~> 0.23', require: false
gem 'twitter-text', '~> 3.1.0'
@ -114,9 +114,9 @@ group :production, :test do
end
group :test do
gem 'capybara', '~> 3.36'
gem 'capybara', '~> 3.37'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.20'
gem 'faker', '~> 2.21'
gem 'microformats', '~> 4.2'
gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.1'
@ -134,7 +134,7 @@ group :development do
gem 'letter_opener', '~> 1.8'
gem 'letter_opener_web', '~> 2.0'
gem 'memory_profiler'
gem 'rubocop', '~> 1.26', require: false
gem 'rubocop', '~> 1.29', require: false
gem 'rubocop-rails', '~> 2.14', require: false
gem 'brakeman', '~> 5.2', require: false
gem 'bundler-audit', '~> 0.9', require: false

View File

@ -1,40 +1,40 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (6.1.5)
actionpack (= 6.1.5)
activesupport (= 6.1.5)
actioncable (6.1.6)
actionpack (= 6.1.6)
activesupport (= 6.1.6)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.5)
actionpack (= 6.1.5)
activejob (= 6.1.5)
activerecord (= 6.1.5)
activestorage (= 6.1.5)
activesupport (= 6.1.5)
actionmailbox (6.1.6)
actionpack (= 6.1.6)
activejob (= 6.1.6)
activerecord (= 6.1.6)
activestorage (= 6.1.6)
activesupport (= 6.1.6)
mail (>= 2.7.1)
actionmailer (6.1.5)
actionpack (= 6.1.5)
actionview (= 6.1.5)
activejob (= 6.1.5)
activesupport (= 6.1.5)
actionmailer (6.1.6)
actionpack (= 6.1.6)
actionview (= 6.1.6)
activejob (= 6.1.6)
activesupport (= 6.1.6)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.5)
actionview (= 6.1.5)
activesupport (= 6.1.5)
actionpack (6.1.6)
actionview (= 6.1.6)
activesupport (= 6.1.6)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.5)
actionpack (= 6.1.5)
activerecord (= 6.1.5)
activestorage (= 6.1.5)
activesupport (= 6.1.5)
actiontext (6.1.6)
actionpack (= 6.1.6)
activerecord (= 6.1.6)
activestorage (= 6.1.6)
activesupport (= 6.1.6)
nokogiri (>= 1.8.5)
actionview (6.1.5)
activesupport (= 6.1.5)
actionview (6.1.6)
activesupport (= 6.1.6)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
@ -45,22 +45,22 @@ GEM
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
active_record_query_trace (1.8)
activejob (6.1.5)
activesupport (= 6.1.5)
activejob (6.1.6)
activesupport (= 6.1.6)
globalid (>= 0.3.6)
activemodel (6.1.5)
activesupport (= 6.1.5)
activerecord (6.1.5)
activemodel (= 6.1.5)
activesupport (= 6.1.5)
activestorage (6.1.5)
actionpack (= 6.1.5)
activejob (= 6.1.5)
activerecord (= 6.1.5)
activesupport (= 6.1.5)
activemodel (6.1.6)
activesupport (= 6.1.6)
activerecord (6.1.6)
activemodel (= 6.1.6)
activesupport (= 6.1.6)
activestorage (6.1.6)
actionpack (= 6.1.6)
activejob (= 6.1.6)
activerecord (= 6.1.6)
activesupport (= 6.1.6)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (6.1.5)
activesupport (6.1.6)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@ -81,20 +81,20 @@ GEM
attr_required (1.0.1)
awrence (1.1.1)
aws-eventstream (1.2.0)
aws-partitions (1.558.0)
aws-sdk-core (3.127.0)
aws-partitions (1.587.0)
aws-sdk-core (3.130.2)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.525.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.55.0)
aws-sdk-kms (1.56.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.113.0)
aws-sdk-s3 (1.114.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.4.0)
aws-sigv4 (1.5.0)
aws-eventstream (~> 1, >= 1.0.2)
bcrypt (3.1.17)
better_errors (2.9.1)
@ -114,9 +114,9 @@ GEM
debug_inspector (>= 0.0.1)
blurhash (0.1.6)
ffi (~> 1.14)
bootsnap (1.10.3)
bootsnap (1.11.1)
msgpack (~> 1.2)
brakeman (5.2.1)
brakeman (5.2.3)
browser (4.2.0)
brpoplpush-redis_script (0.1.2)
concurrent-ruby (~> 1.0, >= 1.0.5)
@ -144,7 +144,7 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (3.36.0)
capybara (3.37.1)
addressable
matrix
mini_mime (>= 0.1.3)
@ -203,7 +203,6 @@ GEM
dotenv-rails (2.7.6)
dotenv (= 2.7.6)
railties (>= 3.2)
e2mmap (0.1.0)
ed25519 (1.3.0)
elasticsearch (7.13.3)
elasticsearch-api (= 7.13.3)
@ -216,11 +215,11 @@ GEM
multi_json
encryptor (3.0.0)
erubi (1.10.0)
et-orbi (1.2.6)
et-orbi (1.2.7)
tzinfo
excon (0.76.0)
fabrication (2.28.0)
faker (2.20.0)
faker (2.21.0)
i18n (>= 1.8.11, < 2)
faraday (1.9.3)
faraday-em_http (~> 1.0)
@ -264,8 +263,8 @@ GEM
fog-json (>= 1.0)
ipaddress (>= 0.8)
formatador (0.2.5)
fugit (1.5.2)
et-orbi (~> 1.1, >= 1.1.8)
fugit (1.5.3)
et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4)
fuubar (2.5.1)
rspec-core (~> 3.0)
@ -309,7 +308,7 @@ GEM
rainbow (>= 2.0.0)
i18n (1.10.0)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.8)
i18n-tasks (1.0.10)
activesupport (>= 4.0.2)
ast (>= 2.1.0)
better_html (~> 1.0)
@ -322,7 +321,7 @@ GEM
terminal-table (>= 1.5.1)
idn-ruby (0.1.4)
ipaddress (0.8.3)
jmespath (1.6.0)
jmespath (1.6.1)
json (2.5.1)
json-canonicalization (0.3.0)
json-jwt (1.13.0)
@ -377,7 +376,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.16.0)
loofah (2.18.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -399,7 +398,7 @@ GEM
mini_mime (1.1.2)
mini_portile2 (2.8.0)
minitest (5.15.0)
msgpack (1.4.4)
msgpack (1.5.1)
multi_json (1.15.0)
multipart-post (2.1.1)
net-ldap (0.17.0)
@ -407,7 +406,7 @@ GEM
net-ssh (>= 2.6.5, < 7.0.0)
net-ssh (6.1.0)
nio4r (2.5.8)
nokogiri (1.13.3)
nokogiri (1.13.6)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
nsa (0.2.8)
@ -444,13 +443,13 @@ GEM
orm_adapter (0.5.0)
ox (2.14.11)
parallel (1.22.1)
parser (3.1.1.0)
parser (3.1.2.0)
ast (~> 2.4.1)
parslet (2.0.0)
pastel (0.8.0)
tty-color (~> 0.5)
pg (1.3.5)
pghero (2.8.2)
pghero (2.8.3)
activerecord (>= 5)
pkg-config (1.4.7)
posix-spawn (0.3.15)
@ -470,7 +469,7 @@ GEM
pry (~> 0.13.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.6)
public_suffix (4.0.7)
puma (5.6.4)
nio4r (~> 2.0)
pundit (2.2.0)
@ -478,7 +477,7 @@ GEM
raabro (1.4.0)
racc (1.6.0)
rack (2.2.3)
rack-attack (6.6.0)
rack-attack (6.6.1)
rack (>= 1.0, < 3)
rack-cors (1.1.1)
rack (>= 2.0.0)
@ -492,20 +491,20 @@ GEM
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rails (6.1.5)
actioncable (= 6.1.5)
actionmailbox (= 6.1.5)
actionmailer (= 6.1.5)
actionpack (= 6.1.5)
actiontext (= 6.1.5)
actionview (= 6.1.5)
activejob (= 6.1.5)
activemodel (= 6.1.5)
activerecord (= 6.1.5)
activestorage (= 6.1.5)
activesupport (= 6.1.5)
rails (6.1.6)
actioncable (= 6.1.6)
actionmailbox (= 6.1.6)
actionmailer (= 6.1.6)
actionpack (= 6.1.6)
actiontext (= 6.1.6)
actionview (= 6.1.6)
activejob (= 6.1.6)
activemodel (= 6.1.6)
activerecord (= 6.1.6)
activestorage (= 6.1.6)
activesupport (= 6.1.6)
bundler (>= 1.15.0)
railties (= 6.1.5)
railties (= 6.1.6)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
@ -521,9 +520,9 @@ GEM
railties (>= 6.0.0, < 7)
rails-settings-cached (0.6.6)
rails (>= 4.2.0)
railties (6.1.5)
actionpack (= 6.1.5)
activesupport (= 6.1.5)
railties (6.1.6)
actionpack (= 6.1.6)
activesupport (= 6.1.6)
method_source
rake (>= 12.2)
thor (~> 1.0)
@ -537,7 +536,7 @@ GEM
redis (4.5.1)
redis-namespace (1.8.2)
redis (>= 3.0.4)
regexp_parser (2.2.1)
regexp_parser (2.4.0)
request_store (1.5.1)
rack (>= 1.4)
responders (3.0.1)
@ -555,10 +554,10 @@ GEM
rspec-expectations (3.11.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.11.0)
rspec-mocks (3.11.0)
rspec-mocks (3.11.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.11.0)
rspec-rails (5.1.1)
rspec-rails (5.1.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
railties (>= 5.2)
@ -572,16 +571,16 @@ GEM
rspec-support (3.11.0)
rspec_junit_formatter (0.5.1)
rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.26.1)
rubocop (1.29.1)
parallel (~> 1.10)
parser (>= 3.1.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml
rubocop-ast (>= 1.16.0, < 2.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.17.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.16.0)
rubocop-ast (1.18.0)
parser (>= 3.1.1.0)
rubocop-rails (2.14.2)
activesupport (>= 4.2.0)
@ -604,20 +603,18 @@ GEM
railties (>= 4.0.0)
securecompare (1.0.0)
semantic_range (3.0.0)
sidekiq (6.4.1)
sidekiq (6.4.2)
connection_pool (>= 2.2.2)
rack (~> 2.0)
redis (>= 4.2.0)
sidekiq-bulk (0.2.0)
sidekiq
sidekiq-scheduler (3.1.1)
e2mmap
redis (>= 3, < 5)
sidekiq-scheduler (4.0.0)
redis (>= 4.2.0)
rufus-scheduler (~> 3.2)
sidekiq (>= 3)
thwait
sidekiq (>= 4)
tilt (>= 1.4.0)
sidekiq-unique-jobs (7.1.16)
sidekiq-unique-jobs (7.1.22)
brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 5.0, < 8.0)
@ -646,7 +643,7 @@ GEM
net-ssh (>= 2.8.0)
stackprof (0.2.19)
statsd-ruby (1.5.0)
stoplight (2.2.1)
stoplight (3.0.0)
strong_migrations (0.7.9)
activerecord (>= 5)
swd (1.3.0)
@ -659,8 +656,6 @@ GEM
terrapin (0.6.0)
climate_control (>= 0.0.3, < 1.0)
thor (1.2.1)
thwait (0.2.0)
e2mmap
tilt (2.0.10)
tpm-key_attestation (0.9.0)
bindata (~> 2.4)
@ -737,11 +732,11 @@ DEPENDENCIES
active_record_query_trace (~> 1.8)
addressable (~> 2.8)
annotate (~> 3.2)
aws-sdk-s3 (~> 1.113)
aws-sdk-s3 (~> 1.114)
better_errors (~> 2.9)
binding_of_caller (~> 1.0)
blurhash (~> 0.1)
bootsnap (~> 1.10.3)
bootsnap (~> 1.11.1)
brakeman (~> 5.2)
browser
bullet (~> 7.0)
@ -750,7 +745,7 @@ DEPENDENCIES
capistrano-rails (~> 1.6)
capistrano-rbenv (~> 2.2)
capistrano-yarn (~> 2.0)
capybara (~> 3.36)
capybara (~> 3.37)
charlock_holmes (~> 0.7.7)
chewy (~> 7.2)
climate_control (~> 0.2)
@ -765,7 +760,7 @@ DEPENDENCIES
dotenv-rails (~> 2.7)
ed25519 (~> 1.3)
fabrication (~> 2.28)
faker (~> 2.20)
faker (~> 2.21)
fast_blank (~> 1.0)
fastimage
fog-core (<= 2.1.0)
@ -817,7 +812,7 @@ DEPENDENCIES
rack (~> 2.2.3)
rack-attack (~> 6.6)
rack-cors (~> 1.1)
rails (~> 6.1.5)
rails (~> 6.1.6)
rails-controller-testing (~> 1.0)
rails-i18n (~> 6.0)
rails-settings-cached (~> 0.6)
@ -830,14 +825,14 @@ DEPENDENCIES
rspec-rails (~> 5.1)
rspec-sidekiq (~> 3.1)
rspec_junit_formatter (~> 0.5)
rubocop (~> 1.26)
rubocop (~> 1.29)
rubocop-rails (~> 2.14)
ruby-progressbar (~> 1.11)
sanitize (~> 6.0)
scenic (~> 1.6)
sidekiq (~> 6.4)
sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 3.1)
sidekiq-scheduler (~> 4.0)
sidekiq-unique-jobs (~> 7.1)
simple-navigation (~> 4.3)
simple_form (~> 5.1)
@ -845,7 +840,7 @@ DEPENDENCIES
sprockets (~> 3.7.2)
sprockets-rails (~> 3.4)
stackprof
stoplight (~> 2.2.1)
stoplight (~> 3.0.0)
strong_migrations (~> 0.7)
thor (~> 1.2)
tty-prompt (~> 0.23)

View File

@ -14,7 +14,7 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
| ------- | ------------------ |
| 3.5.x | Yes |
| 3.4.x | Yes |
| 3.3.x | Yes |
| 3.3.x | No |
| < 3.3 | No |
[bug-bounty]: https://app.intigriti.com/programs/mastodon/mastodonio/detail

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class AccountsIndex < Chewy::Index
settings index: { refresh_interval: '5m' }, analysis: {
settings index: { refresh_interval: '30s' }, analysis: {
analyzer: {
content: {
tokenizer: 'whitespace',
@ -23,7 +23,7 @@ class AccountsIndex < Chewy::Index
},
}
index_scope ::Account.searchable.includes(:account_stat), delete_if: ->(account) { account.destroyed? || !account.searchable? }
index_scope ::Account.searchable.includes(:account_stat)
root date_detection: false do
field :id, type: 'long'
@ -36,8 +36,8 @@ class AccountsIndex < Chewy::Index
field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
end
field :following_count, type: 'long', value: ->(account) { account.following.local.count }
field :followers_count, type: 'long', value: ->(account) { account.followers.local.count }
field :following_count, type: 'long', value: ->(account) { account.following_count }
field :followers_count, type: 'long', value: ->(account) { account.followers_count }
field :last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }
end
end

View File

@ -3,7 +3,7 @@
class StatusesIndex < Chewy::Index
include FormattingHelper
settings index: { refresh_interval: '15m' }, analysis: {
settings index: { refresh_interval: '30s' }, analysis: {
filter: {
english_stop: {
type: 'stop',
@ -33,6 +33,8 @@ class StatusesIndex < Chewy::Index
},
}
# We do not use delete_if option here because it would call a method that we
# expect to be called with crutches without crutches, causing n+1 queries
index_scope ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preloadable_poll)
crutch :mentions do |collection|
@ -55,6 +57,11 @@ class StatusesIndex < Chewy::Index
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
crutch :votes do |collection|
data = ::PollVote.joins(:poll).where(poll: { status_id: collection.map(&:id) }).where(account: Account.local).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
root date_detection: false do
field :id, type: 'long'
field :account_id, type: 'long'

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class TagsIndex < Chewy::Index
settings index: { refresh_interval: '15m' }, analysis: {
settings index: { refresh_interval: '30s' }, analysis: {
analyzer: {
content: {
tokenizer: 'keyword',
@ -23,7 +23,11 @@ class TagsIndex < Chewy::Index
},
}
index_scope ::Tag.listable, delete_if: ->(tag) { tag.destroyed? || !tag.listable? }
index_scope ::Tag.listable
crutch :time_period do
7.days.ago.to_date..0.days.ago.to_date
end
root date_detection: false do
field :name, type: 'text', analyzer: 'content' do
@ -31,7 +35,7 @@ class TagsIndex < Chewy::Index
end
field :reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? }
field :usage, type: 'long', value: ->(tag) { tag.history.reduce(0) { |total, day| total + day.accounts } }
field :usage, type: 'long', value: ->(tag, crutches) { tag.history.aggregate(crutches.time_period).accounts }
field :last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at }
end
end

View File

@ -45,7 +45,6 @@ class AccountsController < ApplicationController
limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
@statuses = filtered_statuses.without_reblogs.limit(limit)
@statuses = cache_collection(@statuses, Status)
render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
end
format.json do

View File

@ -2,6 +2,7 @@
class ActivityPub::BaseController < Api::BaseController
skip_before_action :require_authenticated_user!
skip_before_action :require_not_suspended!
skip_around_action :set_locale
private

View File

@ -2,6 +2,8 @@
module Admin
class DashboardController < BaseController
include Redisable
def index
@system_checks = Admin::SystemCheck.perform
@time_period = (29.days.ago.to_date...Time.now.utc.to_date)
@ -15,10 +17,10 @@ module Admin
def redis_info
@redis_info ||= begin
if Redis.current.is_a?(Redis::Namespace)
Redis.current.redis.info
if redis.is_a?(Redis::Namespace)
redis.redis.info
else
Redis.current.info
redis.info
end
end
end

View File

@ -4,6 +4,17 @@ module Admin
class DomainBlocksController < BaseController
before_action :set_domain_block, only: [:show, :destroy, :edit, :update]
def batch
@form = Form::DomainBlockBatch.new(form_domain_block_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.email_domain_blocks.no_domain_block_selected')
rescue Mastodon::NotPermittedError
flash[:alert] = I18n.t('admin.domain_blocks.created_msg')
else
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
end
def new
authorize :domain_block, :create?
@domain_block = DomainBlock.new(domain: params[:_domain])
@ -76,5 +87,15 @@ module Admin
def resource_params
params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate)
end
def form_domain_block_batch_params
params.require(:form_domain_block_batch).permit(domain_blocks_attributes: [:enabled, :domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate])
end
def action_from_button
if params[:save]
'save'
end
end
end
end

View File

@ -0,0 +1,60 @@
# frozen_string_literal: true
require 'csv'
module Admin
class ExportDomainAllowsController < BaseController
include AdminExportControllerConcern
before_action :set_dummy_import!, only: [:new]
ROWS_PROCESSING_LIMIT = 20_000
def new
authorize :domain_allow, :create?
end
def export
authorize :instance, :index?
send_export_file
end
def import
authorize :domain_allow, :create?
begin
@import = Admin::Import.new(import_params)
parse_import_data!(export_headers)
@data.take(ROWS_PROCESSING_LIMIT).each do |row|
domain = row['#domain'].strip
next if DomainAllow.allowed?(domain)
domain_allow = DomainAllow.new(domain: domain)
log_action :create, domain_allow if domain_allow.save
end
flash[:notice] = I18n.t('admin.domain_allows.created_msg')
rescue ActionController::ParameterMissing
flash[:error] = I18n.t('admin.export_domain_allows.no_file')
end
redirect_to admin_instances_path
end
private
def export_filename
'domain_allows.csv'
end
def export_headers
%w(#domain)
end
def export_data
CSV.generate(headers: export_headers, write_headers: true) do |content|
DomainAllow.allowed_domains.each do |instance|
content << [instance.domain]
end
end
end
end
end

View File

@ -0,0 +1,71 @@
# frozen_string_literal: true
require 'csv'
module Admin
class ExportDomainBlocksController < BaseController
include AdminExportControllerConcern
before_action :set_dummy_import!, only: [:new]
ROWS_PROCESSING_LIMIT = 20_000
def new
authorize :domain_block, :create?
end
def export
authorize :instance, :index?
send_export_file
end
def import
authorize :domain_block, :create?
@import = Admin::Import.new(import_params)
parse_import_data!(export_headers)
@global_private_comment = I18n.t('admin.export_domain_blocks.import.private_comment_template', source: @import.data_file_name, date: I18n.l(Time.now.utc))
@form = Form::DomainBlockBatch.new
@domain_blocks = @data.take(ROWS_PROCESSING_LIMIT).filter_map do |row|
domain = row['#domain'].strip
next if DomainBlock.rule_for(domain).present?
domain_block = DomainBlock.new(domain: domain,
severity: row['#severity'].strip,
reject_media: row['#reject_media'].strip,
reject_reports: row['#reject_reports'].strip,
private_comment: @global_private_comment,
public_comment: row['#public_comment']&.strip,
obfuscate: row['#obfuscate'].strip)
domain_block if domain_block.valid?
end
@warning_domains = Instance.where(domain: @domain_blocks.map(&:domain)).where('EXISTS (SELECT 1 FROM follows JOIN accounts ON follows.account_id = accounts.id OR follows.target_account_id = accounts.id WHERE accounts.domain = instances.domain)').pluck(:domain)
rescue ActionController::ParameterMissing
flash.now[:alert] = I18n.t('admin.export_domain_blocks.no_file')
set_dummy_import!
render :new
end
private
def export_filename
'domain_blocks.csv'
end
def export_headers
%w(#domain #severity #reject_media #reject_reports #public_comment #obfuscate)
end
def export_data
CSV.generate(headers: export_headers, write_headers: true) do |content|
DomainBlock.with_user_facing_limitations.each do |instance|
content << [instance.domain, instance.severity, instance.reject_media, instance.reject_reports, instance.public_comment, instance.obfuscate]
end
end
end
end
end

View File

@ -11,6 +11,7 @@ class Api::BaseController < ApplicationController
skip_before_action :require_functional!, unless: :whitelist_mode?
before_action :require_authenticated_user!, if: :disallow_unauthenticated_api_access?
before_action :require_not_suspended!
before_action :set_cache_headers
protect_from_forgery with: :null_session
@ -97,6 +98,10 @@ class Api::BaseController < ApplicationController
render json: { error: 'This method requires an authenticated user' }, status: 401 unless current_user
end
def require_not_suspended!
render json: { error: 'Your login is currently disabled' }, status: 403 if current_user&.account&.suspended?
end
def require_user!
if !current_user
render json: { error: 'This method requires an authenticated user' }, status: 422

View File

@ -12,5 +12,7 @@ class Api::V1::Accounts::LookupController < Api::BaseController
def set_account
@account = ResolveAccountService.new.call(params[:acct], skip_webfinger: true) || raise(ActiveRecord::RecordNotFound)
rescue Addressable::URI::InvalidURIError
raise(ActiveRecord::RecordNotFound)
end
end

View File

@ -9,6 +9,8 @@ class Api::V1::AccountsController < Api::BaseController
before_action :require_user!, except: [:show, :create]
before_action :set_account, except: [:create]
before_action :check_account_approval, except: [:create]
before_action :check_account_confirmation, except: [:create]
before_action :check_enabled_registrations, only: [:create]
skip_before_action :require_authenticated_user!, only: :create
@ -74,6 +76,14 @@ class Api::V1::AccountsController < Api::BaseController
@account = Account.find(params[:id])
end
def check_account_approval
raise(ActiveRecord::RecordNotFound) if @account.local? && @account.user_pending?
end
def check_account_confirmation
raise(ActiveRecord::RecordNotFound) if @account.local? && !@account.user_confirmed?
end
def relationships(**options)
AccountRelationshipsPresenter.new([@account.id], current_user.account_id, **options)
end

View File

@ -21,7 +21,7 @@ class Api::V1::BookmarksController < Api::BaseController
end
def results
@_results ||= account_bookmarks.eager_load(:status).to_a_paginated_by_id(
@_results ||= account_bookmarks.joins(:status).eager_load(:status).to_a_paginated_by_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)

View File

@ -21,7 +21,7 @@ class Api::V1::FavouritesController < Api::BaseController
end
def results
@_results ||= account_favourites.eager_load(:status).to_a_paginated_by_id(
@_results ||= account_favourites.joins(:status).eager_load(:status).to_a_paginated_by_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)

View File

@ -1,8 +1,8 @@
# frozen_string_literal: true
class Api::V1::NotificationsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss]
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss]
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss, :destroy, :destroy_multiple]
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss, :destroy, :destroy_multiple]
before_action :require_user!
after_action :insert_pagination_headers, only: :index

View File

@ -89,7 +89,7 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
def after_confirmation_path_for(_resource_name, user)
if user.created_by_application && truthy_param?(:redirect_to_app)
user.created_by_application.redirect_uri
user.created_by_application.confirmation_redirect_uri
else
super
end

View File

@ -14,7 +14,7 @@ class AuthorizeInteractionsController < ApplicationController
if @resource.is_a?(Account)
render :show
elsif @resource.is_a?(Status)
redirect_to web_url("statuses/#{@resource.id}")
redirect_to web_url("@#{@resource.account.pretty_acct}/#{@resource.id}")
else
render :error
end
@ -26,15 +26,17 @@ class AuthorizeInteractionsController < ApplicationController
else
render :error
end
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
rescue ActiveRecord::RecordNotFound
render :error
end
private
def set_resource
@resource = located_resource || render(:error)
@resource = located_resource
authorize(@resource, :show?) if @resource.is_a?(Status)
rescue Mastodon::NotPermittedError
not_found
end
def located_resource

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
module AdminExportControllerConcern
extend ActiveSupport::Concern
private
def send_export_file
respond_to do |format|
format.csv { send_data export_data, filename: export_filename }
end
end
def export_data
raise 'Override in controller'
end
def export_filename
raise 'Override in controller'
end
def set_dummy_import!
@import = Admin::Import.new
end
def import_params
params.require(:admin_import).permit(:data)
end
def import_data
Paperclip.io_adapters.for(@import.data).read
end
def parse_import_data!(default_headers)
data = CSV.parse(import_data, headers: true)
data = CSV.parse(import_data, headers: default_headers) unless data.headers&.first&.strip&.include?(default_headers[0])
@data = data.reject(&:blank?)
end
end

View File

@ -22,7 +22,10 @@ class FollowingAccountsController < ApplicationController
end
format.json do
raise Mastodon::NotPermittedError if page_requested? && @account.hide_collections?
if page_requested? && @account.hide_collections?
forbidden
next
end
expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?)

View File

@ -3,6 +3,8 @@
class MediaProxyController < ApplicationController
include RoutingHelper
include Authorization
include Redisable
include Lockable
skip_before_action :store_current_location
skip_before_action :require_functional!
@ -15,14 +17,10 @@ class MediaProxyController < ApplicationController
rescue_from HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, with: :internal_server_error
def show
RedisLock.acquire(lock_options) do |lock|
if lock.acquired?
@media_attachment = MediaAttachment.remote.attached.find(params[:id])
authorize @media_attachment.status, :show?
redownload! if @media_attachment.needs_redownload? && !reject_media?
else
raise Mastodon::RaceConditionError
end
with_lock("media_download:#{params[:id]}") do
@media_attachment = MediaAttachment.remote.attached.find(params[:id])
authorize @media_attachment.status, :show?
redownload! if @media_attachment.needs_redownload? && !reject_media?
end
redirect_to full_asset_url(@media_attachment.file.url(version))
@ -44,10 +42,6 @@ class MediaProxyController < ApplicationController
end
end
def lock_options
{ redis: Redis.current, key: "media_download:#{params[:id]}", autorelease: 15.minutes.seconds }
end
def reject_media?
DomainBlock.reject_media?(@media_attachment.account.domain)
end

View File

@ -2,7 +2,8 @@
class Oauth::TokensController < Doorkeeper::TokensController
def revoke
unsubscribe_for_token if authorized? && token.accessible?
unsubscribe_for_token if token.present? && authorized? && token.accessible?
super
end

View File

@ -2,6 +2,8 @@
class Settings::ExportsController < Settings::BaseController
include Authorization
include Redisable
include Lockable
skip_before_action :require_functional!
@ -13,21 +15,13 @@ class Settings::ExportsController < Settings::BaseController
def create
backup = nil
RedisLock.acquire(lock_options) do |lock|
if lock.acquired?
authorize :backup, :create?
backup = current_user.backups.create!
else
raise Mastodon::RaceConditionError
end
with_lock("backup:#{current_user.id}") do
authorize :backup, :create?
backup = current_user.backups.create!
end
BackupWorker.perform_async(backup.id)
redirect_to settings_export_path
end
def lock_options
{ redis: Redis.current, key: "backup:#{current_user.id}" }
end
end

View File

@ -27,7 +27,6 @@ class TagsController < ApplicationController
format.rss do
expires_in 0, public: true
render xml: RSS::TagSerializer.render(@tag, @statuses)
end
format.json do

View File

@ -132,7 +132,7 @@ module ApplicationHelper
elsif status.private_visibility? || status.limited_visibility?
fa_icon('lock', title: I18n.t('statuses.visibilities.private'))
elsif status.direct_visibility?
fa_icon('envelope', title: I18n.t('statuses.visibilities.direct'))
fa_icon('at', title: I18n.t('statuses.visibilities.direct'))
end
end
@ -244,7 +244,7 @@ module ApplicationHelper
end.values
end
def prerender_custom_emojis(html, custom_emojis)
EmojiFormatter.new(html, custom_emojis, animate: prefers_autoplay?).to_s
def prerender_custom_emojis(html, custom_emojis, other_options = {})
EmojiFormatter.new(html, custom_emojis, other_options.merge(animate: prefers_autoplay?)).to_s
end
end

View File

@ -18,6 +18,32 @@ module FormattingHelper
html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), content_type: status.content_type)
end
def rss_status_content_format(status)
html = status_content_format(status)
before_html = begin
if status.spoiler_text?
"<p><strong>#{I18n.t('rss.content_warning', locale: available_locale_or_nil(status.language) || I18n.default_locale)}</strong> #{h(status.spoiler_text)}</p><hr />"
else
''
end
end.html_safe # rubocop:disable Rails/OutputSafety
after_html = begin
if status.preloadable_poll
"<p>#{status.preloadable_poll.options.map { |o| "<input type=#{status.preloadable_poll.multiple? ? 'checkbox' : 'radio'} disabled /> #{h(o)}" }.join('<br />')}</p>"
else
''
end
end.html_safe # rubocop:disable Rails/OutputSafety
prerender_custom_emojis(
safe_join([before_html, html, after_html]),
status.emojis,
style: 'width: 1.1em; height: 1.1em; object-fit: contain; vertical-align: middle; margin: -.2ex .15em .2ex'
).to_str
end
def account_bio_format(account)
html_aware_format(account.note, account.local?)
end

View File

@ -254,4 +254,8 @@ module LanguagesHelper
def valid_locale?(locale)
locale.present? && SUPPORTED_LOCALES.key?(locale.to_sym)
end
def available_locale_or_nil(locale_name)
locale_name.to_sym if locale_name.present? && I18n.available_locales.include?(locale_name.to_sym)
end
end

View File

@ -1,2 +0,0 @@
module Settings::KeywordMutesHelper
end

View File

@ -101,7 +101,7 @@ module StatusesHelper
when 'private'
fa_icon 'lock fw'
when 'direct'
fa_icon 'envelope fw'
fa_icon 'at fw'
end
end

View File

@ -101,4 +101,20 @@ ready(() => {
const registrationMode = document.getElementById('form_admin_settings_registrations_mode');
if (registrationMode) onChangeRegistrationMode(registrationMode);
const checkAllElement = document.querySelector('#batch_checkbox_all');
if (checkAllElement) {
checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
}
document.querySelector('a#add-instance-button')?.addEventListener('click', (e) => {
const domain = document.getElementById('by_domain')?.value;
if (domain) {
const url = new URL(event.target.href);
url.searchParams.set('_domain', domain);
e.target.href = url;
}
});
});

View File

@ -88,6 +88,8 @@ export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE = 'PINNED_ACCOUNTS_EDITOR
export const PINNED_ACCOUNTS_EDITOR_RESET = 'PINNED_ACCOUNTS_EDITOR_RESET';
export const ACCOUNT_REVEAL = 'ACCOUNT_REVEAL';
export function fetchAccount(id) {
return (dispatch, getState) => {
dispatch(fetchRelationships([id]));
@ -798,6 +800,11 @@ export function unpinAccountFail(error) {
};
};
export const revealAccount = id => ({
type: ACCOUNT_REVEAL,
id,
});
export function fetchPinnedAccounts() {
return (dispatch, getState) => {
dispatch(fetchPinnedAccountsRequest());

View File

@ -48,12 +48,13 @@ export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE';
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE';
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
@ -189,6 +190,7 @@ export function submitCompose(routerHistory) {
spoiler_text: spoilerText,
visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null),
language: getState().getIn(['compose', 'language']),
},
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
@ -675,6 +677,11 @@ export function changeComposeSensitivity() {
};
};
export const changeComposeLanguage = language => ({
type: COMPOSE_LANGUAGE_CHANGE,
language,
});
export function changeComposeSpoilerness() {
return {
type: COMPOSE_SPOILERNESS_CHANGE,

View File

@ -1,7 +1,6 @@
import escapeTextContentForBrowser from 'escape-html';
import emojify from 'flavours/glitch/util/emoji';
import { unescapeHTML } from 'flavours/glitch/util/html';
import { expandSpoilers } from 'flavours/glitch/util/initial_state';
const domParser = new DOMParser();

View File

@ -0,0 +1,12 @@
import { saveSettings } from './settings';
export const LANGUAGE_USE = 'LANGUAGE_USE';
export const useLanguage = language => dispatch => {
dispatch({
type: LANGUAGE_USE,
language,
});
dispatch(saveSettings());
};

View File

@ -1,4 +1,46 @@
import { expandSpoilers, disableSwiping } from 'flavours/glitch/util/initial_state';
import { openModal } from './modal';
export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE';
export const LOCAL_SETTING_DELETE = 'LOCAL_SETTING_DELETE';
export function checkDeprecatedLocalSettings() {
return (dispatch, getState) => {
const local_auto_unfold = getState().getIn(['local_settings', 'content_warnings', 'auto_unfold']);
const local_swipe_to_change_columns = getState().getIn(['local_settings', 'swipe_to_change_columns']);
let changed_settings = [];
if (local_auto_unfold !== null && local_auto_unfold !== undefined) {
if (local_auto_unfold === expandSpoilers) {
dispatch(deleteLocalSetting(['content_warnings', 'auto_unfold']));
} else {
changed_settings.push('user_setting_expand_spoilers');
}
}
if (local_swipe_to_change_columns !== null && local_swipe_to_change_columns !== undefined) {
if (local_swipe_to_change_columns === !disableSwiping) {
dispatch(deleteLocalSetting(['swipe_to_change_columns']));
} else {
changed_settings.push('user_setting_disable_swiping');
}
}
if (changed_settings.length > 0) {
dispatch(openModal('DEPRECATED_SETTINGS', {
settings: changed_settings,
onConfirm: () => dispatch(clearDeprecatedLocalSettings()),
}));
}
};
};
export function clearDeprecatedLocalSettings() {
return (dispatch) => {
dispatch(deleteLocalSetting(['content_warnings', 'auto_unfold']));
dispatch(deleteLocalSetting(['swipe_to_change_columns']));
};
};
export function changeLocalSetting(key, value) {
return dispatch => {
@ -12,6 +54,17 @@ export function changeLocalSetting(key, value) {
};
};
export function deleteLocalSetting(key) {
return dispatch => {
dispatch({
type: LOCAL_SETTING_DELETE,
key,
});
dispatch(saveLocalSettings());
};
};
// __TODO :__
// Right now `saveLocalSettings()` doesn't keep track of which user
// is currently signed in, but it might be better to give each user

View File

@ -70,7 +70,8 @@ export const loadPending = () => ({
export function updateNotifications(notification, intlMessages, intlLocale) {
return (dispatch, getState) => {
const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
const showInColumn = activeFilter === 'all' ? getState().getIn(['settings', 'notifications', 'shows', notification.type], true) : activeFilter === notification.type;
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
const filters = getFiltersRegex(getState(), { contextType: 'notifications' });

View File

@ -16,8 +16,10 @@ const messages = defineMessages({
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'You are not currently muting notifications from @{name}. Click to mute notifications' },
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'You are currently muting notifications from @{name}. Click to unmute notifications' },
mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
});
export default @injectIntl
@ -34,6 +36,7 @@ class Account extends ImmutablePureComponent {
small: PropTypes.bool,
actionIcon: PropTypes.string,
actionTitle: PropTypes.string,
defaultAction: PropTypes.string,
onActionClick: PropTypes.func,
};
@ -70,6 +73,7 @@ class Account extends ImmutablePureComponent {
onActionClick,
actionIcon,
actionTitle,
defaultAction,
} = this.props;
if (!account) {
@ -114,6 +118,10 @@ class Account extends ImmutablePureComponent {
{hidingNotificationsButton}
</Fragment>
);
} else if (defaultAction === 'mute') {
buttons = <IconButton icon='volume-off' title={intl.formatMessage(messages.mute, { name: account.get('username') })} onClick={this.handleMute} />;
} else if (defaultAction === 'block') {
buttons = <IconButton icon='lock' title={intl.formatMessage(messages.block, { name: account.get('username') })} onClick={this.handleBlock} />;
} else if (!account.get('moved') || following) {
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
}

View File

@ -1,13 +1,13 @@
import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from 'flavours/glitch/util/initial_state';
import classNames from 'classnames';
export default class Avatar extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
account: ImmutablePropTypes.map,
className: PropTypes.string,
size: PropTypes.number.isRequired,
style: PropTypes.object,
@ -45,11 +45,6 @@ export default class Avatar extends React.PureComponent {
} = this.props;
const { hovering } = this.state;
const src = account.get('avatar');
const staticSrc = account.get('avatar_static');
const computedClass = classNames('account__avatar', { 'account__avatar-inline': inline }, className);
const style = {
...this.props.style,
width: `${size}px`,
@ -57,19 +52,24 @@ export default class Avatar extends React.PureComponent {
backgroundSize: `${size}px ${size}px`,
};
if (hovering || animate) {
style.backgroundImage = `url(${src})`;
} else {
style.backgroundImage = `url(${staticSrc})`;
if (account) {
const src = account.get('avatar');
const staticSrc = account.get('avatar_static');
if (hovering || animate) {
style.backgroundImage = `url(${src})`;
} else {
style.backgroundImage = `url(${staticSrc})`;
}
}
return (
<div
className={computedClass}
className={classNames('account__avatar', { 'account__avatar-inline': inline }, className)}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
style={style}
data-avatar-of={`@${account.get('acct')}`}
data-avatar-of={account && `@${account.get('acct')}`}
/>
);
}

View File

@ -25,7 +25,7 @@ export function counterRenderer(counterType, isBold = true) {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.statuses_counter'
defaultMessage='{count, plural, one {{counter} Toot} other {{counter} Toots}}'
defaultMessage='{count, plural, one {{counter} Post} other {{counter} Posts}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),

View File

@ -7,7 +7,7 @@ import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames';
import { CircularProgress } from 'mastodon/components/loading_indicator';
import { CircularProgress } from 'flavours/glitch/components/loading_indicator';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
let id = 0;

View File

@ -55,6 +55,10 @@ export default class ModalRoot extends React.PureComponent {
window.addEventListener('keyup', this.handleKeyUp, false);
window.addEventListener('keydown', this.handleKeyDown, false);
this.history = this.context.router ? this.context.router.history : createBrowserHistory();
if (this.props.children) {
this._handleModalOpen();
}
}
componentWillReceiveProps (nextProps) {

View File

@ -100,6 +100,7 @@ class Status extends ImmutablePureComponent {
scrollKey: PropTypes.string,
deployPictureInPicture: PropTypes.func,
usingPiP: PropTypes.bool,
settings: ImmutablePropTypes.map.isRequired,
};
state = {
@ -581,10 +582,7 @@ class Status extends ImmutablePureComponent {
// backgrounds for collapsed statuses are enabled.
attachments = status.get('media_attachments');
if (status.get('poll')) {
media.push(<PollContainer pollId={status.get('poll')} />);
mediaIcons.push('tasks');
}
if (usingPiP) {
media.push(<PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />);
mediaIcons.push('video-camera');
@ -684,6 +682,11 @@ class Status extends ImmutablePureComponent {
mediaIcons.push('link');
}
if (status.get('poll')) {
media.push(<PollContainer pollId={status.get('poll')} />);
mediaIcons.push('tasks');
}
// Here we prepare extra data-* attributes for CSS selectors.
// Users can use those for theming, hiding avatars etc via UserStyle
const selectorAttribs = {
@ -753,6 +756,7 @@ class Status extends ImmutablePureComponent {
collapsed={isCollapsed}
setCollapsed={setCollapsed}
directMessage={!!otherAccounts}
settings={settings.get('status_icons')}
/>
</header>
<StatusContent

View File

@ -8,6 +8,7 @@ import { defineMessages, injectIntl } from 'react-intl';
import IconButton from './icon_button';
import VisibilityIcon from './status_visibility_icon';
import Icon from 'flavours/glitch/components/icon';
import { languages } from 'flavours/glitch/util/initial_state';
// Messages for use with internationalization stuff.
const messages = defineMessages({
@ -22,6 +23,23 @@ const messages = defineMessages({
localOnly: { id: 'status.local_only', defaultMessage: 'Only visible from your instance' },
});
const LanguageIcon = ({ language }) => {
if (!languages) return null;
const lang = languages.find((lang) => lang[0] === language);
if (!lang) return null;
return (
<span className='text-icon' title={`${lang[2]} (${lang[1]})`} aria-hidden='true'>
{lang[0].toUpperCase()}
</span>
);
};
LanguageIcon.propTypes = {
language: PropTypes.string.isRequired,
};
export default @injectIntl
class StatusIcons extends React.PureComponent {
@ -33,6 +51,7 @@ class StatusIcons extends React.PureComponent {
directMessage: PropTypes.bool,
setCollapsed: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
settings: ImmutablePropTypes.map.isRequired,
};
// Handles clicks on collapsed button
@ -82,12 +101,14 @@ class StatusIcons extends React.PureComponent {
collapsible,
collapsed,
directMessage,
settings,
intl,
} = this.props;
return (
<div className='status__info__icons'>
{status.get('in_reply_to_id', null) !== null ? (
{settings.get('language') && status.get('language') && <LanguageIcon language={status.get('language')} />}
{settings.get('reply') && status.get('in_reply_to_id', null) !== null ? (
<Icon
className='status__reply-icon'
fixedWidth
@ -96,16 +117,16 @@ class StatusIcons extends React.PureComponent {
title={intl.formatMessage(messages.inReplyTo)}
/>
) : null}
{status.get('local_only') &&
{settings.get('local_only') && status.get('local_only') &&
<Icon
fixedWidth
id='home'
aria-hidden='true'
title={intl.formatMessage(messages.localOnly)}
/>}
{ !!mediaIcons && mediaIcons.map(icon => this.renderIcon(icon)) }
{!directMessage && <VisibilityIcon visibility={status.get('visibility')} />}
{collapsible ? (
{settings.get('media') && !!mediaIcons && mediaIcons.map(icon => this.renderIcon(icon))}
{settings.get('visibility') && !directMessage && <VisibilityIcon visibility={status.get('visibility')} />}
{collapsible && (
<IconButton
className='status__collapse-button'
animate
@ -118,7 +139,7 @@ class StatusIcons extends React.PureComponent {
icon='angle-double-up'
onClick={this.handleCollapsedClick}
/>
) : null}
)}
</div>
);
}

View File

@ -38,7 +38,7 @@ export default class StatusPrepend extends React.PureComponent {
switch (type) {
case 'featured':
return (
<FormattedMessage id='status.pinned' defaultMessage='Pinned toot' />
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
);
case 'reblogged_by':
return (

View File

@ -9,7 +9,7 @@ const messages = defineMessages({
public: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
direct: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
});
export default @injectIntl

View File

@ -8,6 +8,7 @@ import UI from 'flavours/glitch/features/ui';
import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
import { hydrateStore } from 'flavours/glitch/actions/store';
import { connectUserStream } from 'flavours/glitch/actions/streaming';
import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_settings';
import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from 'locales';
import initialState from 'flavours/glitch/util/initial_state';
@ -20,6 +21,9 @@ export const store = configureStore();
const hydrateAction = hydrateStore(initialState);
store.dispatch(hydrateAction);
// check for deprecated local settings
store.dispatch(checkDeprecatedLocalSettings());
// load custom emojis
store.dispatch(fetchCustomEmojis());

View File

@ -37,7 +37,7 @@ const messages = defineMessages({
showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
enableNotifications: { id: 'account.enable_notifications', defaultMessage: 'Notify me when @{name} posts' },
disableNotifications: { id: 'account.disable_notifications', defaultMessage: 'Stop notifying me when @{name} posts' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
@ -82,6 +82,7 @@ class Header extends ImmutablePureComponent {
onEditAccountNote: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
domain: PropTypes.string.isRequired,
hidden: PropTypes.bool,
};
openEditProfile = () => {
@ -115,7 +116,7 @@ class Header extends ImmutablePureComponent {
}
render () {
const { account, intl, domain, identity_proofs } = this.props;
const { account, hidden, intl, domain } = this.props;
if (!account) {
return null;
@ -270,23 +271,29 @@ class Header extends ImmutablePureComponent {
{info}
</div>
<img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' />
{!(suspended || hidden) && <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' />}
</div>
<div className='account__header__bar'>
<div className='account__header__tabs'>
<a className='avatar' href={account.get('url')} rel='noopener noreferrer' target='_blank'>
<Avatar account={account} size={90} />
<Avatar account={suspended || hidden ? undefined : account} size={90} />
</a>
<div className='spacer' />
<div className='account__header__tabs__buttons'>
{actionBtn}
{bellBtn}
{!suspended && (
<div className='account__header__tabs__buttons'>
{!hidden && (
<React.Fragment>
{actionBtn}
{bellBtn}
</React.Fragment>
)}
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
</div>
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
</div>
)}
</div>
<div className='account__header__tabs__name'>
@ -298,23 +305,11 @@ class Header extends ImmutablePureComponent {
<AccountNoteContainer account={account} />
{!suspended && (
{!(suspended || hidden) && (
<div className='account__header__extra'>
<div className='account__header__bio'>
{ (fields.size > 0 || identity_proofs.size > 0) && (
{ fields.size > 0 && (
<div className='account__header__fields'>
{identity_proofs.map((proof, i) => (
<dl key={i}>
<dt dangerouslySetInnerHTML={{ __html: proof.get('provider') }} className='translate' />
<dd className='verified'>
<a href={proof.get('proof_url')} target='_blank' rel='noopener noreferrer'><span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}>
<Icon id='check' className='verified__mark' />
</span></a>
<a href={proof.get('profile_url')} target='_blank' rel='noopener noreferrer'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} className='translate' /></a>
</dd>
</dl>
))}
{fields.map((pair, i) => (
<dl key={i}>
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />

View File

@ -12,7 +12,6 @@ export default class Header extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
identity_proofs: ImmutablePropTypes.list,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
@ -26,6 +25,7 @@ export default class Header extends ImmutablePureComponent {
onAddToList: PropTypes.func.isRequired,
hideTabs: PropTypes.bool,
domain: PropTypes.string.isRequired,
hidden: PropTypes.bool,
};
static contextTypes = {
@ -93,7 +93,7 @@ export default class Header extends ImmutablePureComponent {
}
render () {
const { account, hideTabs, identity_proofs } = this.props;
const { account, hidden, hideTabs } = this.props;
if (account === null) {
return null;
@ -101,11 +101,10 @@ export default class Header extends ImmutablePureComponent {
return (
<div className='account-timeline__header'>
{account.get('moved') && <MovedNote from={account} to={account.get('moved')} />}
{(!hidden && account.get('moved')) && <MovedNote from={account} to={account.get('moved')} />}
<InnerHeader
account={account}
identity_proofs={identity_proofs}
onFollow={this.handleFollow}
onBlock={this.handleBlock}
onMention={this.handleMention}
@ -120,16 +119,17 @@ export default class Header extends ImmutablePureComponent {
onAddToList={this.handleAddToList}
onEditAccountNote={this.handleEditAccountNote}
domain={this.props.domain}
hidden={hidden}
/>
<ActionBar
account={account}
/>
{!hideTabs && (
{!(hideTabs || hidden) && (
<div className='account__section-headline'>
<NavLink exact to={`/@${account.get('acct')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink>
<NavLink exact to={`/@${account.get('acct')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink>
<NavLink exact to={`/@${account.get('acct')}`}><FormattedMessage id='account.posts' defaultMessage='Posts' /></NavLink>
<NavLink exact to={`/@${account.get('acct')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Posts with replies' /></NavLink>
<NavLink exact to={`/@${account.get('acct')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
</div>
)}

View File

@ -0,0 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { revealAccount } from 'flavours/glitch/actions/accounts';
import { FormattedMessage } from 'react-intl';
import Button from 'flavours/glitch/components/button';
const mapDispatchToProps = (dispatch, { accountId }) => ({
reveal () {
dispatch(revealAccount(accountId));
},
});
export default @connect(() => {}, mapDispatchToProps)
class LimitedAccountHint extends React.PureComponent {
static propTypes = {
accountId: PropTypes.string.isRequired,
reveal: PropTypes.func,
}
render () {
const { reveal } = this.props;
return (
<div className='limited-account-hint'>
<p><FormattedMessage id='limited_account_hint.title' defaultMessage='This profile has been hidden by the moderators of your server.' /></p>
<Button onClick={reveal}><FormattedMessage id='limited_account_hint.action' defaultMessage='Show profile anyway' /></Button>
</div>
);
}
}

View File

@ -1,6 +1,6 @@
import React from 'react';
import { connect } from 'react-redux';
import { makeGetAccount } from 'flavours/glitch/selectors';
import { makeGetAccount, getAccountHidden } from 'flavours/glitch/selectors';
import Header from '../components/header';
import {
followAccount,
@ -22,7 +22,6 @@ import { blockDomain, unblockDomain } from 'flavours/glitch/actions/domain_block
import { initEditAccountNote } from 'flavours/glitch/actions/account_notes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { unfollowModal } from 'flavours/glitch/util/initial_state';
import { List as ImmutableList } from 'immutable';
const messages = defineMessages({
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
@ -35,7 +34,7 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, accountId),
domain: state.getIn(['meta', 'domain']),
identity_proofs: state.getIn(['identity_proofs', accountId], ImmutableList()),
hidden: getAccountHidden(state, accountId),
});
return mapStateToProps;

View File

@ -13,9 +13,10 @@ import ColumnBackButton from 'flavours/glitch/components/column_back_button';
import { List as ImmutableList } from 'immutable';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
import MissingIndicator from 'flavours/glitch/components/missing_indicator';
import TimelineHint from 'flavours/glitch/components/timeline_hint';
import LimitedAccountHint from './components/limited_account_hint';
import { getAccountHidden } from 'flavours/glitch/selectors';
const emptyList = ImmutableList();
@ -40,11 +41,12 @@ const mapStateToProps = (state, { params: { acct, id }, withReplies = false }) =
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
hidden: getAccountHidden(state, accountId),
};
};
const RemoteHint = ({ url }) => (
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older toots' />} />
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older posts' />} />
);
RemoteHint.propTypes = {
@ -68,6 +70,7 @@ class AccountTimeline extends ImmutablePureComponent {
withReplies: PropTypes.bool,
isAccount: PropTypes.bool,
suspended: PropTypes.bool,
hidden: PropTypes.bool,
remote: PropTypes.bool,
remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool,
@ -77,7 +80,7 @@ class AccountTimeline extends ImmutablePureComponent {
const { accountId, withReplies, dispatch } = this.props;
dispatch(fetchAccount(accountId));
dispatch(fetchAccountIdentityProofs(accountId));
if (!withReplies) {
dispatch(expandAccountFeaturedTimeline(accountId));
}
@ -109,10 +112,11 @@ class AccountTimeline extends ImmutablePureComponent {
if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) {
dispatch(fetchAccount(nextProps.params.accountId));
dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
if (!nextProps.withReplies) {
dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
}
dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
}
}
@ -130,7 +134,7 @@ class AccountTimeline extends ImmutablePureComponent {
}
render () {
const { statusIds, featuredStatusIds, isLoading, hasMore, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props;
const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
if (!isAccount) {
return (
@ -151,12 +155,16 @@ class AccountTimeline extends ImmutablePureComponent {
let emptyMessage;
const forceEmptyState = suspended || hidden;
if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (remote && statusIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />;
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts found' />;
}
const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
@ -166,14 +174,14 @@ class AccountTimeline extends ImmutablePureComponent {
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
<StatusList
prepend={<HeaderContainer accountId={this.props.accountId} />}
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs={forceEmptyState} />}
alwaysPrepend
append={remoteMessage}
scrollKey='account_timeline'
statusIds={suspended ? emptyList : statusIds}
statusIds={forceEmptyState ? emptyList : statusIds}
featuredStatusIds={featuredStatusIds}
isLoading={isLoading}
hasMore={hasMore}
hasMore={!forceEmptyState && hasMore}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}

View File

@ -69,7 +69,7 @@ class Blocks extends ImmutablePureComponent {
bindToDocument={!multiColumn}
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} />,
<AccountContainer key={id} id={id} defaultAction='block' />,
)}
</ScrollableList>
</Column>

View File

@ -70,7 +70,7 @@ class Bookmarks extends ImmutablePureComponent {
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked toots yet. When you bookmark one, it will show up here." />;
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} name='bookmarks'>

View File

@ -0,0 +1,332 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import TextIconButton from './text_icon_button';
import Overlay from 'react-overlays/lib/Overlay';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames';
import { languages as preloadedLanguages } from 'flavours/glitch/util/initial_state';
import fuzzysort from 'fuzzysort';
const messages = defineMessages({
changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' },
search: { id: 'compose.language.search', defaultMessage: 'Search languages...' },
clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
});
// Copied from emoji-mart for consistency with emoji picker and since
// they don't export the icons in the package
const icons = {
loupe: (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
</svg>
),
delete: (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
</svg>
),
};
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
class LanguageDropdownMenu extends React.PureComponent {
static propTypes = {
style: PropTypes.object,
value: PropTypes.string.isRequired,
frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired,
placement: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
intl: PropTypes.object,
};
static defaultProps = {
languages: preloadedLanguages,
};
state = {
mounted: false,
searchValue: '',
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
this.setState({ mounted: true });
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
setListRef = c => {
this.listNode = c;
}
handleSearchChange = ({ target }) => {
this.setState({ searchValue: target.value });
}
search () {
const { languages, value, frequentlyUsedLanguages } = this.props;
const { searchValue } = this.state;
if (searchValue === '') {
return [...languages].sort((a, b) => {
// Push current selection to the top of the list
if (a[0] === value) {
return -1;
} else if (b[0] === value) {
return 1;
} else {
// Sort according to frequently used languages
const indexOfA = frequentlyUsedLanguages.indexOf(a[0]);
const indexOfB = frequentlyUsedLanguages.indexOf(b[0]);
return ((indexOfA > -1 ? indexOfA : Infinity) - (indexOfB > -1 ? indexOfB : Infinity));
}
});
}
return fuzzysort.go(searchValue, languages, {
keys: ['0', '1', '2'],
limit: 5,
threshold: -10000,
}).map(result => result.obj);
}
frequentlyUsed () {
const { languages, value } = this.props;
const current = languages.find(lang => lang[0] === value);
const results = [];
if (current) {
results.push(current);
}
return results;
}
handleClick = e => {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onClose();
this.props.onChange(value);
}
handleKeyDown = e => {
const { onClose } = this.props;
const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
let element = null;
switch(e.key) {
case 'Escape':
onClose();
break;
case 'Enter':
this.handleClick(e);
break;
case 'ArrowDown':
element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
break;
case 'ArrowUp':
element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
break;
case 'Tab':
if (e.shiftKey) {
element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
} else {
element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
}
break;
case 'Home':
element = this.listNode.firstChild;
break;
case 'End':
element = this.listNode.lastChild;
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
}
handleSearchKeyDown = e => {
const { onChange, onClose } = this.props;
const { searchValue } = this.state;
let element = null;
switch(e.key) {
case 'Tab':
case 'ArrowDown':
element = this.listNode.firstChild;
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
break;
case 'Enter':
element = this.listNode.firstChild;
if (element) {
onChange(element.getAttribute('data-index'));
onClose();
}
break;
case 'Escape':
if (searchValue !== '') {
e.preventDefault();
this.handleClear();
}
break;
}
}
handleClear = () => {
this.setState({ searchValue: '' });
}
renderItem = lang => {
const { value } = this.props;
return (
<div key={lang[0]} role='option' tabIndex='0' data-index={lang[0]} className={classNames('language-dropdown__dropdown__results__item', { active: lang[0] === value })} aria-selected={lang[0] === value} onClick={this.handleClick} onKeyDown={this.handleKeyDown}>
<span className='language-dropdown__dropdown__results__item__native-name'>{lang[2]}</span> <span className='language-dropdown__dropdown__results__item__common-name'>({lang[1]})</span>
</div>
);
}
render () {
const { style, placement, intl } = this.props;
const { mounted, searchValue } = this.state;
const isSearching = searchValue !== '';
const results = this.search();
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div className={`language-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
<div className='emoji-mart-search'>
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
<button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? icons.loupe : icons.delete}</button>
</div>
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
{results.map(this.renderItem)}
</div>
</div>
)}
</Motion>
);
}
}
export default @injectIntl
class LanguageDropdown extends React.PureComponent {
static propTypes = {
value: PropTypes.string,
frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string),
intl: PropTypes.object.isRequired,
onChange: PropTypes.func,
onClose: PropTypes.func,
};
state = {
open: false,
placement: 'bottom',
};
handleToggle = ({ target }) => {
const { top } = target.getBoundingClientRect();
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
this.setState({ open: !this.state.open });
}
handleClose = () => {
const { value, onClose } = this.props;
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: false });
onClose(value);
}
handleChange = value => {
const { onChange } = this.props;
onChange(value);
}
render () {
const { value, intl, frequentlyUsedLanguages } = this.props;
const { open, placement } = this.state;
return (
<div className={classNames('privacy-dropdown', { active: open })}>
<div className='privacy-dropdown__value'>
<TextIconButton
className='privacy-dropdown__value-icon'
label={value && value.toUpperCase()}
title={intl.formatMessage(messages.changeLanguage)}
active={open}
onClick={this.handleToggle}
/>
</div>
<Overlay show={open} placement={placement} target={this}>
<LanguageDropdownMenu
value={value}
frequentlyUsedLanguages={frequentlyUsedLanguages}
onClose={this.handleClose}
onChange={this.handleChange}
placement={placement}
intl={intl}
/>
</Overlay>
</div>
);
}
}

View File

@ -12,6 +12,7 @@ import IconButton from 'flavours/glitch/components/icon_button';
import TextIconButton from './text_icon_button';
import Dropdown from './dropdown';
import PrivacyDropdown from './privacy_dropdown';
import LanguageDropdown from '../containers/language_dropdown_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Utils.
@ -306,6 +307,7 @@ class ComposerOptions extends ImmutablePureComponent {
title={formatMessage(messages.spoiler)}
/>
)}
<LanguageDropdown />
<Dropdown
active={advancedOptions && advancedOptions.some(value => !!value)}
disabled={disabled || isEditing}

View File

@ -6,12 +6,12 @@ import Dropdown from './dropdown';
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all, shown in public timelines' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but not in public timelines' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Only people I mention' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
});

View File

@ -48,6 +48,9 @@ class SearchResults extends ImmutablePureComponent {
render () {
const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
let accounts, statuses, hashtags;
let count = 0;
if (searchTerm === '' && !suggestions.isEmpty()) {
return (
<div className='drawer--results'>
@ -72,18 +75,15 @@ class SearchResults extends ImmutablePureComponent {
} else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
statuses = (
<section className='search-results__section'>
<h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
<h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></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.' />
<FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching posts by their content is not enabled on this Mastodon server.' />
</div>
</section>
);
}
let accounts, statuses, hashtags;
let count = 0;
if (results.get('accounts') && results.get('accounts').size > 0) {
count += results.get('accounts').size;
accounts = (
@ -101,7 +101,7 @@ class SearchResults extends ImmutablePureComponent {
count += results.get('statuses').size;
statuses = (
<section className='search-results__section'>
<h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
<h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></h5>
{results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId}/>)}

View File

@ -17,11 +17,6 @@ export default class TextIconButton extends React.PureComponent {
ariaControls: PropTypes.string,
};
handleClick = (e) => {
e.preventDefault();
this.props.onClick();
}
render () {
const { label, title, active, ariaControls } = this.props;
@ -31,7 +26,7 @@ export default class TextIconButton extends React.PureComponent {
aria-label={title}
className={`text-icon-button ${active ? 'active' : ''}`}
aria-expanded={active}
onClick={this.handleClick}
onClick={this.props.onClick}
aria-controls={ariaControls}
style={iconStyle}
>

View File

@ -0,0 +1,34 @@
import { connect } from 'react-redux';
import LanguageDropdown from '../components/language_dropdown';
import { changeComposeLanguage } from 'flavours/glitch/actions/compose';
import { useLanguage } from 'flavours/glitch/actions/languages';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';
const getFrequentlyUsedLanguages = createSelector([
state => state.getIn(['settings', 'frequentlyUsedLanguages'], ImmutableMap()),
], languageCounters => (
languageCounters.keySeq()
.sort((a, b) => languageCounters.get(a) - languageCounters.get(b))
.reverse()
.toArray()
));
const mapStateToProps = state => ({
frequentlyUsedLanguages: getFrequentlyUsedLanguages(state),
value: state.getIn(['compose', 'language']),
});
const mapDispatchToProps = dispatch => ({
onChange (value) {
dispatch(changeComposeLanguage(value));
},
onClose (value) {
dispatch(useLanguage(value));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(LanguageDropdown);

View File

@ -43,13 +43,13 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning
}
if (hashtagWarning) {
return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag." />} />;
return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag." />} />;
}
if (directMessageWarning) {
const message = (
<span>
<FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> {!!termsLink && <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>}
<FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> {!!termsLink && <a href={termsLink} target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>}
</span>
);

View File

@ -16,7 +16,7 @@ import { cycleElefriendCompose } from 'flavours/glitch/actions/compose';
import HeaderContainer from './containers/header_container';
const messages = defineMessages({
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' },
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
});
const mapStateToProps = (state, ownProps) => ({

View File

@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import SettingToggle from 'flavours/glitch/features/notifications/components/setting_toggle';
import SettingText from '../../../components/setting_text';
const messages = defineMessages({
@ -23,6 +24,12 @@ class ColumnSettings extends React.PureComponent {
return (
<div>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['conversations']} onChange={onChange} label={<FormattedMessage id='direct.group_by_conversations' defaultMessage='Group by conversation' />} />
</div>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
<div className='column-settings__row'>

View File

@ -10,7 +10,6 @@ import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/col
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { connectDirectStream } from 'flavours/glitch/actions/streaming';
import { changeSetting } from 'flavours/glitch/actions/settings';
import ConversationsListContainer from './containers/conversations_list_container';
const messages = defineMessages({
@ -99,14 +98,6 @@ class DirectTimeline extends React.PureComponent {
this.props.dispatch(expandConversations({ maxId }));
}
handleTimelineClick = () => {
this.props.dispatch(changeSetting(['direct', 'conversations'], false));
}
handleConversationsClick = () => {
this.props.dispatch(changeSetting(['direct', 'conversations'], true));
}
render () {
const { intl, hasUnread, columnId, multiColumn, conversationsMode } = this.props;
const pinned = !!columnId;
@ -119,6 +110,7 @@ class DirectTimeline extends React.PureComponent {
scrollKey={`direct_timeline-${columnId}`}
timelineId='direct'
onLoadMore={this.handleLoadMore}
prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>}
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
/>
);
@ -129,6 +121,7 @@ class DirectTimeline extends React.PureComponent {
scrollKey={`direct_timeline-${columnId}`}
timelineId='direct'
onLoadMore={this.handleLoadMoreTimeline}
prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>}
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
/>
);
@ -149,27 +142,6 @@ class DirectTimeline extends React.PureComponent {
<ColumnSettingsContainer />
</ColumnHeader>
<div className='notification__filter-bar'>
<button
className={conversationsMode ? 'active' : ''}
onClick={this.handleConversationsClick}
>
<FormattedMessage
id='direct.conversations_mode'
defaultMessage='Conversations'
/>
</button>
<button
className={conversationsMode ? '' : 'active'}
onClick={this.handleTimelineClick}
>
<FormattedMessage
id='direct.timeline_mode'
defaultMessage='Timeline'
/>
</button>
</div>
{contents}
</Column>
);

View File

@ -191,7 +191,7 @@ class AccountCard extends ImmutablePureComponent {
<div className='account-card__counters__item'>
<ShortNumber value={account.get('statuses_count')} />
<small>
<FormattedMessage id='account.posts' defaultMessage='Toots' />
<FormattedMessage id='account.posts' defaultMessage='Posts' />
</small>
</div>

View File

@ -70,7 +70,7 @@ class Favourites extends ImmutablePureComponent {
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite toots yet. When you favourite one, it will show up here." />;
const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite posts yet. When you favourite one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} name='favourites' label={intl.formatMessage(messages.heading)}>

View File

@ -68,7 +68,7 @@ class Favourites extends ImmutablePureComponent {
);
}
const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has favourited this toot yet. When someone does, they will show up here.' />;
const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has favourited this post yet. When someone does, they will show up here.' />;
return (
<Column ref={this.setRef}>

View File

@ -19,6 +19,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import MissingIndicator from 'flavours/glitch/components/missing_indicator';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import TimelineHint from 'flavours/glitch/components/timeline_hint';
import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
import { getAccountHidden } from 'flavours/glitch/selectors';
const mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.getIn(['accounts_map', acct]);
@ -37,6 +39,8 @@ const mapStateToProps = (state, { params: { acct, id } }) => {
accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
hidden: getAccountHidden(state, accountId),
};
};
@ -62,6 +66,8 @@ class Followers extends ImmutablePureComponent {
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
isAccount: PropTypes.bool,
suspended: PropTypes.bool,
hidden: PropTypes.bool,
remote: PropTypes.bool,
remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool,
@ -107,7 +113,7 @@ class Followers extends ImmutablePureComponent {
}
render () {
const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
if (!isAccount) {
return (
@ -127,7 +133,13 @@ class Followers extends ImmutablePureComponent {
let emptyMessage;
if (remote && accountIds.isEmpty()) {
const forceEmptyState = suspended || hidden;
if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
@ -141,7 +153,7 @@ class Followers extends ImmutablePureComponent {
<ScrollableList
scrollKey='followers'
hasMore={hasMore}
hasMore={!forceEmptyState && hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}

View File

@ -19,6 +19,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import MissingIndicator from 'flavours/glitch/components/missing_indicator';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import TimelineHint from 'flavours/glitch/components/timeline_hint';
import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
import { getAccountHidden } from 'flavours/glitch/selectors';
const mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.getIn(['accounts_map', acct]);
@ -37,6 +39,8 @@ const mapStateToProps = (state, { params: { acct, id } }) => {
accountIds: state.getIn(['user_lists', 'following', accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
hidden: getAccountHidden(state, accountId),
};
};
@ -62,6 +66,8 @@ class Following extends ImmutablePureComponent {
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
isAccount: PropTypes.bool,
suspended: PropTypes.bool,
hidden: PropTypes.bool,
remote: PropTypes.bool,
remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool,
@ -107,7 +113,7 @@ class Following extends ImmutablePureComponent {
}
render () {
const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
if (!isAccount) {
return (
@ -127,7 +133,13 @@ class Following extends ImmutablePureComponent {
let emptyMessage;
if (remote && accountIds.isEmpty()) {
const forceEmptyState = suspended || hidden;
if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
@ -141,7 +153,7 @@ class Following extends ImmutablePureComponent {
<ScrollableList
scrollKey='following'
hasMore={hasMore}
hasMore={!forceEmptyState && hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}

View File

@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
import IconButton from 'flavours/glitch/components/icon_button';
import Icon from 'flavours/glitch/components/icon';
import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
import { autoPlayGif, reduceMotion } from 'flavours/glitch/util/initial_state';
import { autoPlayGif, reduceMotion, disableSwiping } from 'flavours/glitch/util/initial_state';
import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
import { mascot } from 'flavours/glitch/util/initial_state';
import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
@ -430,6 +430,7 @@ class Announcements extends ImmutablePureComponent {
removeReaction={this.props.removeReaction}
intl={intl}
selected={index === idx}
disabled={disableSwiping}
/>
))}
</ReactSwipeableViews>

View File

@ -18,7 +18,7 @@ const messages = defineMessages({
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' },
show_me_around: { id: 'getting_started.onboarding', defaultMessage: 'Show me around' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' },
keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
featured_users: { id: 'navigation_bar.featured_users', defaultMessage: 'Featured users' },

View File

@ -103,7 +103,7 @@ class KeyboardShortcuts extends ImmutablePureComponent {
</tr>
<tr>
<td><kbd>alt</kbd>+<kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new toot' /></td>
<td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new post' /></td>
</tr>
<tr>
<td><kbd>alt</kbd>+<kbd>x</kbd></td>

View File

@ -0,0 +1,83 @@
// Package imports
import React from 'react';
import PropTypes from 'prop-types';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
export default class LocalSettingsPageItem extends React.PureComponent {
static propTypes = {
children: PropTypes.node.isRequired,
id: PropTypes.string.isRequired,
options: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
hint: PropTypes.string,
})),
value: PropTypes.any,
placeholder: PropTypes.string,
};
render () {
const { id, options, children, placeholder, value } = this.props;
if (options && options.length > 0) {
const currentValue = value;
const optionElems = options && options.length > 0 && options.map((opt) => {
let optionId = `${id}--${opt.value}`;
return (
<label htmlFor={optionId}>
<input
type='radio'
name={id}
id={optionId}
value={opt.value}
checked={currentValue === opt.value}
disabled
/>
{opt.message}
{opt.hint && <span className='hint'>{opt.hint}</span>}
</label>
);
});
return (
<div className='glitch local-settings__page__item radio_buttons'>
<fieldset>
<legend>{children}</legend>
{optionElems}
</fieldset>
</div>
);
} else if (placeholder) {
return (
<div className='glitch local-settings__page__item string'>
<label htmlFor={id}>
<p>{children}</p>
<p>
<input
id={id}
type='text'
value={value}
placeholder={placeholder}
disabled
/>
</p>
</label>
</div>
);
} else return (
<div className='glitch local-settings__page__item boolean'>
<label htmlFor={id}>
<input
id={id}
type='checkbox'
checked={value}
disabled
/>
{children}
</label>
</div>
);
}
}

View File

@ -5,7 +5,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
// Our imports
import { expandSpoilers, disableSwiping } from 'flavours/glitch/util/initial_state';
import { preferenceLink } from 'flavours/glitch/util/backend_links';
import LocalSettingsPageItem from './item';
import DeprecatedLocalSettingsPageItem from './deprecated_item';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
@ -114,6 +117,50 @@ class LocalSettingsPage extends React.PureComponent {
<span className='hint'><FormattedMessage id='settings.notifications.favicon_badge.hint' defaultMessage="Add a badge for unread notifications to the favicon" /></span>
</LocalSettingsPageItem>
</section>
<section>
<h2><FormattedMessage id='settings.status_icons' defaultMessage='Toot icons' /></h2>
<LocalSettingsPageItem
settings={settings}
item={['status_icons', 'language']}
id='mastodon-settings--status-icons-language'
onChange={onChange}
>
<FormattedMessage id='settings.status_icons_language' defaultMessage='Language indicator' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['status_icons', 'reply']}
id='mastodon-settings--status-icons-reply'
onChange={onChange}
>
<FormattedMessage id='settings.status_icons_reply' defaultMessage='Reply indicator' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['status_icons', 'local_only']}
id='mastodon-settings--status-icons-local_only'
onChange={onChange}
>
<FormattedMessage id='settings.status_icons_local_only' defaultMessage='Local-only indicator' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['status_icons', 'media']}
id='mastodon-settings--status-icons-media'
onChange={onChange}
>
<FormattedMessage id='settings.status_icons_media' defaultMessage='Media and poll indicators' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['status_icons', 'visibility']}
id='mastodon-settings--status-icons-visibility'
onChange={onChange}
>
<FormattedMessage id='settings.status_icons_visibility' defaultMessage='Toot privacy indicator' />
</LocalSettingsPageItem>
</section>
<section>
<h2><FormattedMessage id='settings.layout_opts' defaultMessage='Layout options' /></h2>
<LocalSettingsPageItem
@ -146,14 +193,28 @@ class LocalSettingsPage extends React.PureComponent {
>
<FormattedMessage id='settings.navbar_under' defaultMessage='Navbar at the bottom (Mobile only)' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['swipe_to_change_columns']}
<DeprecatedLocalSettingsPageItem
id='mastodon-settings--swipe_to_change_columns'
onChange={onChange}
value={!disableSwiping}
>
<FormattedMessage id='settings.swipe_to_change_columns' defaultMessage='Allow swiping to change columns (Mobile only)' />
</LocalSettingsPageItem>
<span className='hint'>
<FormattedMessage
id='settings.deprecated_setting'
defaultMessage="This setting is now controlled from Mastodon's {settings_page_link}"
values={{
settings_page_link: (
<a href={preferenceLink('user_setting_disable_swiping')}>
<FormattedMessage
id='settings.shared_settings_link'
defaultMessage='user preferences'
/>
</a>
)
}}
/>
</span>
</DeprecatedLocalSettingsPageItem>
</section>
</div>
),
@ -242,21 +303,35 @@ class LocalSettingsPage extends React.PureComponent {
({ intl, onChange, settings }) => (
<div className='glitch local-settings__page content_warnings'>
<h1><FormattedMessage id='settings.content_warnings' defaultMessage='Content warnings' /></h1>
<LocalSettingsPageItem
settings={settings}
item={['content_warnings', 'auto_unfold']}
<DeprecatedLocalSettingsPageItem
id='mastodon-settings--content_warnings-auto_unfold'
onChange={onChange}
value={expandSpoilers}
>
<FormattedMessage id='settings.enable_content_warnings_auto_unfold' defaultMessage='Automatically unfold content-warnings' />
</LocalSettingsPageItem>
<span className='hint'>
<FormattedMessage
id='settings.deprecated_setting'
defaultMessage="This setting is now controlled from Mastodon's {settings_page_link}"
values={{
settings_page_link: (
<a href={preferenceLink('user_setting_expand_spoilers')}>
<FormattedMessage
id='settings.shared_settings_link'
defaultMessage='user preferences'
/>
</a>
)
}}
/>
</span>
</DeprecatedLocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['content_warnings', 'filter']}
id='mastodon-settings--content_warnings-auto_unfold'
onChange={onChange}
dependsOn={[['content_warnings', 'auto_unfold']]}
placeholder={intl.formatMessage(messages.regexp)}
disabled={!expandSpoilers}
>
<FormattedMessage id='settings.content_warnings_filter' defaultMessage='Content warnings to not automatically unfold:' />
</LocalSettingsPageItem>

View File

@ -21,6 +21,7 @@ export default class LocalSettingsPageItem extends React.PureComponent {
})),
settings: ImmutablePropTypes.map.isRequired,
placeholder: PropTypes.string,
disabled: PropTypes.bool,
};
handleChange = e => {
@ -33,8 +34,8 @@ export default class LocalSettingsPageItem extends React.PureComponent {
render () {
const { handleChange } = this;
const { settings, item, id, options, children, dependsOn, dependsOnNot, placeholder } = this.props;
let enabled = true;
const { settings, item, id, options, children, dependsOn, dependsOnNot, placeholder, disabled } = this.props;
let enabled = !disabled;
if (dependsOn) {
for (let i = 0; i < dependsOn.length; i++) {

View File

@ -69,7 +69,7 @@ class Mutes extends ImmutablePureComponent {
bindToDocument={!multiColumn}
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} />,
<AccountContainer key={id} id={id} defaultAction='mute' />,
)}
</ScrollableList>
</Column>

View File

@ -146,7 +146,7 @@ export default class ColumnSettings extends React.PureComponent {
</div>
<div role='group' aria-labelledby='notifications-status'>
<span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span>
<span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New posts:' /></span>
<div className='column-settings__pillbar'>
<PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} />

View File

@ -10,7 +10,7 @@ import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
heading: { id: 'column.pins', defaultMessage: 'Pinned toot' },
heading: { id: 'column.pins', defaultMessage: 'Pinned post' },
});
const mapStateToProps = state => ({

View File

@ -68,7 +68,7 @@ class Reblogs extends ImmutablePureComponent {
);
}
const emptyMessage = <FormattedMessage id='status.reblogs.empty' defaultMessage='No one has boosted this toot yet. When someone does, they will show up here.' />;
const emptyMessage = <FormattedMessage id='status.reblogs.empty' defaultMessage='No one has boosted this post yet. When someone does, they will show up here.' />;
return (
<Column ref={this.setRef}>

View File

@ -1,5 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Button from 'flavours/glitch/components/button';
import Option from './components/option';
@ -17,11 +19,17 @@ const messages = defineMessages({
account: { id: 'report.category.title_account', defaultMessage: 'profile' },
});
export default @injectIntl
const mapStateToProps = state => ({
rules: state.get('rules'),
});
export default @connect(mapStateToProps)
@injectIntl
class Category extends React.PureComponent {
static propTypes = {
onNextStep: PropTypes.func.isRequired,
rules: ImmutablePropTypes.list,
category: PropTypes.string,
onChangeCategory: PropTypes.func.isRequired,
startedFrom: PropTypes.oneOf(['status', 'account']),
@ -53,13 +61,15 @@ class Category extends React.PureComponent {
};
render () {
const { category, startedFrom, intl } = this.props;
const { category, startedFrom, rules, intl } = this.props;
const options = [
'dislike',
const options = rules.size > 0 ? [
'spam',
'violation',
'other',
] : [
'spam',
'other',
];
return (

View File

@ -8,7 +8,7 @@ import {
unfollowAccount,
muteAccount,
blockAccount,
} from 'mastodon/actions/accounts';
} from 'flavours/glitch/actions/accounts';
const mapStateToProps = () => ({});

View File

@ -134,10 +134,6 @@ class DetailedStatus extends ImmutablePureComponent {
outerStyle.height = `${this.state.height}px`;
}
if (status.get('poll')) {
media.push(<PollContainer pollId={status.get('poll')} />);
mediaIcons.push('tasks');
}
if (usingPiP) {
media.push(<PictureInPicturePlaceholder />);
mediaIcons.push('video-camera');
@ -202,6 +198,11 @@ class DetailedStatus extends ImmutablePureComponent {
mediaIcons.push('link');
}
if (status.get('poll')) {
media.push(<PollContainer pollId={status.get('poll')} />);
mediaIcons.push('tasks');
}
if (status.get('application')) {
applicationLink = <React.Fragment> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></React.Fragment>;
}

View File

@ -14,14 +14,11 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import PrivacyDropdown from 'flavours/glitch/features/compose/components/privacy_dropdown';
import classNames from 'classnames';
import { changeBoostPrivacy } from 'flavours/glitch/actions/boosts';
import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
const messages = defineMessages({
cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
const mapStateToProps = state => {
@ -85,15 +82,6 @@ class BoostModal extends ImmutablePureComponent {
const { status, missingMediaDescription, privacy, intl } = this.props;
const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog;
const visibilityIconInfo = {
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) },
};
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
return (
<div className='modal-root__modal boost-modal'>
<div className='boost-modal__container'>
@ -101,7 +89,7 @@ class BoostModal extends ImmutablePureComponent {
<div className='boost-modal__status-header'>
<div className='boost-modal__status-time'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<VisibilityIcon visibility={status.get('visibility')} />
<RelativeTimestamp timestamp={status.get('created_at')} /></a>
</div>

View File

@ -8,6 +8,8 @@ import ReactSwipeableViews from 'react-swipeable-views';
import TabsBar, { links, getIndex, getLink } from './tabs_bar';
import { Link } from 'react-router-dom';
import { disableSwiping } from 'flavours/glitch/util/initial_state';
import BundleContainer from '../containers/bundle_container';
import ColumnLoading from './column_loading';
import DrawerLoading from './drawer_loading';
@ -63,7 +65,6 @@ class ColumnsArea extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
columns: ImmutablePropTypes.list.isRequired,
swipeToChangeColumns: PropTypes.bool,
singleColumn: PropTypes.bool,
children: PropTypes.node,
navbarUnder: PropTypes.bool,
@ -210,7 +211,7 @@ class ColumnsArea extends ImmutablePureComponent {
}
render () {
const { columns, children, singleColumn, swipeToChangeColumns, intl, navbarUnder, openSettings } = this.props;
const { columns, children, singleColumn, intl, navbarUnder, openSettings } = this.props;
const { shouldAnimate, renderComposePanel } = this.state;
const columnIndex = getIndex(this.context.router.history.location.pathname);
@ -219,7 +220,7 @@ class ColumnsArea extends ImmutablePureComponent {
const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/publish' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
const content = columnIndex !== -1 ? (
<ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={!swipeToChangeColumns}>
<ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={disableSwiping}>
{links.map(this.renderView)}
</ReactSwipeableViews>
) : (
@ -234,7 +235,7 @@ class ColumnsArea extends ImmutablePureComponent {
</div>
</div>
<div className='columns-area__panels__main'>
<div className={`columns-area__panels__main ${floatingActionButton && 'with-fab'}`}>
{!navbarUnder && <TabsBar key='tabs' />}
{content}
{navbarUnder && <TabsBar key='tabs' />}

View File

@ -0,0 +1,86 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { preferenceLink } from 'flavours/glitch/util/backend_links';
import Button from 'flavours/glitch/components/button';
import Icon from 'flavours/glitch/components/icon';
import illustration from 'flavours/glitch/images/logo_warn_glitch.svg';
const messages = defineMessages({
discardChanges: { id: 'confirmations.deprecated_settings.confirm', defaultMessage: 'Use Mastodon preferences' },
user_setting_expand_spoilers: { id: 'settings.enable_content_warnings_auto_unfold', defaultMessage: 'Automatically unfold content-warnings' },
user_setting_disable_swiping: { id: 'settings.swipe_to_change_columns', defaultMessage: 'Allow swiping to change columns (Mobile only)' },
});
export default @injectIntl
class DeprecatedSettingsModal extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.list.isRequired,
onClose: PropTypes.func.isRequired,
onConfirm: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
componentDidMount() {
this.button.focus();
}
handleClick = () => {
this.props.onConfirm();
this.props.onClose();
}
setRef = (c) => {
this.button = c;
}
render () {
const { settings, intl } = this.props;
return (
<div className='modal-root__modal confirmation-modal'>
<div className='confirmation-modal__container'>
<img src={illustration} className='modal-warning' alt='' />
<FormattedMessage
id='confirmations.deprecated_settings.message'
defaultMessage='Some of the glitch-soc device-specific {app_settings} you are using have been replaced by Mastodon {preferences} and will be overriden:'
values={{
app_settings: (
<strong className='deprecated-settings-label'>
<Icon id='cogs' /> <FormattedMessage id='navigation_bar.app_settings' defaultMessage='App settings' />
</strong>
),
preferences: (
<strong className='deprecated-settings-label'>
<Icon id='cog' /> <FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' />
</strong>
),
}}
/>
<div className='deprecated-settings-info'>
<ul>
{ settings.map((setting_name) => (
<li>
<a href={preferenceLink(setting_name)}><FormattedMessage {...messages[setting_name]} /></a>
</li>
)) }
</ul>
</div>
</div>
<div>
<div className='confirmation-modal__action-bar'>
<div />
<Button text={intl.formatMessage(messages.discardChanges)} onClick={this.handleClick} ref={this.setRef} />
</div>
</div>
</div>
);
}
}

View File

@ -11,13 +11,10 @@ import AttachmentList from 'flavours/glitch/components/attachment_list';
import Icon from 'flavours/glitch/components/icon';
import ImmutablePureComponent from 'react-immutable-pure-component';
import classNames from 'classnames';
import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
const messages = defineMessages({
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
export default @injectIntl
@ -60,15 +57,6 @@ class FavouriteModal extends ImmutablePureComponent {
render () {
const { status, intl } = this.props;
const visibilityIconInfo = {
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) },
};
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
return (
<div className='modal-root__modal favourite-modal'>
<div className='favourite-modal__container'>
@ -76,7 +64,7 @@ class FavouriteModal extends ImmutablePureComponent {
<div className='favourite-modal__status-header'>
<div className='favourite-modal__status-time'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<VisibilityIcon visibility={status.get('visibility')} />
<RelativeTimestamp timestamp={status.get('created_at')} />
</a>
</div>

View File

@ -12,6 +12,7 @@ import Icon from 'flavours/glitch/components/icon';
import GIFV from 'flavours/glitch/components/gifv';
import Footer from 'flavours/glitch/features/picture_in_picture/components/footer';
import { getAverageFromBlurhash } from 'flavours/glitch/blurhash';
import { disableSwiping } from 'flavours/glitch/util/initial_state';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -227,6 +228,7 @@ class MediaModal extends ImmutablePureComponent {
onChangeIndex={this.handleSwipe}
onTransitionEnd={this.handleTransitionEnd}
index={index}
disabled={disableSwiping}
>
{content}
</ReactSwipeableViews>

View File

@ -14,6 +14,7 @@ import AudioModal from './audio_modal';
import DoodleModal from './doodle_modal';
import ConfirmationModal from './confirmation_modal';
import FocalPointModal from './focal_point_modal';
import DeprecatedSettingsModal from './deprecated_settings_modal';
import {
OnboardingModal,
MuteModal,
@ -40,6 +41,7 @@ const MODAL_COMPONENTS = {
'BLOCK': BlockModal,
'REPORT': ReportModal,
'SETTINGS': SettingsModal,
'DEPRECATED_SETTINGS': () => Promise.resolve({ default: DeprecatedSettingsModal }),
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor,

View File

@ -4,7 +4,6 @@ import { openModal } from 'flavours/glitch/actions/modal';
const mapStateToProps = state => ({
columns: state.getIn(['settings', 'columns']),
swipeToChangeColumns: state.getIn(['local_settings', 'swipe_to_change_columns']),
});
const mapDispatchToProps = dispatch => ({

View File

@ -11,6 +11,7 @@ import { uploadCompose, resetCompose, changeComposeSpoilerness } from 'flavours/
import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications';
import { fetchFilters } from 'flavours/glitch/actions/filters';
import { fetchRules } from 'flavours/glitch/actions/rules';
import { clearHeight } from 'flavours/glitch/actions/height_cache';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers';
@ -402,6 +403,7 @@ class UI extends React.Component {
this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications());
setTimeout(() => this.props.dispatch(fetchFilters()), 500);
setTimeout(() => this.props.dispatch(fetchRules()), 3000);
}
componentDidMount () {

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 216.41507 232.00976"
version="1.1"
id="svg6"
sodipodi:docname="logo_warn_glitch.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs10" />
<sodipodi:namedview
id="namedview8"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="1.7951831"
inkscape:cx="-30.916067"
inkscape:cy="90.241493"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
<g
id="g2025">
<path
d="M211.80683 139.0875c-3.1825 16.36625-28.4925 34.2775-57.5625 37.74875-15.16 1.80875-30.0825 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.3925 27.9425 21.115.7225 39.91625-5.20625 39.91625-5.20625l.86875 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23183 213.82 1.40558 165.31125.20808 116.09125c-.36375-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67058 3.45375 78.20308.2425 107.86433 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.97625 14.7525 32.97625 65.0825 0 0 .4125 37.13375-4.6 62.915"
fill="#3088d4"
id="path2" />
<path
d="m 124.52893,137.75645 c 0,9.01375 -7.30875,16.32125 -16.3225,16.32125 -9.01375,0 -16.32125,-7.3075 -16.32125,-16.32125 0,-9.01375 7.3075,-16.3225 16.32125,-16.3225 9.01375,0 16.3225,7.30875 16.3225,16.3225"
fill="#ffffff"
id="path4"
sodipodi:nodetypes="csssc" />
<path
id="path1121"
d="m 108.20703,25.453125 c -9.013749,0 -16.322264,7.308516 -16.322264,16.322266 0,5.31808 2.555126,37.386806 6.492187,67.763669 4.100497,4.20028 15.890147,3.77063 19.660157,-0.01 3.9367,-30.375272 6.49219,-62.4364 6.49219,-67.753909 0,-9.01375 -7.30852,-16.322266 -16.32227,-16.322266 z"
style="fill:#ffffff"
sodipodi:nodetypes="ssccsss" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -1,4 +1,5 @@
import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer';
import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from 'flavours/glitch/actions/importer';
import { ACCOUNT_REVEAL } from 'flavours/glitch/actions/accounts';
import { Map as ImmutableMap, fromJS } from 'immutable';
const initialState = ImmutableMap();
@ -10,6 +11,8 @@ const normalizeAccount = (state, account) => {
delete account.following_count;
delete account.statuses_count;
account.hidden = state.getIn([account.id, 'hidden']) === false ? false : account.limited;
return state.set(account.id, fromJS(account));
};
@ -27,6 +30,8 @@ export default function accounts(state = initialState, action) {
return normalizeAccount(state, action.account);
case ACCOUNTS_IMPORT:
return normalizeAccounts(state, action.accounts);
case ACCOUNT_REVEAL:
return state.setIn([action.id, 'hidden'], false);
default:
return state;
}

View File

@ -30,6 +30,7 @@ import {
COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE,
COMPOSE_LANGUAGE_CHANGE,
COMPOSE_CONTENT_TYPE_CHANGE,
COMPOSE_EMOJI_INSERT,
COMPOSE_UPLOAD_CHANGE_REQUEST,
@ -100,6 +101,7 @@ const initialState = ImmutableMap({
}),
default_privacy: 'public',
default_sensitive: false,
default_language: 'en',
resetFileKey: Math.floor((Math.random() * 0x10000)),
idempotencyKey: null,
tagHistory: ImmutableList(),
@ -175,7 +177,8 @@ function clearAll(state) {
map => map.mergeWith(overwrite, state.get('default_advanced_options'))
);
map.set('privacy', state.get('default_privacy'));
map.set('sensitive', false);
map.set('sensitive', state.get('default_sensitive'));
map.set('language', state.get('default_language'));
map.update('media_attachments', list => list.clear());
map.set('poll', null);
map.set('idempotencyKey', uuid());
@ -557,6 +560,7 @@ export default function compose(state = initialState, action) {
map.set('caretPosition', null);
map.set('idempotencyKey', uuid());
map.set('sensitive', action.status.get('sensitive'));
map.set('language', action.status.get('language'));
map.update(
'advanced_options',
map => map.merge(new ImmutableMap({ do_not_federate }))
@ -589,6 +593,7 @@ export default function compose(state = initialState, action) {
map.set('caretPosition', null);
map.set('idempotencyKey', uuid());
map.set('sensitive', action.status.get('sensitive'));
map.set('language', action.status.get('language'));
if (action.spoiler_text.length > 0) {
map.set('spoiler', true);
@ -618,6 +623,8 @@ export default function compose(state = initialState, action) {
return state.updateIn(['poll', 'options'], options => options.delete(action.index));
case COMPOSE_POLL_SETTINGS_CHANGE:
return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple));
case COMPOSE_LANGUAGE_CHANGE:
return state.set('language', action.language);
default:
return state;
}

View File

@ -1,25 +0,0 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import {
IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS,
IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
} from '../actions/identity_proofs';
const initialState = ImmutableMap();
export default function identityProofsReducer(state = initialState, action) {
switch(action.type) {
case IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST:
return state.set('isLoading', true);
case IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL:
return state.set('isLoading', false);
case IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS:
return state.update(identity_proofs => identity_proofs.withMutations(map => {
map.set('isLoading', false);
map.set('loaded', true);
map.set(action.accountId, fromJS(action.identity_proofs));
}));
default:
return state;
}
};

View File

@ -34,7 +34,6 @@ import conversations from './conversations';
import suggestions from './suggestions';
import pinnedAccountsEditor from './pinned_accounts_editor';
import polls from './polls';
import identity_proofs from './identity_proofs';
import trends from './trends';
import announcements from './announcements';
import markers from './markers';
@ -73,7 +72,6 @@ const reducers = {
notifications,
height_cache,
custom_emojis,
identity_proofs,
lists,
listEditor,
listAdder,

View File

@ -3,13 +3,12 @@ import { Map as ImmutableMap } from 'immutable';
// Our imports.
import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
import { LOCAL_SETTING_CHANGE } from 'flavours/glitch/actions/local_settings';
import { LOCAL_SETTING_CHANGE, LOCAL_SETTING_DELETE } from 'flavours/glitch/actions/local_settings';
const initialState = ImmutableMap({
layout : 'auto',
stretch : true,
navbar_under : false,
swipe_to_change_columns: true,
side_arm : 'none',
side_arm_reply_mode : 'keep',
show_reply_count : false,
@ -26,7 +25,6 @@ const initialState = ImmutableMap({
tag_misleading_links: true,
rewrite_mentions: 'no',
content_warnings : ImmutableMap({
auto_unfold : false,
filter : null,
}),
collapsed : ImmutableMap({
@ -56,6 +54,13 @@ const initialState = ImmutableMap({
favicon_badge : false,
tab_badge : true,
}),
status_icons : ImmutableMap({
language: true,
reply: true,
local_only: true,
media: true,
visibility: true,
}),
});
const hydrate = (state, localSettings) => state.mergeDeep(localSettings);
@ -66,6 +71,8 @@ export default function localSettings(state = initialState, action) {
return hydrate(state, action.state.get('local_settings'));
case LOCAL_SETTING_CHANGE:
return state.setIn(action.key, action.value);
case LOCAL_SETTING_DELETE:
return state.deleteIn(action.key);
default:
return state;
}

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