Jay Logan 2020-05-14 17:49:16 -05:00
commit 721e05a3af
969 changed files with 9875 additions and 4361 deletions

View File

@ -5,12 +5,13 @@ aliases:
docker:
- image: circleci/ruby:2.7-buster-node
environment: &ruby_environment
BUNDLE_JOBS: 3
BUNDLE_RETRY: 3
BUNDLE_APP_CONFIG: ./.bundle/
BUNDLE_PATH: ./vendor/bundle/
DB_HOST: localhost
DB_USER: root
RAILS_ENV: test
PARALLEL_TEST_PROCESSORS: 4
ALLOW_NOPAM: true
CONTINUOUS_INTEGRATION: true
DISABLE_SIMPLECOV: true
@ -32,9 +33,9 @@ aliases:
- &restore_ruby_dependencies
restore_cache:
keys:
- v2-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }}
- v2-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-
- v2-ruby-dependencies-
- v3-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }}
- v3-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-
- v3-ruby-dependencies-
- &install_steps
steps:
@ -42,11 +43,13 @@ aliases:
- *attach_workspace
- restore_cache:
keys:
- v1-node-dependencies-{{ checksum "yarn.lock" }}
- v1-node-dependencies-
- run: yarn install --frozen-lockfile
- v2-node-dependencies-{{ checksum "yarn.lock" }}
- v2-node-dependencies-
- run:
name: Install yarn dependencies
command: yarn install --frozen-lockfile
- save_cache:
key: v1-node-dependencies-{{ checksum "yarn.lock" }}
key: v2-node-dependencies-{{ checksum "yarn.lock" }}
paths:
- ./node_modules/
- *persist_to_workspace
@ -57,27 +60,28 @@ aliases:
command: |
sudo apt-get update
sudo apt-get install -y libicu-dev libidn11-dev libprotobuf-dev protobuf-compiler
## TODO: FIX THESE BUSTER DEPENDANCES
sudo wget http://ftp.au.debian.org/debian/pool/main/i/icu/libicu57_57.1-6+deb9u3_amd64.deb
sudo dpkg -i libicu57_57.1-6+deb9u3_amd64.deb
sudo wget http://ftp.au.debian.org/debian/pool/main/p/protobuf/libprotobuf10_3.0.0-9_amd64.deb
sudo dpkg -i libprotobuf10_3.0.0-9_amd64.deb
- &install_ruby_dependencies
steps:
- *attach_workspace
- *install_system_dependencies
- run: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version
- run:
name: Set Ruby version
command: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version
- *restore_ruby_dependencies
- run: bundle config set clean 'true'
- run: bundle config set deployment 'true'
- run: bundle config set with 'pam_authentication'
- run: bundle config set without 'development production'
- run: bundle config set frozen 'true'
- run: bundle install --jobs 16 --retry 3 && bundle clean
- run:
name: Set bundler settings
command: |
bundle config clean 'true'
bundle config deployment 'true'
bundle config with 'pam_authentication'
bundle config without 'development production'
bundle config frozen 'true'
- run:
name: Install bundler dependencies
command: bundle check || (bundle install && bundle clean)
- save_cache:
key: v2-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }}
key: v3-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }}
paths:
- ./.bundle/
- ./vendor/bundle/
@ -88,17 +92,26 @@ aliases:
- ./mastodon/vendor/bundle/
- &test_steps
parallelism: 4
steps:
- *attach_workspace
- *install_system_dependencies
- run: sudo apt-get install -y ffmpeg
- run:
name: Prepare Tests
command: ./bin/rails parallel:create parallel:load_schema parallel:prepare
name: Install FFMPEG
command: sudo apt-get install -y ffmpeg
- run:
name: Run Tests
command: ./bin/retry bundle exec parallel_test ./spec/ --group-by filesize --type rspec
name: Load database schema
command: ./bin/rails db:create db:schema:load db:seed
- run:
name: Run rspec in parallel
command: |
bundle exec rspec --profile 10 \
--format RspecJunitFormatter \
--out test_results/rspec.xml \
--format progress \
$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
- store_test_results:
path: test_results
jobs:
install:
<<: *defaults
@ -115,19 +128,14 @@ jobs:
environment: *ruby_environment
<<: *install_ruby_dependencies
install-ruby2.5:
<<: *defaults
docker:
- image: circleci/ruby:2.5-buster-node
environment: *ruby_environment
<<: *install_ruby_dependencies
build:
<<: *defaults
steps:
- *attach_workspace
- *install_system_dependencies
- run: ./bin/rails assets:precompile
- run:
name: Precompile assets
command: ./bin/rails assets:precompile
- persist_to_workspace:
root: ~/projects/
paths:
@ -149,10 +157,10 @@ jobs:
- *install_system_dependencies
- run:
name: Create database
command: ./bin/rails parallel:create
command: ./bin/rails db:create
- run:
name: Run migrations
command: ./bin/rails parallel:migrate
command: ./bin/rails db:migrate
test-ruby2.7:
<<: *defaults
@ -178,35 +186,33 @@ jobs:
- image: circleci/redis:5-alpine
<<: *test_steps
test-ruby2.5:
<<: *defaults
docker:
- image: circleci/ruby:2.5-buster-node
environment: *ruby_environment
- image: circleci/postgres:12.2
environment:
POSTGRES_USER: root
POSTGRES_HOST_AUTH_METHOD: trust
- image: circleci/redis:5-alpine
<<: *test_steps
test-webui:
<<: *defaults
docker:
- image: circleci/node:12-buster
steps:
- *attach_workspace
- run: ./bin/retry yarn test:jest
- run:
name: Run jest
command: yarn test:jest
check-i18n:
<<: *defaults
steps:
- *attach_workspace
- *install_system_dependencies
- run: bundle exec i18n-tasks check-normalized
- run: bundle exec i18n-tasks unused -l en
- run: bundle exec i18n-tasks check-consistent-interpolations
- run: bundle exec rake repo:check_locales_files
- run:
name: Check locale file normalization
command: bundle exec i18n-tasks check-normalized
- run:
name: Check for unused strings
command: bundle exec i18n-tasks unused -l en
- run:
name: Check for wrong string interpolations
command: bundle exec i18n-tasks check-consistent-interpolations
- run:
name: Check that all required locale files exist
command: bundle exec rake repo:check_locales_files
workflows:
version: 2
@ -220,10 +226,6 @@ workflows:
requires:
- install
- install-ruby2.7
- install-ruby2.5:
requires:
- install
- install-ruby2.7
- build:
requires:
- install-ruby2.7
@ -238,10 +240,6 @@ workflows:
requires:
- install-ruby2.6
- build
- test-ruby2.5:
requires:
- install-ruby2.5
- build
- test-webui:
requires:
- install

View File

@ -30,7 +30,7 @@ plugins:
channel: eslint-6
rubocop:
enabled: true
channel: rubocop-0-76
channel: rubocop-0-82
sass-lint:
enabled: true
exclude_patterns:

View File

@ -4,7 +4,25 @@ update_configs:
- package_manager: "ruby:bundler"
directory: "/"
update_schedule: "weekly"
# Supported update schedule: live daily weekly monthly
version_requirement_updates: "auto"
# Supported version requirements: auto widen_ranges increase_versions increase_versions_if_necessary
allowed_updates:
- match:
dependency_type: "all"
# Supported dependency types: all indirect direct production development
update_type: "all"
# Supported update types: all security
- package_manager: "javascript"
directory: "/"
update_schedule: "weekly"
# Supported update schedule: live daily weekly monthly
version_requirement_updates: "auto"
# Supported version requirements: auto widen_ranges increase_versions increase_versions_if_necessary
allowed_updates:
- match:
dependency_type: "all"
# Supported dependency types: all indirect direct production development
update_type: "all"
# Supported update types: all security

View File

@ -33,7 +33,7 @@ LOCAL_DOMAIN=example.com
# ALTERNATE_DOMAINS=example1.com,example2.com
# Application secrets
# Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose)
# Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web bundle exec rake secret` if you use docker compose)
SECRET_KEY_BASE=
OTP_SECRET=
@ -42,7 +42,7 @@ OTP_SECRET=
# You should only generate this once per instance. If you later decide to change it, all push subscription will
# be invalidated, requiring the users to access the website again to resubscribe.
#
# Generate with `RAILS_ENV=production bundle exec rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose)
# Generate with `RAILS_ENV=production bundle exec rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web bundle exec rake mastodon:webpush:generate_vapid_key` if you use docker compose)
#
# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html
VAPID_PRIVATE_KEY=

2
.gitignore vendored
View File

@ -58,7 +58,7 @@ yarn-error.log
yarn-debug.log
# Ignore vagrant log files
ubuntu-xenial-16.04-cloudimg-console.log
*-cloudimg-console.log
# Ignore Docker option files
docker-compose.override.yml

View File

@ -2,7 +2,7 @@ require:
- rubocop-rails
AllCops:
TargetRubyVersion: 2.3
TargetRubyVersion: 2.4
Exclude:
- 'spec/**/*'
- 'db/**/*'
@ -46,7 +46,7 @@ Metrics/ClassLength:
Metrics/CyclomaticComplexity:
Max: 25
Metrics/LineLength:
Layout/LineLength:
AllowURI: true
Enabled: false

View File

@ -3,6 +3,73 @@ Changelog
All notable changes to this project will be documented in this file.
## [v3.1.4] - 2020-05-14
### Added
- Add `vi` to available locales ([taicv](https://github.com/tootsuite/mastodon/pull/13542))
- Add ability to remove identity proofs from account ([Gargron](https://github.com/tootsuite/mastodon/pull/13682))
- Add ability to exclude local content from federated timeline ([noellabo](https://github.com/tootsuite/mastodon/pull/13504), [noellabo](https://github.com/tootsuite/mastodon/pull/13745))
- Add `remote` param to `GET /api/v1/timelines/public` REST API
- Add `public/remote` / `public:remote` variants to streaming API
- "Remote only" option in federated timeline column settings in web UI
- Add ability to exclude remote content from hashtag timelines in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/13502))
- No changes to REST API
- "Local only" option in hashtag column settings in web UI
- Add Capistrano tasks that reload the services after deploying ([berkes](https://github.com/tootsuite/mastodon/pull/12642))
- Add `invites_enabled` attribute to `GET /api/v1/instance` in REST API ([ThibG](https://github.com/tootsuite/mastodon/pull/13501))
- Add `tootctl emoji export` command ([lfuelling](https://github.com/tootsuite/mastodon/pull/13534))
- Add separate cache directory for non-local uploads ([Gargron](https://github.com/tootsuite/mastodon/pull/12821), [Hanage999](https://github.com/tootsuite/mastodon/pull/13593), [mayaeh](https://github.com/tootsuite/mastodon/pull/13551))
- Add `tootctl upgrade storage-schema` command to move old non-local uploads to the cache directory
- Add buttons to delete header and avatar from profile settings ([sternenseemann](https://github.com/tootsuite/mastodon/pull/13234))
- Add emoji graphics and shortcodes from Twemoji 12.1.5 ([DeeUnderscore](https://github.com/tootsuite/mastodon/pull/13021))
### Changed
- Change error message when trying to migrate to an account that does not have current account set as an alias to be more clear ([TheEvilSkeleton](https://github.com/tootsuite/mastodon/pull/13746))
- Change delivery failure tracking to work with hostnames instead of URLs ([Gargron](https://github.com/tootsuite/mastodon/pull/13437), [noellabo](https://github.com/tootsuite/mastodon/pull/13481), [noellabo](https://github.com/tootsuite/mastodon/pull/13482), [noellabo](https://github.com/tootsuite/mastodon/pull/13535))
- Change Content-Security-Policy to not need unsafe-inline style-src ([ThibG](https://github.com/tootsuite/mastodon/pull/13679), [ThibG](https://github.com/tootsuite/mastodon/pull/13692), [ThibG](https://github.com/tootsuite/mastodon/pull/13576), [ThibG](https://github.com/tootsuite/mastodon/pull/13575), [ThibG](https://github.com/tootsuite/mastodon/pull/13438))
- Change how RSS items are titled and formatted ([ThibG](https://github.com/tootsuite/mastodon/pull/13592), [ykzts](https://github.com/tootsuite/mastodon/pull/13591))
### Fixed
- Fix dropdown of muted and followed accounts offering option to hide boosts in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13748))
- Fix "You are already signed in" alert being shown at wrong times ([ThibG](https://github.com/tootsuite/mastodon/pull/13547))
- Fix retrying of failed-to-download media files not actually working ([noellabo](https://github.com/tootsuite/mastodon/pull/13741))
- Fix first poll option not being focused when adding a poll in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13740))
- Fix `sr` locale being selected over `sr-Latn` ([ThibG](https://github.com/tootsuite/mastodon/pull/13693))
- Fix error within error when limiting backtrace to 3 lines ([Gargron](https://github.com/tootsuite/mastodon/pull/13120))
- Fix `tootctl media remove-orphans` crashing on "Import" files ([ThibG](https://github.com/tootsuite/mastodon/pull/13685))
- Fix regression in `tootctl media remove-orphans` ([Gargron](https://github.com/tootsuite/mastodon/pull/13405))
- Fix old unique jobs digests not having been cleaned up ([Gargron](https://github.com/tootsuite/mastodon/pull/13683))
- Fix own following/followers not showing muted users ([ThibG](https://github.com/tootsuite/mastodon/pull/13614))
- Fix list of followed people ignoring sorting on Follows & Followers page ([taras2358](https://github.com/tootsuite/mastodon/pull/13676))
- Fix wrong pgHero Content-Security-Policy when `CDN_HOST` is set ([ThibG](https://github.com/tootsuite/mastodon/pull/13595))
- Fix needlessly deduplicating usernames on collisions with remote accounts when signing-up through SAML/CAS ([kaiyou](https://github.com/tootsuite/mastodon/pull/13581))
- Fix page incorrectly scrolling when bringing up dropdown menus in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13574))
- Fix messed up z-index when NoScript blocks media/previews in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13449))
- Fix "See what's happening" page showing public instead of local timeline for logged-in users ([ThibG](https://github.com/tootsuite/mastodon/pull/13499))
- Fix not being able to resolve public resources in development environment ([Gargron](https://github.com/tootsuite/mastodon/pull/13505))
- Fix uninformative error message when uploading unsupported image files ([ThibG](https://github.com/tootsuite/mastodon/pull/13540))
- Fix expanded video player issues in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13541), [eai04191](https://github.com/tootsuite/mastodon/pull/13533))
- Fix and refactor keyboard navigation in dropdown menus in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13528))
- Fix uploaded image orientation being messed up in some browsers in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13493))
- Fix actions log crash when displaying updates of deleted announcements in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13489))
- Fix search not working due to proxy settings when using hidden services ([Gargron](https://github.com/tootsuite/mastodon/pull/13488))
- Fix poll refresh button not being debounced in web UI ([rasjonell](https://github.com/tootsuite/mastodon/pull/13485), [ThibG](https://github.com/tootsuite/mastodon/pull/13490))
- Fix confusing error when failing to add an alias to an unknown account ([ThibG](https://github.com/tootsuite/mastodon/pull/13480))
- Fix "Email changed" notification sometimes having wrong e-mail ([ThibG](https://github.com/tootsuite/mastodon/pull/13475))
- Fix varioues issues on the account aliases page ([ThibG](https://github.com/tootsuite/mastodon/pull/13452))
- Fix API footer link in web UI ([bubblineyuri](https://github.com/tootsuite/mastodon/pull/13441))
- Fix pagination of following, followers, follow requests, blocks and mutes lists in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13445))
- Fix styling of polls in JS-less fallback on public pages ([ThibG](https://github.com/tootsuite/mastodon/pull/13436))
- Fix trying to delete already deleted file when post-processing ([Gargron](https://github.com/tootsuite/mastodon/pull/13406))
### Security
- Fix Doorkeeper vulnerability that exposed app secret to users who authorized the app and reset secret of the web UI that could have been exposed ([dependabot-preview[bot]](https://github.com/tootsuite/mastodon/pull/13613), [Gargron](https://github.com/tootsuite/mastodon/pull/13688))
- For apps that self-register on behalf of every individual user (such as most mobile apps), this is a non-issue
- The issue only affects developers of apps who are shared between multiple users, such as server-side apps like cross-posters
## [v3.1.3] - 2020-04-05
### Added

29
Gemfile
View File

@ -20,7 +20,7 @@ gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.4'
gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.61', require: false
gem 'aws-sdk-s3', '~> 1.64', require: false
gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0'
@ -49,7 +49,7 @@ gem 'omniauth-saml', '~> 1.10'
gem 'omniauth', '~> 1.9'
gem 'discard', '~> 1.2'
gem 'doorkeeper', '~> 5.3'
gem 'doorkeeper', '~> 5.4'
gem 'fast_blank', '~> 1.0'
gem 'fastimage'
gem 'goldfinger', '~> 2.1'
@ -57,12 +57,12 @@ gem 'hiredis', '~> 0.6'
gem 'redis-namespace', '~> 1.7'
gem 'health_check', git: 'https://github.com/ianheggie/health_check', ref: '0b799ead604f900ed50685e9b2d469cd2befba5b'
gem 'htmlentities', '~> 4.3'
gem 'http', '~> 4.3'
gem 'http', '~> 4.4'
gem 'http_accept_language', '~> 2.1'
gem 'http_parser.rb', '~> 0.6', git: 'https://github.com/tmm1/http_parser.rb', ref: '54b17ba8c7d8d20a16dfc65d1775241833219cf2', submodules: true
gem 'httplog', '~> 1.4.2'
gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.1'
gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.3.1', require: 'mime/types/columnar'
gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532'
@ -75,7 +75,7 @@ gem 'parallel', '~> 1.19'
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
gem 'pundit', '~> 2.1'
gem 'premailer-rails'
gem 'rack-attack', '~> 6.2'
gem 'rack-attack', '~> 6.3'
gem 'rack-cors', '~> 1.1', require: 'rack/cors'
gem 'rails-i18n', '~> 5.1'
gem 'rails-settings-cached', '~> 0.6'
@ -96,8 +96,8 @@ gem 'strong_migrations', '~> 0.6'
gem 'tty-command', '~> 0.9', require: false
gem 'tty-prompt', '~> 0.21', require: false
gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2019'
gem 'webpacker', '~> 4.2'
gem 'tzinfo-data', '~> 1.2020'
gem 'webpacker', '~> 5.1'
gem 'webpush'
gem 'json-ld'
@ -110,7 +110,7 @@ group :development, :test do
gem 'fabrication', '~> 2.21'
gem 'fuubar', '~> 2.5'
gem 'i18n-tasks', '~> 0.9', require: false
gem 'pry-byebug', '~> 3.8'
gem 'pry-byebug', '~> 3.9'
gem 'pry-rails', '~> 0.3'
gem 'rspec-rails', '~> 4.0'
end
@ -120,7 +120,7 @@ group :production, :test do
end
group :test do
gem 'capybara', '~> 3.31'
gem 'capybara', '~> 3.32'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.11'
gem 'microformats', '~> 4.2'
@ -129,23 +129,24 @@ group :test do
gem 'simplecov', '~> 0.18', require: false
gem 'webmock', '~> 3.8'
gem 'parallel_tests', '~> 2.32'
gem 'rspec_junit_formatter', '~> 0.4'
end
group :development do
gem 'active_record_query_trace', '~> 1.7'
gem 'annotate', '~> 3.0'
gem 'better_errors', '~> 2.6'
gem 'annotate', '~> 3.1'
gem 'better_errors', '~> 2.7'
gem 'binding_of_caller', '~> 0.7'
gem 'bullet', '~> 6.1'
gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.4'
gem 'memory_profiler'
gem 'rubocop', '~> 0.79', require: false
gem 'rubocop-rails', '~> 2.4', require: false
gem 'rubocop', '~> 0.82', require: false
gem 'rubocop-rails', '~> 2.5', require: false
gem 'brakeman', '~> 4.8', require: false
gem 'bundler-audit', '~> 0.6', require: false
gem 'capistrano', '~> 3.12'
gem 'capistrano', '~> 3.14'
gem 'capistrano-rails', '~> 1.4'
gem 'capistrano-rbenv', '~> 2.1'
gem 'capistrano-yarn', '~> 2.0'

View File

@ -82,7 +82,7 @@ GEM
public_suffix (>= 2.0.2, < 5.0)
airbrussh (1.4.0)
sshkit (>= 1.6.1, != 1.7.0)
annotate (3.0.3)
annotate (3.1.1)
activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 14.0)
arel (9.0.0)
@ -91,24 +91,24 @@ GEM
encryptor (~> 3.0.0)
av (0.9.0)
cocaine (~> 0.5.3)
aws-eventstream (1.0.3)
aws-partitions (1.286.0)
aws-sdk-core (3.92.0)
aws-eventstream (~> 1.0, >= 1.0.2)
aws-eventstream (1.1.0)
aws-partitions (1.312.0)
aws-sdk-core (3.95.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.30.0)
aws-sdk-kms (1.31.0)
aws-sdk-core (~> 3, >= 3.71.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.61.1)
aws-sdk-s3 (1.64.0)
aws-sdk-core (~> 3, >= 3.83.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.1.1)
aws-sigv4 (1.1.3)
aws-eventstream (~> 1.0, >= 1.0.2)
bcrypt (3.1.12)
better_errors (2.6.0)
bcrypt (3.1.13)
better_errors (2.7.0)
coderay (>= 1.0.0)
erubi (>= 1.0.0)
rack (>= 0.9.0)
@ -118,8 +118,8 @@ GEM
ffi (~> 1.10.0)
bootsnap (1.4.6)
msgpack (~> 1.0)
brakeman (4.8.0)
browser (4.0.0)
brakeman (4.8.1)
browser (4.1.0)
builder (3.2.4)
bullet (6.1.0)
activesupport (>= 3.0.0)
@ -127,15 +127,14 @@ GEM
bundler-audit (0.6.1)
bundler (>= 1.2.0, < 3)
thor (~> 0.18)
byebug (11.1.1)
capistrano (3.12.1)
byebug (11.1.3)
capistrano (3.14.0)
airbrussh (>= 1.0.0)
i18n
rake (>= 10.0.0)
sshkit (>= 1.9.0)
capistrano-bundler (1.3.0)
capistrano-bundler (1.6.0)
capistrano (~> 3.1)
sshkit (~> 1.2)
capistrano-rails (1.4.0)
capistrano (~> 3.1)
capistrano-bundler (~> 1.1)
@ -144,7 +143,7 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (3.31.0)
capybara (3.32.1)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
@ -195,32 +194,32 @@ GEM
docile (1.3.2)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.3.1)
doorkeeper (5.4.0)
railties (>= 5)
dotenv (2.7.5)
dotenv-rails (2.7.5)
dotenv (= 2.7.5)
railties (>= 3.2, < 6.1)
e2mmap (0.1.0)
elasticsearch (7.5.0)
elasticsearch-api (= 7.5.0)
elasticsearch-transport (= 7.5.0)
elasticsearch-api (7.5.0)
elasticsearch (7.6.0)
elasticsearch-api (= 7.6.0)
elasticsearch-transport (= 7.6.0)
elasticsearch-api (7.6.0)
multi_json
elasticsearch-dsl (0.1.8)
elasticsearch-transport (7.5.0)
faraday (>= 0.14, < 1)
elasticsearch-dsl (0.1.9)
elasticsearch-transport (7.6.0)
faraday (~> 1)
multi_json
encryptor (3.0.0)
equatable (0.6.1)
erubi (1.9.0)
et-orbi (1.2.3)
et-orbi (1.2.4)
tzinfo
excon (0.71.0)
fabrication (2.21.0)
excon (0.73.0)
fabrication (2.21.1)
faker (2.11.0)
i18n (>= 1.6, < 2)
faraday (0.17.3)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
fast_blank (1.0.0)
fastimage (2.1.7)
@ -241,7 +240,7 @@ GEM
fog-json (>= 1.0)
ipaddress (>= 0.8)
formatador (0.2.5)
fugit (1.3.3)
fugit (1.3.5)
et-orbi (~> 1.1, >= 1.1.8)
raabro (~> 1.1)
fuubar (2.5.0)
@ -271,14 +270,14 @@ GEM
hiredis (0.6.3)
hkdf (0.3.0)
htmlentities (4.3.4)
http (4.3.0)
http (4.4.1)
addressable (~> 2.3)
http-cookie (~> 1.0)
http-form_data (~> 2.2)
http-parser (~> 1.2.0)
http-cookie (1.0.3)
domain_name (~> 0.5)
http-form_data (2.2.0)
http-form_data (2.3.0)
http-parser (1.2.1)
ffi-compiler (>= 1.0, < 2.0)
http_accept_language (2.1.1)
@ -304,7 +303,7 @@ GEM
jmespath (1.4.0)
json (2.3.0)
json-canonicalization (0.2.0)
json-ld (3.1.2)
json-ld (3.1.4)
htmlentities (~> 4.3)
json-canonicalization (~> 0.2)
link_header (~> 0.0, >= 0.0.8)
@ -315,21 +314,21 @@ GEM
json-ld (~> 3.1)
rdf (~> 3.1)
jsonapi-renderer (0.2.2)
jwt (2.1.0)
kaminari (1.1.1)
jwt (2.2.1)
kaminari (1.2.0)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.1.1)
kaminari-activerecord (= 1.1.1)
kaminari-core (= 1.1.1)
kaminari-actionview (1.1.1)
kaminari-actionview (= 1.2.0)
kaminari-activerecord (= 1.2.0)
kaminari-core (= 1.2.0)
kaminari-actionview (1.2.0)
actionview
kaminari-core (= 1.1.1)
kaminari-activerecord (1.1.1)
kaminari-core (= 1.2.0)
kaminari-activerecord (1.2.0)
activerecord
kaminari-core (= 1.1.1)
kaminari-core (1.1.1)
launchy (2.4.3)
addressable (~> 2.3)
kaminari-core (= 1.2.0)
kaminari-core (1.2.0)
launchy (2.5.0)
addressable (~> 2.7)
letter_opener (1.7.0)
launchy (~> 2.2)
letter_opener_web (1.4.0)
@ -342,7 +341,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.4.0)
loofah (2.5.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -354,14 +353,14 @@ GEM
mario-redis-lock (1.2.1)
redis (>= 3.0.5)
memory_profiler (0.9.14)
method_source (0.9.2)
method_source (1.0.0)
microformats (4.2.0)
json (~> 2.2)
nokogiri (~> 1.10)
mime-types (3.3.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2019.1009)
mimemagic (0.3.4)
mime-types-data (3.2020.0425)
mimemagic (0.3.5)
mini_mime (1.0.2)
mini_portile2 (2.4.0)
minitest (5.14.0)
@ -370,20 +369,20 @@ GEM
multipart-post (2.1.1)
necromancer (0.5.1)
net-ldap (0.16.2)
net-scp (2.0.0)
net-ssh (>= 2.6.5, < 6.0.0)
net-ssh (5.2.0)
net-scp (3.0.0)
net-ssh (>= 2.6.5, < 7.0.0)
net-ssh (6.0.2)
nio4r (2.5.2)
nokogiri (1.10.9)
mini_portile2 (~> 2.4.0)
nokogumbo (2.0.1)
nokogumbo (2.0.2)
nokogiri (~> 1.8, >= 1.8.4)
nsa (0.2.7)
activesupport (>= 4.2, < 6)
concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.10.5)
oj (3.10.6)
omniauth (1.9.1)
hashie (>= 3.4.6)
rack (>= 1.6.2, < 3)
@ -408,40 +407,40 @@ GEM
parallel (1.19.1)
parallel_tests (2.32.0)
parallel
parser (2.7.0.5)
parser (2.7.1.2)
ast (~> 2.4.0)
parslet (1.8.2)
pastel (0.7.3)
parslet (2.0.0)
pastel (0.7.4)
equatable (~> 0.6)
tty-color (~> 0.5)
pg (1.2.3)
pghero (2.4.1)
pghero (2.4.2)
activerecord (>= 5)
pkg-config (1.4.1)
premailer (1.11.1)
addressable
css_parser (>= 1.6.0)
htmlentities (>= 4.0.0)
premailer-rails (1.10.3)
premailer-rails (1.11.1)
actionmailer (>= 3)
premailer (~> 1.7, >= 1.7.9)
private_address_check (0.5.0)
pry (0.12.2)
coderay (~> 1.1.0)
method_source (~> 0.9.0)
pry-byebug (3.8.0)
pry (0.13.1)
coderay (~> 1.1)
method_source (~> 1.0)
pry-byebug (3.9.0)
byebug (~> 11.0)
pry (~> 0.10)
pry (~> 0.13.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.3)
public_suffix (4.0.5)
puma (4.3.3)
nio4r (~> 2.0)
pundit (2.1.0)
activesupport (>= 3.0.0)
raabro (1.1.6)
raabro (1.3.1)
rack (2.2.2)
rack-attack (6.2.2)
rack-attack (6.3.0)
rack (>= 1.0, < 3)
rack-cors (1.1.1)
rack (>= 2.0.0)
@ -492,40 +491,41 @@ GEM
rdf-normalize (0.4.0)
rdf (~> 3.1)
redcarpet (3.5.0)
redis (4.1.3)
redis-actionpack (5.0.2)
actionpack (>= 4.0, < 6)
redis-rack (>= 1, < 3)
redis (4.1.4)
redis-actionpack (5.2.0)
actionpack (>= 5, < 7)
redis-rack (>= 2.1.0, < 3)
redis-store (>= 1.1.0, < 2)
redis-activesupport (5.0.4)
activesupport (>= 3, < 6)
redis-activesupport (5.2.0)
activesupport (>= 3, < 7)
redis-store (>= 1.3, < 2)
redis-namespace (1.7.0)
redis (>= 3.0.4)
redis-rack (2.0.4)
rack (>= 1.5, < 3)
redis-rack (2.1.2)
rack (>= 2.0.8, < 3)
redis-store (>= 1.2, < 2)
redis-rails (5.0.2)
redis-actionpack (>= 5.0, < 6)
redis-activesupport (>= 5.0, < 6)
redis-store (>= 1.2, < 2)
redis-store (1.5.0)
redis (>= 2.2, < 5)
regexp_parser (1.6.0)
redis-store (1.8.2)
redis (>= 4, < 5)
regexp_parser (1.7.0)
request_store (1.5.0)
rack (>= 1.4)
responders (3.0.0)
actionpack (>= 5.0)
railties (>= 5.0)
rexml (3.2.4)
rotp (2.1.2)
rpam2 (4.0.2)
rqrcode (1.1.2)
chunky_png (~> 1.0)
rqrcode_core (~> 0.1)
rqrcode_core (0.1.1)
rspec-core (3.9.1)
rspec-support (~> 3.9.1)
rspec-expectations (3.9.1)
rqrcode_core (0.1.2)
rspec-core (3.9.2)
rspec-support (~> 3.9.3)
rspec-expectations (3.9.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.9.0)
rspec-mocks (3.9.1)
@ -542,19 +542,23 @@ GEM
rspec-sidekiq (3.0.3)
rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0)
rspec-support (3.9.2)
rubocop (0.79.0)
rspec-support (3.9.3)
rspec_junit_formatter (0.4.1)
rspec-core (>= 2, < 4, != 2.12.0)
rubocop (0.82.0)
jaro_winkler (~> 1.5.1)
parallel (~> 1.10)
parser (>= 2.7.0.1)
rainbow (>= 2.2.2, < 4.0)
rexml
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 1.7)
rubocop-rails (2.4.2)
unicode-display_width (>= 1.4.0, < 2.0)
rubocop-rails (2.5.2)
activesupport
rack (>= 1.1)
rubocop (>= 0.72.0)
ruby-progressbar (1.10.1)
ruby-saml (1.9.0)
ruby-saml (1.11.0)
nokogiri (>= 1.5.10)
rufus-scheduler (3.6.0)
fugit (~> 1.1, >= 1.1.6)
@ -563,9 +567,10 @@ GEM
crass (~> 1.0.2)
nokogiri (>= 1.8.0)
nokogumbo (~> 2.0)
sidekiq (6.0.4)
semantic_range (2.3.0)
sidekiq (6.0.7)
connection_pool (>= 2.2.2)
rack (>= 2.0.0)
rack (~> 2.0)
rack-protection (>= 2.0.0)
redis (>= 4.1.0)
sidekiq-bulk (0.2.0)
@ -605,7 +610,7 @@ GEM
stoplight (2.2.0)
streamio-ffmpeg (3.0.2)
multi_json (~> 1.8)
strong_migrations (0.6.2)
strong_migrations (0.6.6)
activerecord (>= 5)
temple (0.8.2)
terminal-table (1.8.0)
@ -633,12 +638,12 @@ GEM
unf (~> 0.1.0)
tzinfo (1.2.7)
thread_safe (~> 0.1)
tzinfo-data (1.2019.3)
tzinfo-data (1.2020.1)
tzinfo (>= 1.0.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.6)
unicode-display_width (1.6.1)
unf_ext (0.0.7.7)
unicode-display_width (1.7.0)
uniform_notifier (1.13.0)
warden (1.2.8)
rack (>= 2.0.6)
@ -646,10 +651,11 @@ GEM
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webpacker (4.2.2)
activesupport (>= 4.2)
webpacker (5.1.1)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 4.2)
railties (>= 5.2)
semantic_range (>= 2.3.0)
webpush (0.3.8)
hkdf (~> 0.2)
jwt (~> 2.0)
@ -667,9 +673,9 @@ DEPENDENCIES
active_model_serializers (~> 0.10)
active_record_query_trace (~> 1.7)
addressable (~> 2.7)
annotate (~> 3.0)
aws-sdk-s3 (~> 1.61)
better_errors (~> 2.6)
annotate (~> 3.1)
aws-sdk-s3 (~> 1.64)
better_errors (~> 2.7)
binding_of_caller (~> 0.7)
blurhash (~> 0.1)
bootsnap (~> 1.4)
@ -677,11 +683,11 @@ DEPENDENCIES
browser
bullet (~> 6.1)
bundler-audit (~> 0.6)
capistrano (~> 3.12)
capistrano (~> 3.14)
capistrano-rails (~> 1.4)
capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0)
capybara (~> 3.31)
capybara (~> 3.32)
charlock_holmes (~> 0.7.7)
chewy (~> 5.1)
cld3 (~> 3.3.0)
@ -692,7 +698,7 @@ DEPENDENCIES
devise-two-factor (~> 3.1)
devise_pam_authenticatable2 (~> 9.2)
discard (~> 1.2)
doorkeeper (~> 5.3)
doorkeeper (~> 5.4)
dotenv-rails (~> 2.7)
e2mmap (~> 0.1.0)
fabrication (~> 2.21)
@ -707,7 +713,7 @@ DEPENDENCIES
health_check!
hiredis (~> 0.6)
htmlentities (~> 4.3)
http (~> 4.3)
http (~> 4.4)
http_accept_language (~> 2.1)
http_parser.rb (~> 0.6)!
httplog (~> 1.4.2)
@ -716,7 +722,7 @@ DEPENDENCIES
iso-639
json-ld
json-ld-preloaded (~> 3.1)
kaminari (~> 1.1)
kaminari (~> 1.2)
letter_opener (~> 1.7)
letter_opener_web (~> 1.4)
link_header (~> 0.0)
@ -746,12 +752,12 @@ DEPENDENCIES
posix-spawn!
premailer-rails
private_address_check (~> 0.5)
pry-byebug (~> 3.8)
pry-byebug (~> 3.9)
pry-rails (~> 0.3)
puma (~> 4.3)
pundit (~> 2.1)
rack (~> 2.2.2)
rack-attack (~> 6.2)
rack-attack (~> 6.3)
rack-cors (~> 1.1)
rails (~> 5.2.4.2)
rails-controller-testing (~> 1.0)
@ -765,8 +771,9 @@ DEPENDENCIES
rqrcode (~> 1.1)
rspec-rails (~> 4.0)
rspec-sidekiq (~> 3.0)
rubocop (~> 0.79)
rubocop-rails (~> 2.4)
rspec_junit_formatter (~> 0.4)
rubocop (~> 0.82)
rubocop-rails (~> 2.5)
ruby-progressbar (~> 1.10)
sanitize (~> 5.1)
sidekiq (~> 6.0)
@ -787,7 +794,7 @@ DEPENDENCIES
tty-command (~> 0.9)
tty-prompt (~> 0.21)
twitter-text (~> 1.14)
tzinfo-data (~> 1.2019)
tzinfo-data (~> 1.2020)
webmock (~> 3.8)
webpacker (~> 4.2)
webpacker (~> 5.1)
webpush

View File

@ -28,7 +28,7 @@ class AccountsController < ApplicationController
end
@pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses?
@statuses = filtered_status_page(params)
@statuses = filtered_status_page
@statuses = cache_collection(@statuses, Status)
@rss_url = rss_url
@ -41,7 +41,7 @@ class AccountsController < ApplicationController
format.rss do
expires_in 1.minute, public: true
@statuses = filtered_statuses.without_reblogs.without_replies.limit(PAGE_SIZE)
@statuses = filtered_statuses.without_reblogs.limit(PAGE_SIZE)
@statuses = cache_collection(@statuses, Status)
render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
end
@ -130,23 +130,23 @@ class AccountsController < ApplicationController
end
def media_requested?
request.path.ends_with?('/media') && !tag_requested?
request.path.split('.').first.ends_with?('/media') && !tag_requested?
end
def replies_requested?
request.path.ends_with?('/with_replies') && !tag_requested?
request.path.split('.').first.ends_with?('/with_replies') && !tag_requested?
end
def tag_requested?
request.path.split('.').first.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
end
def filtered_status_page(params)
if params[:min_id].present?
filtered_statuses.paginate_by_min_id(PAGE_SIZE, params[:min_id]).reverse
else
filtered_statuses.paginate_by_max_id(PAGE_SIZE, params[:max_id], params[:since_id]).to_a
end
def filtered_status_page
filtered_statuses.paginate_by_id(PAGE_SIZE, params_slice(:max_id, :min_id, :since_id))
end
def params_slice(*keys)
params.slice(*keys).permit(*keys)
end
def restrict_fields_to

View File

@ -24,20 +24,23 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
def set_size
case params[:id]
when 'featured'
@account.pinned_statuses.count
@size = @account.pinned_statuses.count
else
raise ActiveRecord::RecordNotFound
not_found
end
end
def scope_for_collection
case params[:id]
when 'featured'
return Status.none if @account.blocking?(signed_request_account)
@account.pinned_statuses
else
raise ActiveRecord::RecordNotFound
# Because in public fetch mode we cache the response, there would be no
# benefit from performing the check below, since a blocked account or domain
# would likely be served the cache from the reverse proxy anyway
if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
Status.none
else
@account.pinned_statuses
end
end
end

View File

@ -49,7 +49,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
ResolveAccountWorker.perform_async(signed_request_account.acct)
end
DeliveryFailureTracker.track_inverse_success!(signed_request_account)
DeliveryFailureTracker.reset!(signed_request_account.inbox_url)
end
def process_payload

View File

@ -11,7 +11,7 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
before_action :set_cache_headers
def show
expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?)
expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode? && !(signed_request_account.present? && page_requested?))
render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
@ -50,12 +50,12 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
return unless page_requested?
@statuses = @account.statuses.permitted_for(@account, signed_request_account)
@statuses = params[:min_id].present? ? @statuses.paginate_by_min_id(LIMIT, params[:min_id]).reverse : @statuses.paginate_by_max_id(LIMIT, params[:max_id])
@statuses = @statuses.paginate_by_id(LIMIT, params_slice(:max_id, :min_id, :since_id))
@statuses = cache_collection(@statuses, Status)
end
def page_requested?
params[:page] == 'true'
truthy_param?(:page)
end
def page_params

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class ActivityPub::RepliesController < ActivityPub::BaseController
include SignatureAuthentication
include SignatureVerification
include Authorization
include AccountOwnedConcern
@ -19,15 +19,19 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
private
def pundit_user
signed_request_account
end
def set_status
@status = @account.statuses.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
raise ActiveRecord::RecordNotFound
not_found
end
def set_replies
@replies = page_params[:only_other_accounts] ? Status.where.not(account_id: @account.id) : @account.statuses
@replies = only_other_accounts? ? Status.where.not(account_id: @account.id) : @account.statuses
@replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted])
@replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id])
end
@ -38,7 +42,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
type: :unordered,
part_of: account_status_replies_url(@account, @status),
next: next_page,
items: @replies.map { |status| status.local ? status : status.uri }
items: @replies.map { |status| status.local? ? status : status.uri }
)
return page if page_requested?
@ -51,16 +55,21 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
end
def page_requested?
params[:page] == 'true'
truthy_param?(:page)
end
def only_other_accounts?
truthy_param?(:only_other_accounts)
end
def next_page
only_other_accounts = !(@replies&.last&.account_id == @account.id && @replies.size == DESCENDANTS_LIMIT)
account_status_replies_url(
@account,
@status,
page: true,
min_id: only_other_accounts && !page_params[:only_other_accounts] ? nil : @replies&.last&.id,
min_id: only_other_accounts && !only_other_accounts? ? nil : @replies&.last&.id,
only_other_accounts: only_other_accounts
)
end

View File

@ -19,7 +19,7 @@ module Admin
@followers_count = Follow.where(target_account: Account.where(domain: params[:id])).count
@reports_count = Report.where(target_account: Account.where(domain: params[:id])).count
@blocks_count = Block.where(target_account: Account.where(domain: params[:id])).count
@available = DeliveryFailureTracker.available?(Account.select(:shared_inbox_url).where(domain: params[:id]).first&.shared_inbox_url)
@available = DeliveryFailureTracker.available?(params[:id])
@media_storage = MediaAttachment.where(account: Account.where(domain: params[:id])).sum(:file_file_size)
@private_comment = @domain_block&.private_comment
@public_comment = @domain_block&.public_comment

View File

@ -20,7 +20,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
return [] if hide_results?
scope = default_accounts
scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil?
scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? || current_account.id == @account.id
scope.merge(paginated_follows).to_a
end

View File

@ -20,7 +20,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
return [] if hide_results?
scope = default_accounts
scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil?
scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? || current_account.id == @account.id
scope.merge(paginated_follows).to_a
end

View File

@ -18,7 +18,7 @@ class Api::V1::Polls::VotesController < Api::BaseController
@poll = Poll.attached.find(params[:poll_id])
authorize @poll.status, :show?
rescue Mastodon::NotPermittedError
raise ActiveRecord::RecordNotFound
not_found
end
def vote_params

View File

@ -17,7 +17,7 @@ class Api::V1::PollsController < Api::BaseController
@poll = Poll.attached.find(params[:id])
authorize @poll.status, :show?
rescue Mastodon::NotPermittedError
raise ActiveRecord::RecordNotFound
not_found
end
def refresh_poll

View File

@ -4,6 +4,7 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
before_action -> { doorkeeper_authorize! :push }
before_action :require_user!
before_action :set_web_push_subscription
before_action :check_web_push_subscription, only: [:show, :update]
def create
@web_subscription&.destroy!
@ -21,16 +22,11 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
end
def show
raise ActiveRecord::RecordNotFound if @web_subscription.nil?
render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer
end
def update
raise ActiveRecord::RecordNotFound if @web_subscription.nil?
@web_subscription.update!(data: data_params)
render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer
end
@ -45,12 +41,17 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
@web_subscription = ::Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id)
end
def check_web_push_subscription
not_found if @web_subscription.nil?
end
def subscription_params
params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh])
end
def data_params
return {} if params[:data].blank?
params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll])
end
end

View File

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

View File

@ -68,7 +68,7 @@ class Api::V1::StatusesController < Api::BaseController
@status = Status.find(params[:id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
raise ActiveRecord::RecordNotFound
not_found
end
def set_thread

View File

@ -39,7 +39,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController
end
def public_timeline_statuses
Status.as_public_timeline(current_account, truthy_param?(:local))
Status.as_public_timeline(current_account, truthy_param?(:remote) ? :remote : truthy_param?(:local))
end
def insert_pagination_headers
@ -47,7 +47,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController
end
def pagination_params(core_params)
params.slice(:local, :limit, :only_media).permit(:local, :limit, :only_media).merge(core_params)
params.slice(:local, :remote, :limit, :only_media).permit(:local, :remote, :limit, :only_media).merge(core_params)
end
def next_path

View File

@ -113,6 +113,13 @@ class Auth::SessionsController < Devise::SessionsController
render :two_factor
end
def require_no_authentication
super
# Delete flash message that isn't entirely useful and may be confusing in
# most cases because /web doesn't display/clear flash messages.
flash.delete(:alert) if flash[:alert] == I18n.t('devise.failure.already_authenticated')
end
private
def set_pack

View File

@ -28,18 +28,6 @@ module Localized
end
def request_locale
preferred_locale || compatible_locale
end
def preferred_locale
http_accept_language.preferred_language_from(available_locales)
end
def compatible_locale
http_accept_language.compatible_language_from(available_locales)
end
def available_locales
I18n.available_locales.reverse
http_accept_language.language_region_compatible_from(I18n.available_locales)
end
end

View File

@ -33,7 +33,7 @@ class MediaController < ApplicationController
def verify_permitted_status!
authorize @media_attachment.status, :show?
rescue Mastodon::NotPermittedError
raise ActiveRecord::RecordNotFound
not_found
end
def check_playable

View File

@ -42,7 +42,7 @@ class RemoteInteractionController < ApplicationController
@status = Status.find(params[:id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
raise ActiveRecord::RecordNotFound
not_found
end
def set_body_classes

View File

@ -22,8 +22,7 @@ class Settings::IdentityProofsController < Settings::BaseController
if current_account.username.casecmp(params[:username]).zero?
render layout: 'auth'
else
flash[:alert] = I18n.t('identity_proofs.errors.wrong_user', proving: params[:username], current: current_account.username)
redirect_to settings_identity_proofs_path
redirect_to settings_identity_proofs_path, alert: I18n.t('identity_proofs.errors.wrong_user', proving: params[:username], current: current_account.username)
end
end
@ -35,11 +34,16 @@ class Settings::IdentityProofsController < Settings::BaseController
PostStatusService.new.call(current_user.account, text: post_params[:status_text]) if publish_proof?
redirect_to @proof.on_success_path(params[:user_agent])
else
flash[:alert] = I18n.t('identity_proofs.errors.failed', provider: @proof.provider.capitalize)
redirect_to settings_identity_proofs_path
redirect_to settings_identity_proofs_path, alert: I18n.t('identity_proofs.errors.failed', provider: @proof.provider.capitalize)
end
end
def destroy
@proof = current_account.identity_proofs.find(params[:id])
@proof.destroy!
redirect_to settings_identity_proofs_path, success: I18n.t('identity_proofs.removed')
end
private
def check_enabled

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
module Settings
class PicturesController < BaseController
before_action :authenticate_user!
before_action :set_account
before_action :set_picture
def destroy
if valid_picture
account_params = {
@picture => nil,
(@picture + '_remote_url') => nil,
}
msg = UpdateAccountService.new.call(@account, account_params) ? I18n.t('generic.changes_saved_msg') : nil
redirect_to settings_profile_path, notice: msg, status: 303
else
bad_request
end
end
private
def set_account
@account = current_account
end
def set_picture
@picture = params[:id]
end
def valid_picture
@picture == 'avatar' || @picture == 'header'
end
end
end

View File

@ -49,7 +49,7 @@ class StatusesController < ApplicationController
def embed
use_pack 'embed'
return not_found if @status.hidden?
return not_found if @status.hidden? || @status.reblog?
expires_in 180, public: true
response.headers['X-Frame-Options'] = 'ALLOWALL'

View File

@ -10,6 +10,7 @@ class TagsController < ApplicationController
before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :authenticate_user!, if: :whitelist_mode?
before_action :set_tag
before_action :set_local
before_action :set_body_classes
before_action :set_instance_presenter
@ -25,7 +26,7 @@ class TagsController < ApplicationController
format.rss do
expires_in 0, public: true
@statuses = HashtagQueryService.new.call(@tag, filter_params).limit(PAGE_SIZE)
@statuses = HashtagQueryService.new.call(@tag, filter_params, nil, @local).limit(PAGE_SIZE)
@statuses = cache_collection(@statuses, Status)
render xml: RSS::TagSerializer.render(@tag, @statuses)
@ -34,7 +35,7 @@ class TagsController < ApplicationController
format.json do
expires_in 3.minutes, public: public_fetch_mode?
@statuses = HashtagQueryService.new.call(@tag, filter_params, current_account, params[:local]).paginate_by_max_id(PAGE_SIZE, params[:max_id])
@statuses = HashtagQueryService.new.call(@tag, filter_params, current_account, @local).paginate_by_max_id(PAGE_SIZE, params[:max_id])
@statuses = cache_collection(@statuses, Status)
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
@ -48,6 +49,10 @@ class TagsController < ApplicationController
@tag = Tag.usable.find_normalized!(params[:id])
end
def set_local
@local = truthy_param?(:local)
end
def set_body_classes
@body_classes = 'with-modals'
end

View File

@ -47,7 +47,7 @@ module Admin::ActionLogsHelper
I18n.t('admin.action_logs.deleted_status')
end
when 'Announcement'
truncate(attributes['text'])
truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text'])
end
end
end

View File

@ -7,13 +7,13 @@ module HomeHelper
}
end
def account_link_to(account, button = '', size: 36, path: nil)
def account_link_to(account, button = '', path: nil)
content_tag(:div, class: 'account') do
content_tag(:div, class: 'account__wrapper') do
section = if account.nil?
content_tag(:div, class: 'account__display-name') do
content_tag(:div, class: 'account__avatar-wrapper') do
content_tag(:div, '', class: 'account__avatar', style: "width: #{size}px; height: #{size}px; background-size: #{size}px #{size}px; background-image: url(#{full_asset_url('avatars/original/missing.png', skip_pipeline: true)})")
image_tag(full_asset_url('avatars/original/missing.png', skip_pipeline: true), class: 'account__avatar')
end +
content_tag(:span, class: 'display-name') do
content_tag(:strong, t('about.contact_missing')) +
@ -23,7 +23,7 @@ module HomeHelper
else
link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do
content_tag(:div, class: 'account__avatar-wrapper') do
content_tag(:div, '', class: 'account__avatar', style: "width: #{size}px; height: #{size}px; background-size: #{size}px #{size}px; background-image: url(#{full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url)})")
image_tag(full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url), class: 'account__avatar')
end +
content_tag(:span, class: 'display-name') do
content_tag(:bdi) do

View File

@ -68,6 +68,7 @@ module SettingsHelper
tr: 'Türkçe',
uk: 'Українська',
ur: 'اُردُو',
vi: 'Tiếng Việt',
'zh-CN': '简体中文',
'zh-HK': '繁體中文(香港)',
'zh-TW': '繁體中文(臺灣)',
@ -105,4 +106,13 @@ module SettingsHelper
safe_join([image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'), content_tag(:span, account.acct, class: 'username')], ' ')
end
end
def picture_hint(hint, picture)
if picture.original_filename.nil?
hint
else
link = link_to t('generic.delete'), settings_profile_picture_path(picture.name.to_s), data: { method: :delete }
safe_join([hint, link], '<br/>'.html_safe)
end
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module WebfingerHelper
def webfinger!(uri)
hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri)
raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if !Rails.configuration.x.access_to_hidden_service && hidden_service_uri
opts = {
ssl: !hidden_service_uri,
headers: {
'User-Agent': Mastodon::Version.user_agent,
},
}
Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger
end
end

View File

@ -1,6 +1,5 @@
// This file will be loaded on public pages, regardless of theme.
import createHistory from 'history/createBrowserHistory';
import ready from '../mastodon/ready';
const { delegate } = require('@rails/ujs');

View File

@ -10,7 +10,7 @@ delegate(document, '#account_display_name', 'input', ({ target }) => {
if (target.value) {
name.innerHTML = emojify(escapeTextContentForBrowser(target.value));
} else {
name.textContent = document.querySelector('#default_account_display_name').textContent;
name.textContent = name.textContent = target.dataset.default;
}
}
});

View File

@ -73,7 +73,7 @@ const refreshHomeTimelineAndNotification = (dispatch, done) => {
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
export const connectPublicStream = ({ onlyMedia } = {}) => connectTimelineStream(`public${onlyMedia ? ':media' : ''}`, `public${onlyMedia ? ':media' : ''}`);
export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) => connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`);
export const connectHashtagStream = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept);
export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`);

View File

@ -55,7 +55,7 @@ export function updateTimeline(timeline, status, accept) {
export function deleteFromTimelines(id) {
return (dispatch, getState) => {
const accountId = getState().getIn(['statuses', id, 'account']);
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => status.get('id'));
const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
dispatch({
@ -121,15 +121,14 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
};
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
max_id: maxId,
any: parseTags(tags, 'any'),

View File

@ -46,7 +46,7 @@ class DropdownMenu extends React.PureComponent {
document.addEventListener('keydown', this.handleKeyDown, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus();
this.focusedItem.focus({ preventScroll: true });
}
this.setState({ mounted: true });
}
@ -68,20 +68,14 @@ class DropdownMenu extends React.PureComponent {
handleKeyDown = e => {
const items = Array.from(this.node.getElementsByTagName('a'));
const index = items.indexOf(document.activeElement);
let element;
let element = null;
switch(e.key) {
case 'ArrowDown':
element = items[index+1];
if (element) {
element.focus();
}
element = items[index+1] || items[0];
break;
case 'ArrowUp':
element = items[index-1];
if (element) {
element.focus();
}
element = items[index-1] || items[items.length-1];
break;
case 'Tab':
if (e.shiftKey) {
@ -89,28 +83,23 @@ class DropdownMenu extends React.PureComponent {
} else {
element = items[index+1] || items[0];
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
break;
case 'Home':
element = items[0];
if (element) {
element.focus();
}
break;
case 'End':
element = items[items.length-1];
if (element) {
element.focus();
}
break;
case 'Escape':
this.props.onClose();
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
}
handleItemKeyPress = e => {

View File

@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import 'wicg-inert';
import createHistory from 'history/createBrowserHistory';
import { createBrowserHistory } from 'history';
export default class ModalRoot extends React.PureComponent {
static contextTypes = {
@ -51,7 +51,7 @@ export default class ModalRoot extends React.PureComponent {
componentDidMount () {
window.addEventListener('keyup', this.handleKeyUp, false);
window.addEventListener('keydown', this.handleKeyDown, false);
this.history = this.context.router ? this.context.router.history : createHistory();
this.history = this.context.router ? this.context.router.history : createBrowserHistory();
}
componentWillReceiveProps (nextProps) {

View File

@ -4,7 +4,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { vote, fetchPoll } from 'flavours/glitch/actions/polls';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import escapeTextContentForBrowser from 'escape-html';
@ -28,8 +27,9 @@ class Poll extends ImmutablePureComponent {
static propTypes = {
poll: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func,
disabled: PropTypes.bool,
refresh: PropTypes.func,
onVote: PropTypes.func,
};
state = {
@ -100,7 +100,7 @@ class Poll extends ImmutablePureComponent {
return;
}
this.props.dispatch(vote(this.props.poll.get('id'), Object.keys(this.state.selected)));
this.props.onVote(Object.keys(this.state.selected));
};
handleRefresh = () => {
@ -108,7 +108,7 @@ class Poll extends ImmutablePureComponent {
return;
}
this.props.dispatch(fetchPoll(this.props.poll.get('id')));
this.props.refresh();
};
renderOption (option, optionIndex, showResults) {

View File

@ -372,8 +372,8 @@ class Status extends ImmutablePureComponent {
}
};
handleOpenVideo = (media, startTime) => {
this.props.onOpenVideo(media, startTime);
handleOpenVideo = (media, options) => {
this.props.onOpenVideo(media, options);
}
handleHotkeyOpenMedia = e => {
@ -385,7 +385,7 @@ class Status extends ImmutablePureComponent {
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
// TODO: toggle play/paused?
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
onOpenVideo(status.getIn(['media_attachments', 0]), 0);
onOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 });
} else {
onOpenMedia(status.get('media_attachments'), 0);
}

View File

@ -1,8 +1,25 @@
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import Poll from 'flavours/glitch/components/poll';
import { fetchPoll, vote } from 'flavours/glitch/actions/polls';
const mapDispatchToProps = (dispatch, { pollId }) => ({
refresh: debounce(
() => {
dispatch(fetchPoll(pollId));
},
1000,
{ leading: true },
),
onVote (choices) {
dispatch(vote(pollId, choices));
},
});
const mapStateToProps = (state, { pollId }) => ({
poll: state.getIn(['polls', pollId]),
});
export default connect(mapStateToProps)(Poll);
export default connect(mapStateToProps, mapDispatchToProps)(Poll);

View File

@ -178,8 +178,8 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
dispatch(openModal('MEDIA', { media, index }));
},
onOpenVideo (media, time) {
dispatch(openModal('VIDEO', { media, time }));
onOpenVideo (media, options) {
dispatch(openModal('VIDEO', { media, options }));
},
onBlock (status) {

View File

@ -38,7 +38,7 @@ export default class TimelineContainer extends React.PureComponent {
let timeline;
if (hashtag) {
timeline = <HashtagTimeline hashtag={hashtag} />;
timeline = <HashtagTimeline hashtag={hashtag} local={local} />;
} else {
timeline = <PublicTimeline local={local} />;
}

View File

@ -186,10 +186,12 @@ class Header extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
} else {
if (account.getIn(['relationship', 'following'])) {
if (account.getIn(['relationship', 'showing_reblogs'])) {
menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
} else {
menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
if (!account.getIn(['relationship', 'muting'])) {
if (account.getIn(['relationship', 'showing_reblogs'])) {
menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
} else {
menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
}
}
menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle });

View File

@ -19,6 +19,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'blocks', 'items']),
hasMore: !!state.getIn(['user_lists', 'blocks', 'next']),
isLoading: state.getIn(['user_lists', 'blocks', 'isLoading'], true),
});
export default @connect(mapStateToProps)
@ -30,6 +31,7 @@ class Blocks extends ImmutablePureComponent {
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
@ -43,7 +45,7 @@ class Blocks extends ImmutablePureComponent {
}, 300, { leading: true });
render () {
const { intl, accountIds, hasMore, multiColumn } = this.props;
const { intl, accountIds, hasMore, multiColumn, isLoading } = this.props;
if (!accountIds) {
return (
@ -62,6 +64,7 @@ class Blocks extends ImmutablePureComponent {
scrollKey='blocks'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>

View File

@ -64,9 +64,9 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, withPassive);
if (this.focusedItem) {
this.focusedItem.focus();
this.focusedItem.focus({ preventScroll: true });
} else {
this.node.firstChild.focus();
this.node.firstChild.focus({ preventScroll: true });
}
this.setState({ mounted: true });
}
@ -106,7 +106,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
const index = items.findIndex(item => {
return (item.name === name);
});
let element;
let element = null;
switch(e.key) {
case 'Escape':
@ -117,18 +117,10 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
this.handleClick(e);
break;
case 'ArrowDown':
element = this.node.childNodes[index + 1];
if (element) {
element.focus();
this.handleChange(element.getAttribute('data-index'));
}
element = this.node.childNodes[index + 1] || this.node.firstChild;
break;
case 'ArrowUp':
element = this.node.childNodes[index - 1];
if (element) {
element.focus();
this.handleChange(element.getAttribute('data-index'));
}
element = this.node.childNodes[index - 1] || this.node.lastChild;
break;
case 'Tab':
if (e.shiftKey) {
@ -136,28 +128,21 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
} else {
element = this.node.childNodes[index + 1] || this.node.firstChild;
}
if (element) {
element.focus();
this.handleChange(element.getAttribute('data-index'));
e.preventDefault();
e.stopPropagation();
}
break;
case 'Home':
element = this.node.firstChild;
if (element) {
element.focus();
this.handleChange(element.getAttribute('data-index'));
}
break;
case 'End':
element = this.node.lastChild;
if (element) {
element.focus();
this.handleChange(element.getAttribute('data-index'));
}
break;
}
if (element) {
element.focus();
this.handleChange(element.getAttribute('data-index'));
e.preventDefault();
e.stopPropagation();
}
}
setFocusRef = c => {

View File

@ -28,6 +28,7 @@ class Option extends React.PureComponent {
title: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
isPollMultiple: PropTypes.bool,
autoFocus: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
@ -58,7 +59,7 @@ class Option extends React.PureComponent {
}
render () {
const { isPollMultiple, title, index, intl } = this.props;
const { isPollMultiple, title, index, autoFocus, intl } = this.props;
return (
<li>
@ -75,6 +76,7 @@ class Option extends React.PureComponent {
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
searchTokens={[':']}
autoFocus={autoFocus}
/>
</label>
@ -125,10 +127,12 @@ class PollForm extends ImmutablePureComponent {
return null;
}
const autoFocusIndex = options.indexOf('');
return (
<div className='compose-form__poll-wrapper'>
<ul>
{options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} {...other} />)}
{options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} autoFocus={i === autoFocusIndex} {...other} />)}
{options.size < pollLimits.max_options && (
<label className='poll__text editable'>
<span className={classNames('poll__input')} style={{ opacity: 0 }} />

View File

@ -19,6 +19,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'follow_requests', 'items']),
isLoading: state.getIn(['user_lists', 'follow_requests', 'isLoading'], true),
hasMore: !!state.getIn(['user_lists', 'follow_requests', 'next']),
locked: !!state.getIn(['accounts', me, 'locked']),
domain: state.getIn(['meta', 'domain']),
@ -32,6 +33,7 @@ class FollowRequests extends ImmutablePureComponent {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
accountIds: ImmutablePropTypes.list,
locked: PropTypes.bool,
domain: PropTypes.string,
@ -48,7 +50,7 @@ class FollowRequests extends ImmutablePureComponent {
}, 300, { leading: true });
render () {
const { intl, accountIds, hasMore, multiColumn, locked, domain } = this.props;
const { intl, accountIds, hasMore, multiColumn, locked, domain, isLoading } = this.props;
if (!accountIds) {
return (
@ -77,6 +79,7 @@ class FollowRequests extends ImmutablePureComponent {
scrollKey='follow_requests'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
prepend={unlockedPrependMessage}

View File

@ -22,6 +22,7 @@ const mapStateToProps = (state, props) => ({
isAccount: !!state.getIn(['accounts', props.params.accountId]),
accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
isLoading: state.getIn(['user_lists', 'followers', props.params.accountId, 'isLoading'], true),
});
export default @connect(mapStateToProps)
@ -32,6 +33,7 @@ class Followers extends ImmutablePureComponent {
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
isAccount: PropTypes.bool,
multiColumn: PropTypes.bool,
};
@ -54,14 +56,6 @@ class Followers extends ImmutablePureComponent {
this.column.scrollTop();
}
handleScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
this.props.dispatch(expandFollowers(this.props.params.accountId));
}
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandFollowers(this.props.params.accountId));
}, 300, { leading: true });
@ -71,7 +65,7 @@ class Followers extends ImmutablePureComponent {
}
render () {
const { accountIds, hasMore, isAccount, multiColumn } = this.props;
const { accountIds, hasMore, isAccount, multiColumn, isLoading } = this.props;
if (!isAccount) {
return (
@ -98,6 +92,7 @@ class Followers extends ImmutablePureComponent {
<ScrollableList
scrollKey='followers'
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
alwaysPrepend

View File

@ -22,6 +22,7 @@ const mapStateToProps = (state, props) => ({
isAccount: !!state.getIn(['accounts', props.params.accountId]),
accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
isLoading: state.getIn(['user_lists', 'following', props.params.accountId, 'isLoading'], true),
});
export default @connect(mapStateToProps)
@ -32,6 +33,7 @@ class Following extends ImmutablePureComponent {
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
isAccount: PropTypes.bool,
multiColumn: PropTypes.bool,
};
@ -54,14 +56,6 @@ class Following extends ImmutablePureComponent {
this.column.scrollTop();
}
handleScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
this.props.dispatch(expandFollowing(this.props.params.accountId));
}
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandFollowing(this.props.params.accountId));
}, 300, { leading: true });
@ -71,7 +65,7 @@ class Following extends ImmutablePureComponent {
}
render () {
const { accountIds, hasMore, isAccount, multiColumn } = this.props;
const { accountIds, hasMore, isAccount, multiColumn, isLoading } = this.props;
if (!isAccount) {
return (
@ -98,6 +92,7 @@ class Following extends ImmutablePureComponent {
<ScrollableList
scrollKey='following'
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
alwaysPrepend

View File

@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle';
import AsyncSelect from 'react-select/async';
import SettingToggle from '../../notifications/components/setting_toggle';
const messages = defineMessages({
placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' },
@ -87,6 +88,8 @@ class ColumnSettings extends React.PureComponent {
};
render () {
const { settings, onChange } = this.props;
return (
<div>
<div className='column-settings__row'>
@ -106,6 +109,10 @@ class ColumnSettings extends React.PureComponent {
{this.modeSelect('none')}
</div>
)}
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['local']} onChange={onChange} label={<FormattedMessage id='community.column_settings.local_only' defaultMessage='Local only' />} />
</div>
</div>
);
}

View File

@ -97,21 +97,21 @@ class HashtagTimeline extends React.PureComponent {
componentDidMount () {
const { dispatch } = this.props;
const { id, tags } = this.props.params;
const { id, tags, local } = this.props.params;
this._subscribe(dispatch, id, tags);
dispatch(expandHashtagTimeline(id, { tags }));
dispatch(expandHashtagTimeline(id, { tags, local }));
}
componentWillReceiveProps (nextProps) {
const { dispatch, params } = this.props;
const { id, tags } = nextProps.params;
const { id, tags, local } = nextProps.params;
if (id !== params.id || !isEqual(tags, params.tags)) {
if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
this._unsubscribe();
this._subscribe(dispatch, id, tags);
this.props.dispatch(clearTimeline(`hashtag:${id}`));
this.props.dispatch(expandHashtagTimeline(id, { tags }));
dispatch(clearTimeline(`hashtag:${id}`));
dispatch(expandHashtagTimeline(id, { tags, local }));
}
}
@ -124,8 +124,8 @@ class HashtagTimeline extends React.PureComponent {
}
handleLoadMore = maxId => {
const { id, tags } = this.props.params;
this.props.dispatch(expandHashtagTimeline(id, { maxId, tags }));
const { id, tags, local } = this.props.params;
this.props.dispatch(expandHashtagTimeline(id, { maxId, tags, local }));
}
render () {

View File

@ -19,6 +19,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'mutes', 'items']),
hasMore: !!state.getIn(['user_lists', 'mutes', 'next']),
isLoading: state.getIn(['user_lists', 'mutes', 'isLoading'], true),
});
export default @connect(mapStateToProps)
@ -29,6 +30,7 @@ class Mutes extends ImmutablePureComponent {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
accountIds: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
@ -43,7 +45,7 @@ class Mutes extends ImmutablePureComponent {
}, 300, { leading: true });
render () {
const { intl, accountIds, hasMore, multiColumn } = this.props;
const { intl, accountIds, hasMore, multiColumn, isLoading } = this.props;
if (!accountIds) {
return (
@ -62,6 +64,7 @@ class Mutes extends ImmutablePureComponent {
scrollKey='mutes'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>

View File

@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, FormattedMessage } from 'react-intl';
import SettingToggle from '../../notifications/components/setting_toggle';
export default @injectIntl
class ColumnSettings extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
};
render () {
const { settings, onChange } = this.props;
return (
<div>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
<SettingToggle settings={settings} settingPath={['other', 'onlyRemote']} onChange={onChange} label={<FormattedMessage id='community.column_settings.remote_only' defaultMessage='Remote only' />} />
</div>
</div>
);
}
}

View File

@ -1,5 +1,5 @@
import { connect } from 'react-redux';
import ColumnSettings from 'flavours/glitch/features/community_timeline/components/column_settings';
import ColumnSettings from '../components/column_settings';
import { changeSetting } from 'flavours/glitch/actions/settings';
import { changeColumnParams } from 'flavours/glitch/actions/columns';

View File

@ -19,11 +19,13 @@ const mapStateToProps = (state, { columnId }) => {
const columns = state.getIn(['settings', 'columns']);
const index = columns.findIndex(c => c.get('uuid') === uuid);
const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'public', 'other', 'onlyMedia']);
const onlyRemote = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyRemote']) : state.getIn(['settings', 'public', 'other', 'onlyRemote']);
const timelineState = state.getIn(['timelines', `public${onlyMedia ? ':media' : ''}`]);
return {
hasUnread: !!timelineState && timelineState.get('unread') > 0,
onlyMedia,
onlyRemote,
};
};
@ -46,15 +48,16 @@ class PublicTimeline extends React.PureComponent {
multiColumn: PropTypes.bool,
hasUnread: PropTypes.bool,
onlyMedia: PropTypes.bool,
onlyRemote: PropTypes.bool,
};
handlePin = () => {
const { columnId, dispatch, onlyMedia } = this.props;
const { columnId, dispatch, onlyMedia, onlyRemote } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('PUBLIC', { other: { onlyMedia } }));
dispatch(addColumn(onlyRemote ? 'REMOTE' : 'PUBLIC', { other: { onlyMedia, onlyRemote } }));
}
}
@ -68,19 +71,19 @@ class PublicTimeline extends React.PureComponent {
}
componentDidMount () {
const { dispatch, onlyMedia } = this.props;
const { dispatch, onlyMedia, onlyRemote } = this.props;
dispatch(expandPublicTimeline({ onlyMedia }));
this.disconnect = dispatch(connectPublicStream({ onlyMedia }));
dispatch(expandPublicTimeline({ onlyMedia, onlyRemote }));
this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote }));
}
componentDidUpdate (prevProps) {
if (prevProps.onlyMedia !== this.props.onlyMedia) {
const { dispatch, onlyMedia } = this.props;
if (prevProps.onlyMedia !== this.props.onlyMedia || prevProps.onlyRemote !== this.props.onlyRemote) {
const { dispatch, onlyMedia, onlyRemote } = this.props;
this.disconnect();
dispatch(expandPublicTimeline({ onlyMedia }));
this.disconnect = dispatch(connectPublicStream({ onlyMedia }));
dispatch(expandPublicTimeline({ onlyMedia, onlyRemote }));
this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote }));
}
}
@ -96,13 +99,13 @@ class PublicTimeline extends React.PureComponent {
}
handleLoadMore = maxId => {
const { dispatch, onlyMedia } = this.props;
const { dispatch, onlyMedia, onlyRemote } = this.props;
dispatch(expandPublicTimeline({ maxId, onlyMedia }));
dispatch(expandPublicTimeline({ maxId, onlyMedia, onlyRemote }));
}
render () {
const { intl, columnId, hasUnread, multiColumn, onlyMedia } = this.props;
const { intl, columnId, hasUnread, multiColumn, onlyMedia, onlyRemote } = this.props;
const pinned = !!columnId;
return (
@ -121,7 +124,7 @@ class PublicTimeline extends React.PureComponent {
</ColumnHeader>
<StatusListContainer
timelineId={`public${onlyMedia ? ':media' : ''}`}
timelineId={`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore}
trackScroll={!pinned}
scrollKey={`public_timeline-${columnId}`}

View File

@ -24,19 +24,25 @@ class HashtagTimeline extends React.PureComponent {
isLoading: PropTypes.bool.isRequired,
hasMore: PropTypes.bool.isRequired,
hashtag: PropTypes.string.isRequired,
local: PropTypes.bool.isRequired,
};
static defaultProps = {
local: false,
};
componentDidMount () {
const { dispatch, hashtag } = this.props;
const { dispatch, hashtag, local } = this.props;
dispatch(expandHashtagTimeline(hashtag));
dispatch(expandHashtagTimeline(hashtag, { local }));
}
handleLoadMore = () => {
const maxId = this.props.statusIds.last();
const { dispatch, hashtag, local, statusIds } = this.props;
const maxId = statusIds.last();
if (maxId) {
this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
dispatch(expandHashtagTimeline(hashtag, { maxId, local }));
}
}

View File

@ -66,8 +66,8 @@ export default class DetailedStatus extends ImmutablePureComponent {
e.stopPropagation();
}
handleOpenVideo = (media, startTime) => {
this.props.onOpenVideo(media, startTime);
handleOpenVideo = (media, options) => {
this.props.onOpenVideo(media, options);
}
_measureHeight (heightJustChanged) {

View File

@ -130,8 +130,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(openModal('MEDIA', { media, index }));
},
onOpenVideo (media, time) {
dispatch(openModal('VIDEO', { media, time }));
onOpenVideo (media, options) {
dispatch(openModal('VIDEO', { media, options }));
},
onBlock (status) {

View File

@ -316,8 +316,8 @@ class Status extends ImmutablePureComponent {
this.props.dispatch(openModal('MEDIA', { media, index }));
}
handleOpenVideo = (media, time) => {
this.props.dispatch(openModal('VIDEO', { media, time }));
handleOpenVideo = (media, options) => {
this.props.dispatch(openModal('VIDEO', { media, options }));
}
handleHotkeyOpenMedia = e => {
@ -329,7 +329,7 @@ class Status extends ImmutablePureComponent {
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
// TODO: toggle play/paused?
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
this.handleOpenVideo(status.getIn(['media_attachments', 0]), 0);
this.handleOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 });
} else {
this.handleOpenMedia(status.get('media_attachments'), 0);
}

View File

@ -37,6 +37,7 @@ const componentMap = {
'HOME': HomeTimeline,
'NOTIFICATIONS': Notifications,
'PUBLIC': PublicTimeline,
'REMOTE': PublicTimeline,
'COMMUNITY': CommunityTimeline,
'HASHTAG': HashtagTimeline,
'DIRECT': DirectTimeline,

View File

@ -16,7 +16,11 @@ export default class VideoModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
status: ImmutablePropTypes.map,
time: PropTypes.number,
options: PropTypes.shape({
startTime: PropTypes.number,
autoPlay: PropTypes.bool,
defaultVolume: PropTypes.number,
}),
onClose: PropTypes.func.isRequired,
};
@ -28,7 +32,8 @@ export default class VideoModal extends ImmutablePureComponent {
}
render () {
const { media, status, time, onClose } = this.props;
const { media, status, onClose } = this.props;
const options = this.props.options || {};
return (
<div className='modal-root__modal video-modal'>
@ -37,7 +42,9 @@ export default class VideoModal extends ImmutablePureComponent {
preview={media.get('preview_url')}
blurhash={media.get('blurhash')}
src={media.get('url')}
startTime={time}
startTime={options.startTime}
autoPlay={options.autoPlay}
defaultVolume={options.defaultVolume}
onCloseVideo={onClose}
detailed
alt={media.get('description')}

View File

@ -111,6 +111,8 @@ class Video extends React.PureComponent {
preventPlayback: PropTypes.bool,
blurhash: PropTypes.string,
link: PropTypes.node,
autoPlay: PropTypes.bool,
defaultVolume: PropTypes.number,
};
state = {
@ -360,6 +362,13 @@ class Video extends React.PureComponent {
handleLoadedData = () => {
if (this.props.startTime) {
this.video.currentTime = this.props.startTime;
}
if (this.props.defaultVolume !== undefined) {
this.video.volume = this.props.defaultVolume;
}
if (this.props.autoPlay) {
this.video.play();
}
}
@ -386,8 +395,14 @@ class Video extends React.PureComponent {
height,
});
const options = {
startTime: this.video.currentTime,
autoPlay: !this.state.paused,
defaultVolume: this.state.volume,
};
this.video.pause();
this.props.onOpenVideo(media, this.video.currentTime);
this.props.onOpenVideo(media, options);
}
handleCloseVideo = () => {

View File

@ -99,15 +99,13 @@ function main() {
delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));
delegate(document, '.status__content__spoiler-link', 'click', function() {
const contentEl = this.parentNode.parentNode.querySelector('.e-content');
const statusEl = this.parentNode.parentNode;
if (contentEl.style.display === 'block') {
contentEl.style.display = 'none';
this.parentNode.style.marginBottom = 0;
if (statusEl.dataset.spoiler === 'expanded') {
statusEl.dataset.spoiler = 'folded';
this.textContent = (new IntlMessageFormat(messages['status.show_more'] || 'Show more', locale)).format();
} else {
contentEl.style.display = 'block';
this.parentNode.style.marginBottom = null;
statusEl.dataset.spoiler = 'expanded';
this.textContent = (new IntlMessageFormat(messages['status.show_less'] || 'Show less', locale)).format();
}
@ -115,8 +113,8 @@ function main() {
});
[].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => {
const contentEl = spoilerLink.parentNode.parentNode.querySelector('.e-content');
const message = (contentEl.style.display === 'block') ? (messages['status.show_less'] || 'Show less') : (messages['status.show_more'] || 'Show more');
const statusEl = spoilerLink.parentNode.parentNode;
const message = (statusEl.dataset.spoiler === 'expanded') ? (messages['status.show_less'] || 'Show less') : (messages['status.show_more'] || 'Show more');
spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format();
});
});

View File

@ -24,7 +24,7 @@ const importStatuses = (state, statuses) =>
const deleteStatus = (state, id, references) => {
references.forEach(ref => {
state = deleteStatus(state, ref[0], []);
state = deleteStatus(state, ref, []);
});
return state.delete(id);

View File

@ -94,7 +94,7 @@ const updateTimeline = (state, timeline, status, usePendingItems, filtered) => {
}));
};
const deleteStatus = (state, id, accountId, references, exclude_account = null) => {
const deleteStatus = (state, id, references, exclude_account = null) => {
state.keySeq().forEach(timeline => {
if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) {
const helper = list => list.filterNot(item => item === id);
@ -104,7 +104,7 @@ const deleteStatus = (state, id, accountId, references, exclude_account = null)
// Remove reblogs of deleted status
references.forEach(ref => {
state = deleteStatus(state, ref[0], ref[1], [], exclude_account);
state = deleteStatus(state, ref, [], exclude_account);
});
return state;
@ -122,8 +122,8 @@ const filterTimelines = (state, relationship, statuses) => {
return;
}
references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => [item.get('id'), item.get('account')]);
state = deleteStatus(state, status.get('id'), status.get('account'), references, relationship.id);
references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => item.get('id'));
state = deleteStatus(state, status.get('id'), references, relationship.id);
});
return state;
@ -155,7 +155,7 @@ export default function timelines(state = initialState, action) {
case TIMELINE_UPDATE:
return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems, action.filtered);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
return deleteStatus(state, action.id, action.references, action.reblogOf);
case TIMELINE_CLEAR:
return clearTimeline(state, action.timeline);
case ACCOUNT_BLOCK_SUCCESS:

View File

@ -2,12 +2,24 @@ import {
NOTIFICATIONS_UPDATE,
} from '../actions/notifications';
import {
FOLLOWERS_FETCH_REQUEST,
FOLLOWERS_FETCH_SUCCESS,
FOLLOWERS_FETCH_FAIL,
FOLLOWERS_EXPAND_REQUEST,
FOLLOWERS_EXPAND_SUCCESS,
FOLLOWERS_EXPAND_FAIL,
FOLLOWING_FETCH_REQUEST,
FOLLOWING_FETCH_SUCCESS,
FOLLOWING_FETCH_FAIL,
FOLLOWING_EXPAND_REQUEST,
FOLLOWING_EXPAND_SUCCESS,
FOLLOWING_EXPAND_FAIL,
FOLLOW_REQUESTS_FETCH_REQUEST,
FOLLOW_REQUESTS_FETCH_SUCCESS,
FOLLOW_REQUESTS_FETCH_FAIL,
FOLLOW_REQUESTS_EXPAND_REQUEST,
FOLLOW_REQUESTS_EXPAND_SUCCESS,
FOLLOW_REQUESTS_EXPAND_FAIL,
FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
FOLLOW_REQUEST_REJECT_SUCCESS,
} from 'flavours/glitch/actions/accounts';
@ -16,12 +28,20 @@ import {
FAVOURITES_FETCH_SUCCESS,
} from 'flavours/glitch/actions/interactions';
import {
BLOCKS_FETCH_REQUEST,
BLOCKS_FETCH_SUCCESS,
BLOCKS_FETCH_FAIL,
BLOCKS_EXPAND_REQUEST,
BLOCKS_EXPAND_SUCCESS,
BLOCKS_EXPAND_FAIL,
} from 'flavours/glitch/actions/blocks';
import {
MUTES_FETCH_REQUEST,
MUTES_FETCH_SUCCESS,
MUTES_FETCH_FAIL,
MUTES_EXPAND_REQUEST,
MUTES_EXPAND_SUCCESS,
MUTES_EXPAND_FAIL,
} from 'flavours/glitch/actions/mutes';
import {
DIRECTORY_FETCH_REQUEST,
@ -47,12 +67,13 @@ const normalizeList = (state, type, id, accounts, next) => {
return state.setIn([type, id], ImmutableMap({
next,
items: ImmutableList(accounts.map(item => item.id)),
isLoading: false,
}));
};
const appendToList = (state, type, id, accounts, next) => {
return state.updateIn([type, id], map => {
return map.set('next', next).update('items', list => list.concat(accounts.map(item => item.id)));
return map.set('next', next).set('isLoading', false).update('items', list => list.concat(accounts.map(item => item.id)));
});
};
@ -68,10 +89,22 @@ export default function userLists(state = initialState, action) {
return normalizeList(state, 'followers', action.id, action.accounts, action.next);
case FOLLOWERS_EXPAND_SUCCESS:
return appendToList(state, 'followers', action.id, action.accounts, action.next);
case FOLLOWERS_FETCH_REQUEST:
case FOLLOWERS_EXPAND_REQUEST:
return state.setIn(['followers', action.id, 'isLoading'], true);
case FOLLOWERS_FETCH_FAIL:
case FOLLOWERS_EXPAND_FAIL:
return state.setIn(['followers', action.id, 'isLoading'], false);
case FOLLOWING_FETCH_SUCCESS:
return normalizeList(state, 'following', action.id, action.accounts, action.next);
case FOLLOWING_EXPAND_SUCCESS:
return appendToList(state, 'following', action.id, action.accounts, action.next);
case FOLLOWING_FETCH_REQUEST:
case FOLLOWING_EXPAND_REQUEST:
return state.setIn(['following', action.id, 'isLoading'], true);
case FOLLOWING_FETCH_FAIL:
case FOLLOWING_EXPAND_FAIL:
return state.setIn(['following', action.id, 'isLoading'], false);
case REBLOGS_FETCH_SUCCESS:
return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
case FAVOURITES_FETCH_SUCCESS:
@ -79,9 +112,15 @@ export default function userLists(state = initialState, action) {
case NOTIFICATIONS_UPDATE:
return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
case FOLLOW_REQUESTS_FETCH_SUCCESS:
return state.setIn(['follow_requests', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
return state.setIn(['follow_requests', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next).setIn(['follow_requests', 'isLoading'], false);
case FOLLOW_REQUESTS_EXPAND_SUCCESS:
return state.updateIn(['follow_requests', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
return state.updateIn(['follow_requests', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next).setIn(['follow_requests', 'isLoading'], false);
case FOLLOW_REQUESTS_FETCH_REQUEST:
case FOLLOW_REQUESTS_EXPAND_REQUEST:
return state.setIn(['follow_requests', 'isLoading'], true);
case FOLLOW_REQUESTS_FETCH_FAIL:
case FOLLOW_REQUESTS_EXPAND_FAIL:
return state.setIn(['follow_requests', 'isLoading'], false);
case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
case FOLLOW_REQUEST_REJECT_SUCCESS:
return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
@ -89,10 +128,22 @@ export default function userLists(state = initialState, action) {
return state.setIn(['blocks', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
case BLOCKS_EXPAND_SUCCESS:
return state.updateIn(['blocks', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
case BLOCKS_FETCH_REQUEST:
case BLOCKS_EXPAND_REQUEST:
return state.setIn(['blocks', 'isLoading'], true);
case BLOCKS_FETCH_FAIL:
case BLOCKS_EXPAND_FAIL:
return state.setIn(['blocks', 'isLoading'], false);
case MUTES_FETCH_SUCCESS:
return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
case MUTES_EXPAND_SUCCESS:
return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
case MUTES_FETCH_REQUEST:
case MUTES_EXPAND_REQUEST:
return state.setIn(['mutes', 'isLoading'], true);
case MUTES_FETCH_FAIL:
case MUTES_EXPAND_FAIL:
return state.setIn(['mutes', 'isLoading'], false);
case DIRECTORY_FETCH_SUCCESS:
return state.setIn(['directory', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false);
case DIRECTORY_EXPAND_SUCCESS:

View File

@ -545,13 +545,6 @@ $small-breakpoint: 960px;
flex: 0 0 auto;
}
&__avatar {
width: 44px;
height: 44px;
background-size: 44px 44px;
@include avatar-size(44px);
}
.display-name {
font-size: 15px;
@ -752,16 +745,15 @@ $small-breakpoint: 960px;
display: flex;
align-items: center;
}
}
.account__avatar {
width: 44px;
height: 44px;
background-size: 44px 44px;
}
&__counters__wrapper {
display: flex;
}
&__counter {
padding: 10px;
width: 50%;
strong {
font-family: $font-display, sans-serif;

View File

@ -567,6 +567,18 @@ body,
}
}
.special-action-button,
.back-link {
text-align: right;
flex: 1 1 auto;
}
.action-buttons {
display: flex;
overflow: hidden;
justify-content: space-between;
}
.spacer {
flex: 1 1 auto;
}
@ -904,3 +916,11 @@ a.name-tag,
}
}
}
.account-badges {
margin: -2px 0;
}
.dashboard__counters.admin-account-counters {
margin-top: 10px;
}

View File

@ -150,3 +150,19 @@ button {
height: 100%;
}
}
.logo-resources {
display: none;
}
// NoScript adds a __ns__pop2top class to the full ancestry of blocked elements,
// to set the z-index to a high value, which messes with modals and dropdowns.
// Blocked elements can in theory only be media and frames/embeds, so they
// should only appear in statuses, under divs and articles.
body,
div,
article {
.__ns__pop2top {
z-index: unset !important;
}
}

View File

@ -38,9 +38,14 @@
.account__avatar {
@include avatar-radius();
display: block;
position: relative;
cursor: pointer;
width: 36px;
height: 36px;
background-size: 36px 36px;
&-inline {
display: inline-block;
vertical-align: middle;
@ -80,6 +85,12 @@
&-base {
@include avatar-radius();
@include avatar-size(36px);
img {
@include avatar-radius;
width: 100%;
height: 100%;
}
}
&-overlay {
@ -90,6 +101,12 @@
bottom: 0;
right: 0;
z-index: 1;
img {
@include avatar-radius;
width: 100%;
height: 100%;
}
}
}

View File

@ -691,3 +691,10 @@
}
}
}
.gifv {
video {
max-width: 100vw;
max-height: 80vh;
}
}

View File

@ -185,6 +185,7 @@
path:last-child {
stroke: lighten($highlight-text-color, 6%) !important;
fill: none !important;
}
}
}

View File

@ -133,6 +133,10 @@ code {
}
}
.otp-hint {
margin-bottom: 25px;
}
.card {
margin-bottom: 15px;
}
@ -276,6 +280,14 @@ code {
margin-bottom: 25px;
}
}
.fields-group.invited-by {
margin-bottom: 30px;
.hint {
text-align: center;
}
}
}
.input.radio_buttons .radio label {
@ -626,6 +638,15 @@ code {
@media screen and (max-width: 740px) and (min-width: 441px) {
margin-top: 40px;
}
&.translation-prompt {
text-align: unset;
color: unset;
a {
text-decoration: underline;
}
}
}
.form-footer {

View File

@ -25,6 +25,36 @@
}
}
progress {
border: 0;
display: block;
width: 100%;
height: 5px;
appearance: none;
background: transparent;
&::-webkit-progress-bar {
background: transparent;
}
// Those rules need to be entirely separate or they won't work, hence the
// duplication
&::-moz-progress-bar {
border-radius: 4px;
background: darken($ui-primary-color, 5%);
}
&::-ms-fill {
border-radius: 4px;
background: darken($ui-primary-color, 5%);
}
&::-webkit-progress-value {
border-radius: 4px;
background: darken($ui-primary-color, 5%);
}
}
&__option {
position: relative;
display: flex;

View File

@ -124,6 +124,16 @@
.embed,
.public-layout {
.status__content[data-spoiler=folded] {
.e-content {
display: none;
}
p:first-child {
margin-bottom: 0;
}
}
.detailed-status {
padding: 15px;
}
@ -135,6 +145,11 @@
&__avatar {
left: 15px;
top: 17px;
.account__avatar {
width: 48px;
height: 48px;
}
}
&__content {
@ -162,6 +177,13 @@
.video-player {
margin-top: 10px;
}
&__action-bar-button {
font-size: 18px;
width: 23.1429px;
height: 23.1429px;
line-height: 23.15px;
}
}
}

View File

@ -93,12 +93,6 @@
display: flex;
align-items: center;
}
.account__avatar {
width: 44px;
height: 44px;
background-size: 44px 44px;
}
}
.trends__item {

File diff suppressed because one or more lines are too long

View File

@ -2,6 +2,45 @@ import EXIF from 'exif-js';
const MAX_IMAGE_PIXELS = 1638400; // 1280x1280px
const _browser_quirks = {};
// Some browsers will automatically draw images respecting their EXIF orientation
// while others won't, and the safest way to detect that is to examine how it
// is done on a known image.
// See https://github.com/w3c/csswg-drafts/issues/4666
// and https://github.com/blueimp/JavaScript-Load-Image/commit/1e4df707821a0afcc11ea0720ee403b8759f3881
const dropOrientationIfNeeded = (orientation) => new Promise(resolve => {
switch (_browser_quirks['image-orientation-automatic']) {
case true:
resolve(1);
break;
case false:
resolve(orientation);
break;
default:
// black 2x1 JPEG, with the following meta information set:
// - EXIF Orientation: 6 (Rotated 90° CCW)
const testImageURL =
'data:image/jpeg;base64,/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAA' +
'AAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBA' +
'QEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE' +
'BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAEAAgMBEQACEQEDEQH/x' +
'ABKAAEAAAAAAAAAAAAAAAAAAAALEAEAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAA' +
'AAAAAEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8H//2Q==';
const img = new Image();
img.onload = () => {
const automatic = (img.width === 1 && img.height === 2);
_browser_quirks['image-orientation-automatic'] = automatic;
resolve(automatic ? 1 : orientation);
};
img.onerror = () => {
_browser_quirks['image-orientation-automatic'] = false;
resolve(orientation);
};
img.src = testImageURL;
}
});
const getImageUrl = inputFile => new Promise((resolve, reject) => {
if (window.URL && URL.createObjectURL) {
try {
@ -38,7 +77,11 @@ const getOrientation = (img, type = 'image/png') => new Promise(resolve => {
EXIF.getData(img, () => {
const orientation = EXIF.getTag(img, 'Orientation');
resolve(orientation);
if (orientation !== 1) {
dropOrientationIfNeeded(orientation).then(resolve).catch(() => resolve(orientation));
} else {
resolve(orientation);
}
});
});
@ -95,7 +138,7 @@ const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) =
.catch(reject);
});
export default inputFile => new Promise((resolve, reject) => {
export default inputFile => new Promise((resolve) => {
if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') {
resolve(inputFile);
return;
@ -110,5 +153,5 @@ export default inputFile => new Promise((resolve, reject) => {
resizeImage(img, inputFile.type)
.then(resolve)
.catch(() => resolve(inputFile));
}).catch(reject);
}).catch(() => resolve(inputFile));
});

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216.4144 232.00976"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -73,7 +73,7 @@ const refreshHomeTimelineAndNotification = (dispatch, done) => {
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
export const connectPublicStream = ({ onlyMedia } = {}) => connectTimelineStream(`public${onlyMedia ? ':media' : ''}`, `public${onlyMedia ? ':media' : ''}`);
export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) => connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`);
export const connectHashtagStream = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept);
export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`);

View File

@ -42,7 +42,7 @@ export function updateTimeline(timeline, status, accept) {
export function deleteFromTimelines(id) {
return (dispatch, getState) => {
const accountId = getState().getIn(['statuses', id, 'account']);
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => status.get('id'));
const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
dispatch({
@ -107,18 +107,19 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
};
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
max_id: maxId,
any: parseTags(tags, 'any'),
all: parseTags(tags, 'all'),
none: parseTags(tags, 'none'),
local: local,
}, done);
};

View File

@ -46,7 +46,7 @@ class DropdownMenu extends React.PureComponent {
document.addEventListener('keydown', this.handleKeyDown, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus();
this.focusedItem.focus({ preventScroll: true });
}
this.setState({ mounted: true });
}
@ -68,20 +68,14 @@ class DropdownMenu extends React.PureComponent {
handleKeyDown = e => {
const items = Array.from(this.node.getElementsByTagName('a'));
const index = items.indexOf(document.activeElement);
let element;
let element = null;
switch(e.key) {
case 'ArrowDown':
element = items[index+1];
if (element) {
element.focus();
}
element = items[index+1] || items[0];
break;
case 'ArrowUp':
element = items[index-1];
if (element) {
element.focus();
}
element = items[index-1] || items[items.length-1];
break;
case 'Tab':
if (e.shiftKey) {
@ -89,28 +83,23 @@ class DropdownMenu extends React.PureComponent {
} else {
element = items[index+1] || items[0];
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
break;
case 'Home':
element = items[0];
if (element) {
element.focus();
}
break;
case 'End':
element = items[items.length-1];
if (element) {
element.focus();
}
break;
case 'Escape':
this.props.onClose();
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
}
handleItemKeyPress = e => {

View File

@ -4,7 +4,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { vote, fetchPoll } from 'mastodon/actions/polls';
import Motion from 'mastodon/features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import escapeTextContentForBrowser from 'escape-html';
@ -28,8 +27,9 @@ class Poll extends ImmutablePureComponent {
static propTypes = {
poll: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func,
disabled: PropTypes.bool,
refresh: PropTypes.func,
onVote: PropTypes.func,
};
state = {
@ -100,7 +100,7 @@ class Poll extends ImmutablePureComponent {
return;
}
this.props.dispatch(vote(this.props.poll.get('id'), Object.keys(this.state.selected)));
this.props.onVote(Object.keys(this.state.selected));
};
handleRefresh = () => {
@ -108,7 +108,7 @@ class Poll extends ImmutablePureComponent {
return;
}
this.props.dispatch(fetchPoll(this.props.poll.get('id')));
this.props.refresh();
};
renderOption (option, optionIndex, showResults) {

View File

@ -176,8 +176,8 @@ class Status extends ImmutablePureComponent {
return <div className='audio-player' style={{ height: '110px' }} />;
}
handleOpenVideo = (media, startTime) => {
this.props.onOpenVideo(media, startTime);
handleOpenVideo = (media, options) => {
this.props.onOpenVideo(media, options);
}
handleHotkeyOpenMedia = e => {
@ -190,7 +190,7 @@ class Status extends ImmutablePureComponent {
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
// TODO: toggle play/paused?
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
onOpenVideo(status.getIn(['media_attachments', 0]), 0);
onOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 });
} else {
onOpenMedia(status.get('media_attachments'), 0);
}

View File

@ -1,8 +1,25 @@
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import Poll from 'mastodon/components/poll';
import { fetchPoll, vote } from 'mastodon/actions/polls';
const mapDispatchToProps = (dispatch, { pollId }) => ({
refresh: debounce(
() => {
dispatch(fetchPoll(pollId));
},
1000,
{ leading: true },
),
onVote (choices) {
dispatch(vote(pollId, choices));
},
});
const mapStateToProps = (state, { pollId }) => ({
poll: state.getIn(['polls', pollId]),
});
export default connect(mapStateToProps)(Poll);
export default connect(mapStateToProps, mapDispatchToProps)(Poll);

View File

@ -150,8 +150,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(openModal('MEDIA', { media, index }));
},
onOpenVideo (media, time) {
dispatch(openModal('VIDEO', { media, time }));
onOpenVideo (media, options) {
dispatch(openModal('VIDEO', { media, options }));
},
onBlock (status) {

View File

@ -38,7 +38,7 @@ export default class TimelineContainer extends React.PureComponent {
let timeline;
if (hashtag) {
timeline = <HashtagTimeline hashtag={hashtag} />;
timeline = <HashtagTimeline hashtag={hashtag} local={local} />;
} else {
timeline = <PublicTimeline local={local} />;
}

View File

@ -192,10 +192,12 @@ class Header extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
} else {
if (account.getIn(['relationship', 'following'])) {
if (account.getIn(['relationship', 'showing_reblogs'])) {
menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
} else {
menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
if (!account.getIn(['relationship', 'muting'])) {
if (account.getIn(['relationship', 'showing_reblogs'])) {
menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
} else {
menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
}
}
menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle });

View File

@ -19,6 +19,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'blocks', 'items']),
hasMore: !!state.getIn(['user_lists', 'blocks', 'next']),
isLoading: state.getIn(['user_lists', 'blocks', 'isLoading'], true),
});
export default @connect(mapStateToProps)
@ -31,6 +32,7 @@ class Blocks extends ImmutablePureComponent {
shouldUpdateScroll: PropTypes.func,
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
@ -44,7 +46,7 @@ class Blocks extends ImmutablePureComponent {
}, 300, { leading: true });
render () {
const { intl, accountIds, shouldUpdateScroll, hasMore, multiColumn } = this.props;
const { intl, accountIds, shouldUpdateScroll, hasMore, multiColumn, isLoading } = this.props;
if (!accountIds) {
return (
@ -63,6 +65,7 @@ class Blocks extends ImmutablePureComponent {
scrollKey='blocks'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}

View File

@ -27,6 +27,7 @@ class Option extends React.PureComponent {
title: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
isPollMultiple: PropTypes.bool,
autoFocus: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
onToggleMultiple: PropTypes.func.isRequired,
@ -71,7 +72,7 @@ class Option extends React.PureComponent {
}
render () {
const { isPollMultiple, title, index, intl } = this.props;
const { isPollMultiple, title, index, autoFocus, intl } = this.props;
return (
<li>
@ -96,6 +97,7 @@ class Option extends React.PureComponent {
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
searchTokens={[':']}
autoFocus={autoFocus}
/>
</label>
@ -146,10 +148,12 @@ class PollForm extends ImmutablePureComponent {
return null;
}
const autoFocusIndex = options.indexOf('');
return (
<div className='compose-form__poll-wrapper'>
<ul>
{options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} onToggleMultiple={this.handleToggleMultiple} {...other} />)}
{options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} onToggleMultiple={this.handleToggleMultiple} autoFocus={i === autoFocusIndex} {...other} />)}
</ul>
<div className='poll__footer'>

View File

@ -50,7 +50,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
const index = items.findIndex(item => {
return (item.value === value);
});
let element;
let element = null;
switch(e.key) {
case 'Escape':
@ -60,18 +60,10 @@ class PrivacyDropdownMenu extends React.PureComponent {
this.handleClick(e);
break;
case 'ArrowDown':
element = this.node.childNodes[index + 1];
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
element = this.node.childNodes[index + 1] || this.node.firstChild;
break;
case 'ArrowUp':
element = this.node.childNodes[index - 1];
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
element = this.node.childNodes[index - 1] || this.node.lastChild;
break;
case 'Tab':
if (e.shiftKey) {
@ -79,28 +71,21 @@ class PrivacyDropdownMenu extends React.PureComponent {
} else {
element = this.node.childNodes[index + 1] || this.node.firstChild;
}
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
e.preventDefault();
e.stopPropagation();
}
break;
case 'Home':
element = this.node.firstChild;
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break;
case 'End':
element = this.node.lastChild;
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break;
}
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
e.preventDefault();
e.stopPropagation();
}
}
handleClick = e => {
@ -115,7 +100,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem) this.focusedItem.focus();
if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
this.setState({ mounted: true });
}

File diff suppressed because one or more lines are too long

View File

@ -19,6 +19,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'follow_requests', 'items']),
isLoading: state.getIn(['user_lists', 'follow_requests', 'isLoading'], true),
hasMore: !!state.getIn(['user_lists', 'follow_requests', 'next']),
locked: !!state.getIn(['accounts', me, 'locked']),
domain: state.getIn(['meta', 'domain']),
@ -33,6 +34,7 @@ class FollowRequests extends ImmutablePureComponent {
dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
accountIds: ImmutablePropTypes.list,
locked: PropTypes.bool,
domain: PropTypes.string,
@ -49,7 +51,7 @@ class FollowRequests extends ImmutablePureComponent {
}, 300, { leading: true });
render () {
const { intl, shouldUpdateScroll, accountIds, hasMore, multiColumn, locked, domain } = this.props;
const { intl, shouldUpdateScroll, accountIds, hasMore, multiColumn, locked, domain, isLoading } = this.props;
if (!accountIds) {
return (
@ -77,6 +79,7 @@ class FollowRequests extends ImmutablePureComponent {
scrollKey='follow_requests'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}

View File

@ -22,6 +22,7 @@ const mapStateToProps = (state, props) => ({
isAccount: !!state.getIn(['accounts', props.params.accountId]),
accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
isLoading: state.getIn(['user_lists', 'followers', props.params.accountId, 'isLoading'], true),
blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
});
@ -34,6 +35,7 @@ class Followers extends ImmutablePureComponent {
shouldUpdateScroll: PropTypes.func,
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
blockedBy: PropTypes.bool,
isAccount: PropTypes.bool,
multiColumn: PropTypes.bool,
@ -58,7 +60,7 @@ class Followers extends ImmutablePureComponent {
}, 300, { leading: true });
render () {
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn } = this.props;
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading } = this.props;
if (!isAccount) {
return (
@ -85,6 +87,7 @@ class Followers extends ImmutablePureComponent {
<ScrollableList
scrollKey='followers'
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
shouldUpdateScroll={shouldUpdateScroll}
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}

View File

@ -22,6 +22,7 @@ const mapStateToProps = (state, props) => ({
isAccount: !!state.getIn(['accounts', props.params.accountId]),
accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
isLoading: state.getIn(['user_lists', 'following', props.params.accountId, 'isLoading'], true),
blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
});
@ -34,6 +35,7 @@ class Following extends ImmutablePureComponent {
shouldUpdateScroll: PropTypes.func,
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
blockedBy: PropTypes.bool,
isAccount: PropTypes.bool,
multiColumn: PropTypes.bool,
@ -58,7 +60,7 @@ class Following extends ImmutablePureComponent {
}, 300, { leading: true });
render () {
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn } = this.props;
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading } = this.props;
if (!isAccount) {
return (
@ -85,6 +87,7 @@ class Following extends ImmutablePureComponent {
<ScrollableList
scrollKey='following'
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
shouldUpdateScroll={shouldUpdateScroll}
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}

View File

@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle';
import AsyncSelect from 'react-select/async';
import SettingToggle from '../../notifications/components/setting_toggle';
const messages = defineMessages({
placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' },
@ -87,6 +88,8 @@ class ColumnSettings extends React.PureComponent {
};
render () {
const { settings, onChange } = this.props;
return (
<div>
<div className='column-settings__row'>
@ -106,6 +109,10 @@ class ColumnSettings extends React.PureComponent {
{this.modeSelect('none')}
</div>
)}
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['local']} onChange={onChange} label={<FormattedMessage id='community.column_settings.local_only' defaultMessage='Local only' />} />
</div>
</div>
);
}

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