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: docker:
- image: circleci/ruby:2.7-buster-node - image: circleci/ruby:2.7-buster-node
environment: &ruby_environment environment: &ruby_environment
BUNDLE_JOBS: 3
BUNDLE_RETRY: 3
BUNDLE_APP_CONFIG: ./.bundle/ BUNDLE_APP_CONFIG: ./.bundle/
BUNDLE_PATH: ./vendor/bundle/ BUNDLE_PATH: ./vendor/bundle/
DB_HOST: localhost DB_HOST: localhost
DB_USER: root DB_USER: root
RAILS_ENV: test RAILS_ENV: test
PARALLEL_TEST_PROCESSORS: 4
ALLOW_NOPAM: true ALLOW_NOPAM: true
CONTINUOUS_INTEGRATION: true CONTINUOUS_INTEGRATION: true
DISABLE_SIMPLECOV: true DISABLE_SIMPLECOV: true
@ -32,9 +33,9 @@ aliases:
- &restore_ruby_dependencies - &restore_ruby_dependencies
restore_cache: restore_cache:
keys: keys:
- v2-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }} - v3-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }}
- v2-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}- - v3-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-
- v2-ruby-dependencies- - v3-ruby-dependencies-
- &install_steps - &install_steps
steps: steps:
@ -42,11 +43,13 @@ aliases:
- *attach_workspace - *attach_workspace
- restore_cache: - restore_cache:
keys: keys:
- v1-node-dependencies-{{ checksum "yarn.lock" }} - v2-node-dependencies-{{ checksum "yarn.lock" }}
- v1-node-dependencies- - v2-node-dependencies-
- run: yarn install --frozen-lockfile - run:
name: Install yarn dependencies
command: yarn install --frozen-lockfile
- save_cache: - save_cache:
key: v1-node-dependencies-{{ checksum "yarn.lock" }} key: v2-node-dependencies-{{ checksum "yarn.lock" }}
paths: paths:
- ./node_modules/ - ./node_modules/
- *persist_to_workspace - *persist_to_workspace
@ -57,27 +60,28 @@ aliases:
command: | command: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libicu-dev libidn11-dev libprotobuf-dev protobuf-compiler 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 - &install_ruby_dependencies
steps: steps:
- *attach_workspace - *attach_workspace
- *install_system_dependencies - *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 - *restore_ruby_dependencies
- run: bundle config set clean 'true' - run:
- run: bundle config set deployment 'true' name: Set bundler settings
- run: bundle config set with 'pam_authentication' command: |
- run: bundle config set without 'development production' bundle config clean 'true'
- run: bundle config set frozen 'true' bundle config deployment 'true'
- run: bundle install --jobs 16 --retry 3 && bundle clean 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: - 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: paths:
- ./.bundle/ - ./.bundle/
- ./vendor/bundle/ - ./vendor/bundle/
@ -88,17 +92,26 @@ aliases:
- ./mastodon/vendor/bundle/ - ./mastodon/vendor/bundle/
- &test_steps - &test_steps
parallelism: 4
steps: steps:
- *attach_workspace - *attach_workspace
- *install_system_dependencies - *install_system_dependencies
- run: sudo apt-get install -y ffmpeg
- run: - run:
name: Prepare Tests name: Install FFMPEG
command: ./bin/rails parallel:create parallel:load_schema parallel:prepare command: sudo apt-get install -y ffmpeg
- run: - run:
name: Run Tests name: Load database schema
command: ./bin/retry bundle exec parallel_test ./spec/ --group-by filesize --type rspec 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: jobs:
install: install:
<<: *defaults <<: *defaults
@ -115,19 +128,14 @@ jobs:
environment: *ruby_environment environment: *ruby_environment
<<: *install_ruby_dependencies <<: *install_ruby_dependencies
install-ruby2.5:
<<: *defaults
docker:
- image: circleci/ruby:2.5-buster-node
environment: *ruby_environment
<<: *install_ruby_dependencies
build: build:
<<: *defaults <<: *defaults
steps: steps:
- *attach_workspace - *attach_workspace
- *install_system_dependencies - *install_system_dependencies
- run: ./bin/rails assets:precompile - run:
name: Precompile assets
command: ./bin/rails assets:precompile
- persist_to_workspace: - persist_to_workspace:
root: ~/projects/ root: ~/projects/
paths: paths:
@ -149,10 +157,10 @@ jobs:
- *install_system_dependencies - *install_system_dependencies
- run: - run:
name: Create database name: Create database
command: ./bin/rails parallel:create command: ./bin/rails db:create
- run: - run:
name: Run migrations name: Run migrations
command: ./bin/rails parallel:migrate command: ./bin/rails db:migrate
test-ruby2.7: test-ruby2.7:
<<: *defaults <<: *defaults
@ -178,35 +186,33 @@ jobs:
- image: circleci/redis:5-alpine - image: circleci/redis:5-alpine
<<: *test_steps <<: *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: test-webui:
<<: *defaults <<: *defaults
docker: docker:
- image: circleci/node:12-buster - image: circleci/node:12-buster
steps: steps:
- *attach_workspace - *attach_workspace
- run: ./bin/retry yarn test:jest - run:
name: Run jest
command: yarn test:jest
check-i18n: check-i18n:
<<: *defaults <<: *defaults
steps: steps:
- *attach_workspace - *attach_workspace
- *install_system_dependencies - *install_system_dependencies
- run: bundle exec i18n-tasks check-normalized - run:
- run: bundle exec i18n-tasks unused -l en name: Check locale file normalization
- run: bundle exec i18n-tasks check-consistent-interpolations command: bundle exec i18n-tasks check-normalized
- run: bundle exec rake repo:check_locales_files - 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: workflows:
version: 2 version: 2
@ -220,10 +226,6 @@ workflows:
requires: requires:
- install - install
- install-ruby2.7 - install-ruby2.7
- install-ruby2.5:
requires:
- install
- install-ruby2.7
- build: - build:
requires: requires:
- install-ruby2.7 - install-ruby2.7
@ -238,10 +240,6 @@ workflows:
requires: requires:
- install-ruby2.6 - install-ruby2.6
- build - build
- test-ruby2.5:
requires:
- install-ruby2.5
- build
- test-webui: - test-webui:
requires: requires:
- install - install

View File

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

View File

@ -4,7 +4,25 @@ update_configs:
- package_manager: "ruby:bundler" - package_manager: "ruby:bundler"
directory: "/" directory: "/"
update_schedule: "weekly" 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" - package_manager: "javascript"
directory: "/" directory: "/"
update_schedule: "weekly" 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 # ALTERNATE_DOMAINS=example1.com,example2.com
# Application secrets # 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= SECRET_KEY_BASE=
OTP_SECRET= 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 # 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. # 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 # For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=

2
.gitignore vendored
View File

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

View File

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

View File

@ -3,6 +3,73 @@ Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [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 ## [v3.1.3] - 2020-04-05
### Added ### Added

29
Gemfile
View File

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

View File

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

View File

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

View File

@ -24,20 +24,23 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
def set_size def set_size
case params[:id] case params[:id]
when 'featured' when 'featured'
@account.pinned_statuses.count @size = @account.pinned_statuses.count
else else
raise ActiveRecord::RecordNotFound not_found
end end
end end
def scope_for_collection def scope_for_collection
case params[:id] case params[:id]
when 'featured' when 'featured'
return Status.none if @account.blocking?(signed_request_account) # 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
@account.pinned_statuses # would likely be served the cache from the reverse proxy anyway
else 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)))
raise ActiveRecord::RecordNotFound Status.none
else
@account.pinned_statuses
end
end end
end end

View File

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

View File

@ -11,7 +11,7 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
before_action :set_cache_headers before_action :set_cache_headers
def show 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' render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end end
@ -50,12 +50,12 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
return unless page_requested? return unless page_requested?
@statuses = @account.statuses.permitted_for(@account, signed_request_account) @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) @statuses = cache_collection(@statuses, Status)
end end
def page_requested? def page_requested?
params[:page] == 'true' truthy_param?(:page)
end end
def page_params def page_params

View File

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

View File

@ -19,7 +19,7 @@ module Admin
@followers_count = Follow.where(target_account: Account.where(domain: params[:id])).count @followers_count = Follow.where(target_account: Account.where(domain: params[:id])).count
@reports_count = Report.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 @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) @media_storage = MediaAttachment.where(account: Account.where(domain: params[:id])).sum(:file_file_size)
@private_comment = @domain_block&.private_comment @private_comment = @domain_block&.private_comment
@public_comment = @domain_block&.public_comment @public_comment = @domain_block&.public_comment

View File

@ -20,7 +20,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
return [] if hide_results? return [] if hide_results?
scope = default_accounts 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 scope.merge(paginated_follows).to_a
end end

View File

@ -20,7 +20,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
return [] if hide_results? return [] if hide_results?
scope = default_accounts 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 scope.merge(paginated_follows).to_a
end end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -39,7 +39,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController
end end
def public_timeline_statuses 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 end
def insert_pagination_headers def insert_pagination_headers
@ -47,7 +47,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController
end end
def pagination_params(core_params) 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 end
def next_path def next_path

View File

@ -113,6 +113,13 @@ class Auth::SessionsController < Devise::SessionsController
render :two_factor render :two_factor
end 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 private
def set_pack def set_pack

View File

@ -28,18 +28,6 @@ module Localized
end end
def request_locale def request_locale
preferred_locale || compatible_locale http_accept_language.language_region_compatible_from(I18n.available_locales)
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
end end
end end

View File

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

View File

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

View File

@ -22,8 +22,7 @@ class Settings::IdentityProofsController < Settings::BaseController
if current_account.username.casecmp(params[:username]).zero? if current_account.username.casecmp(params[:username]).zero?
render layout: 'auth' render layout: 'auth'
else else
flash[:alert] = I18n.t('identity_proofs.errors.wrong_user', proving: params[:username], current: current_account.username) redirect_to settings_identity_proofs_path, alert: I18n.t('identity_proofs.errors.wrong_user', proving: params[:username], current: current_account.username)
redirect_to settings_identity_proofs_path
end end
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? PostStatusService.new.call(current_user.account, text: post_params[:status_text]) if publish_proof?
redirect_to @proof.on_success_path(params[:user_agent]) redirect_to @proof.on_success_path(params[:user_agent])
else else
flash[:alert] = I18n.t('identity_proofs.errors.failed', provider: @proof.provider.capitalize) redirect_to settings_identity_proofs_path, alert: I18n.t('identity_proofs.errors.failed', provider: @proof.provider.capitalize)
redirect_to settings_identity_proofs_path
end end
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 private
def check_enabled 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 def embed
use_pack 'embed' use_pack 'embed'
return not_found if @status.hidden? return not_found if @status.hidden? || @status.reblog?
expires_in 180, public: true expires_in 180, public: true
response.headers['X-Frame-Options'] = 'ALLOWALL' 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 :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :authenticate_user!, if: :whitelist_mode? before_action :authenticate_user!, if: :whitelist_mode?
before_action :set_tag before_action :set_tag
before_action :set_local
before_action :set_body_classes before_action :set_body_classes
before_action :set_instance_presenter before_action :set_instance_presenter
@ -25,7 +26,7 @@ class TagsController < ApplicationController
format.rss do format.rss do
expires_in 0, public: true 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) @statuses = cache_collection(@statuses, Status)
render xml: RSS::TagSerializer.render(@tag, @statuses) render xml: RSS::TagSerializer.render(@tag, @statuses)
@ -34,7 +35,7 @@ class TagsController < ApplicationController
format.json do format.json do
expires_in 3.minutes, public: public_fetch_mode? 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) @statuses = cache_collection(@statuses, Status)
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' 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]) @tag = Tag.usable.find_normalized!(params[:id])
end end
def set_local
@local = truthy_param?(:local)
end
def set_body_classes def set_body_classes
@body_classes = 'with-modals' @body_classes = 'with-modals'
end end

View File

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

View File

@ -7,13 +7,13 @@ module HomeHelper
} }
end 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') do
content_tag(:div, class: 'account__wrapper') do content_tag(:div, class: 'account__wrapper') do
section = if account.nil? section = if account.nil?
content_tag(:div, class: 'account__display-name') do content_tag(:div, class: 'account__display-name') do
content_tag(:div, class: 'account__avatar-wrapper') 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 + end +
content_tag(:span, class: 'display-name') do content_tag(:span, class: 'display-name') do
content_tag(:strong, t('about.contact_missing')) + content_tag(:strong, t('about.contact_missing')) +
@ -23,7 +23,7 @@ module HomeHelper
else else
link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do 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-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 + end +
content_tag(:span, class: 'display-name') do content_tag(:span, class: 'display-name') do
content_tag(:bdi) do content_tag(:bdi) do

View File

@ -68,6 +68,7 @@ module SettingsHelper
tr: 'Türkçe', tr: 'Türkçe',
uk: 'Українська', uk: 'Українська',
ur: 'اُردُو', ur: 'اُردُو',
vi: 'Tiếng Việt',
'zh-CN': '简体中文', 'zh-CN': '简体中文',
'zh-HK': '繁體中文(香港)', 'zh-HK': '繁體中文(香港)',
'zh-TW': '繁體中文(臺灣)', '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')], ' ') safe_join([image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'), content_tag(:span, account.acct, class: 'username')], ' ')
end end
end 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 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. // This file will be loaded on public pages, regardless of theme.
import createHistory from 'history/createBrowserHistory';
import ready from '../mastodon/ready'; import ready from '../mastodon/ready';
const { delegate } = require('@rails/ujs'); const { delegate } = require('@rails/ujs');

View File

@ -10,7 +10,7 @@ delegate(document, '#account_display_name', 'input', ({ target }) => {
if (target.value) { if (target.value) {
name.innerHTML = emojify(escapeTextContentForBrowser(target.value)); name.innerHTML = emojify(escapeTextContentForBrowser(target.value));
} else { } 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 connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); 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 connectHashtagStream = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept);
export const connectDirectStream = () => connectTimelineStream('direct', 'direct'); export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`); 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) { export function deleteFromTimelines(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
const accountId = getState().getIn(['statuses', id, 'account']); 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); const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
dispatch({ 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 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 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 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 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 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 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 expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
max_id: maxId, max_id: maxId,
any: parseTags(tags, 'any'), any: parseTags(tags, 'any'),

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,25 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { debounce } from 'lodash';
import Poll from 'flavours/glitch/components/poll'; 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 }) => ({ const mapStateToProps = (state, { pollId }) => ({
poll: state.getIn(['polls', 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 })); dispatch(openModal('MEDIA', { media, index }));
}, },
onOpenVideo (media, time) { onOpenVideo (media, options) {
dispatch(openModal('VIDEO', { media, time })); dispatch(openModal('VIDEO', { media, options }));
}, },
onBlock (status) { onBlock (status) {

View File

@ -38,7 +38,7 @@ export default class TimelineContainer extends React.PureComponent {
let timeline; let timeline;
if (hashtag) { if (hashtag) {
timeline = <HashtagTimeline hashtag={hashtag} />; timeline = <HashtagTimeline hashtag={hashtag} local={local} />;
} else { } else {
timeline = <PublicTimeline local={local} />; 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' }); menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
} else { } else {
if (account.getIn(['relationship', 'following'])) { if (account.getIn(['relationship', 'following'])) {
if (account.getIn(['relationship', 'showing_reblogs'])) { if (!account.getIn(['relationship', 'muting'])) {
menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); if (account.getIn(['relationship', 'showing_reblogs'])) {
} else { menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
menu.push({ text: intl.formatMessage(messages.showReblogs, { 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 }); 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 => ({ const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'blocks', 'items']), accountIds: state.getIn(['user_lists', 'blocks', 'items']),
hasMore: !!state.getIn(['user_lists', 'blocks', 'next']), hasMore: !!state.getIn(['user_lists', 'blocks', 'next']),
isLoading: state.getIn(['user_lists', 'blocks', 'isLoading'], true),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@ -30,6 +31,7 @@ class Blocks extends ImmutablePureComponent {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list, accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
}; };
@ -43,7 +45,7 @@ class Blocks extends ImmutablePureComponent {
}, 300, { leading: true }); }, 300, { leading: true });
render () { render () {
const { intl, accountIds, hasMore, multiColumn } = this.props; const { intl, accountIds, hasMore, multiColumn, isLoading } = this.props;
if (!accountIds) { if (!accountIds) {
return ( return (
@ -62,6 +64,7 @@ class Blocks extends ImmutablePureComponent {
scrollKey='blocks' scrollKey='blocks'
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
hasMore={hasMore} hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,19 +24,25 @@ class HashtagTimeline extends React.PureComponent {
isLoading: PropTypes.bool.isRequired, isLoading: PropTypes.bool.isRequired,
hasMore: PropTypes.bool.isRequired, hasMore: PropTypes.bool.isRequired,
hashtag: PropTypes.string.isRequired, hashtag: PropTypes.string.isRequired,
local: PropTypes.bool.isRequired,
};
static defaultProps = {
local: false,
}; };
componentDidMount () { componentDidMount () {
const { dispatch, hashtag } = this.props; const { dispatch, hashtag, local } = this.props;
dispatch(expandHashtagTimeline(hashtag)); dispatch(expandHashtagTimeline(hashtag, { local }));
} }
handleLoadMore = () => { handleLoadMore = () => {
const maxId = this.props.statusIds.last(); const { dispatch, hashtag, local, statusIds } = this.props;
const maxId = statusIds.last();
if (maxId) { 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(); e.stopPropagation();
} }
handleOpenVideo = (media, startTime) => { handleOpenVideo = (media, options) => {
this.props.onOpenVideo(media, startTime); this.props.onOpenVideo(media, options);
} }
_measureHeight (heightJustChanged) { _measureHeight (heightJustChanged) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -545,13 +545,6 @@ $small-breakpoint: 960px;
flex: 0 0 auto; flex: 0 0 auto;
} }
&__avatar {
width: 44px;
height: 44px;
background-size: 44px 44px;
@include avatar-size(44px);
}
.display-name { .display-name {
font-size: 15px; font-size: 15px;
@ -752,16 +745,15 @@ $small-breakpoint: 960px;
display: flex; display: flex;
align-items: center; align-items: center;
} }
}
.account__avatar { &__counters__wrapper {
width: 44px; display: flex;
height: 44px;
background-size: 44px 44px;
}
} }
&__counter { &__counter {
padding: 10px; padding: 10px;
width: 50%;
strong { strong {
font-family: $font-display, sans-serif; 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 { .spacer {
flex: 1 1 auto; 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%; 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 { .account__avatar {
@include avatar-radius(); @include avatar-radius();
display: block;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
width: 36px;
height: 36px;
background-size: 36px 36px;
&-inline { &-inline {
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
@ -80,6 +85,12 @@
&-base { &-base {
@include avatar-radius(); @include avatar-radius();
@include avatar-size(36px); @include avatar-size(36px);
img {
@include avatar-radius;
width: 100%;
height: 100%;
}
} }
&-overlay { &-overlay {
@ -90,6 +101,12 @@
bottom: 0; bottom: 0;
right: 0; right: 0;
z-index: 1; 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 { path:last-child {
stroke: lighten($highlight-text-color, 6%) !important; stroke: lighten($highlight-text-color, 6%) !important;
fill: none !important;
} }
} }
} }

View File

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

View File

@ -124,6 +124,16 @@
.embed, .embed,
.public-layout { .public-layout {
.status__content[data-spoiler=folded] {
.e-content {
display: none;
}
p:first-child {
margin-bottom: 0;
}
}
.detailed-status { .detailed-status {
padding: 15px; padding: 15px;
} }
@ -135,6 +145,11 @@
&__avatar { &__avatar {
left: 15px; left: 15px;
top: 17px; top: 17px;
.account__avatar {
width: 48px;
height: 48px;
}
} }
&__content { &__content {
@ -162,6 +177,13 @@
.video-player { .video-player {
margin-top: 10px; 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; display: flex;
align-items: center; align-items: center;
} }
.account__avatar {
width: 44px;
height: 44px;
background-size: 44px 44px;
}
} }
.trends__item { .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 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) => { const getImageUrl = inputFile => new Promise((resolve, reject) => {
if (window.URL && URL.createObjectURL) { if (window.URL && URL.createObjectURL) {
try { try {
@ -38,7 +77,11 @@ const getOrientation = (img, type = 'image/png') => new Promise(resolve => {
EXIF.getData(img, () => { EXIF.getData(img, () => {
const orientation = EXIF.getTag(img, 'Orientation'); 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); .catch(reject);
}); });
export default inputFile => new Promise((resolve, reject) => { export default inputFile => new Promise((resolve) => {
if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') { if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') {
resolve(inputFile); resolve(inputFile);
return; return;
@ -110,5 +153,5 @@ export default inputFile => new Promise((resolve, reject) => {
resizeImage(img, inputFile.type) resizeImage(img, inputFile.type)
.then(resolve) .then(resolve)
.catch(() => resolve(inputFile)); .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 connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); 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 connectHashtagStream = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept);
export const connectDirectStream = () => connectTimelineStream('direct', 'direct'); export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`); 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) { export function deleteFromTimelines(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
const accountId = getState().getIn(['statuses', id, 'account']); 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); const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
dispatch({ 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 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 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 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 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 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 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}`, { return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
max_id: maxId, max_id: maxId,
any: parseTags(tags, 'any'), any: parseTags(tags, 'any'),
all: parseTags(tags, 'all'), all: parseTags(tags, 'all'),
none: parseTags(tags, 'none'), none: parseTags(tags, 'none'),
local: local,
}, done); }, done);
}; };

View File

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

View File

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

View File

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

View File

@ -1,8 +1,25 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { debounce } from 'lodash';
import Poll from 'mastodon/components/poll'; 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 }) => ({ const mapStateToProps = (state, { pollId }) => ({
poll: state.getIn(['polls', 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 })); dispatch(openModal('MEDIA', { media, index }));
}, },
onOpenVideo (media, time) { onOpenVideo (media, options) {
dispatch(openModal('VIDEO', { media, time })); dispatch(openModal('VIDEO', { media, options }));
}, },
onBlock (status) { onBlock (status) {

View File

@ -38,7 +38,7 @@ export default class TimelineContainer extends React.PureComponent {
let timeline; let timeline;
if (hashtag) { if (hashtag) {
timeline = <HashtagTimeline hashtag={hashtag} />; timeline = <HashtagTimeline hashtag={hashtag} local={local} />;
} else { } else {
timeline = <PublicTimeline local={local} />; 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' }); menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
} else { } else {
if (account.getIn(['relationship', 'following'])) { if (account.getIn(['relationship', 'following'])) {
if (account.getIn(['relationship', 'showing_reblogs'])) { if (!account.getIn(['relationship', 'muting'])) {
menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); if (account.getIn(['relationship', 'showing_reblogs'])) {
} else { menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
menu.push({ text: intl.formatMessage(messages.showReblogs, { 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 }); 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 => ({ const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'blocks', 'items']), accountIds: state.getIn(['user_lists', 'blocks', 'items']),
hasMore: !!state.getIn(['user_lists', 'blocks', 'next']), hasMore: !!state.getIn(['user_lists', 'blocks', 'next']),
isLoading: state.getIn(['user_lists', 'blocks', 'isLoading'], true),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@ -31,6 +32,7 @@ class Blocks extends ImmutablePureComponent {
shouldUpdateScroll: PropTypes.func, shouldUpdateScroll: PropTypes.func,
accountIds: ImmutablePropTypes.list, accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
}; };
@ -44,7 +46,7 @@ class Blocks extends ImmutablePureComponent {
}, 300, { leading: true }); }, 300, { leading: true });
render () { render () {
const { intl, accountIds, shouldUpdateScroll, hasMore, multiColumn } = this.props; const { intl, accountIds, shouldUpdateScroll, hasMore, multiColumn, isLoading } = this.props;
if (!accountIds) { if (!accountIds) {
return ( return (
@ -63,6 +65,7 @@ class Blocks extends ImmutablePureComponent {
scrollKey='blocks' scrollKey='blocks'
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
hasMore={hasMore} hasMore={hasMore}
isLoading={isLoading}
shouldUpdateScroll={shouldUpdateScroll} shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}

View File

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

View File

@ -50,7 +50,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
const index = items.findIndex(item => { const index = items.findIndex(item => {
return (item.value === value); return (item.value === value);
}); });
let element; let element = null;
switch(e.key) { switch(e.key) {
case 'Escape': case 'Escape':
@ -60,18 +60,10 @@ class PrivacyDropdownMenu extends React.PureComponent {
this.handleClick(e); this.handleClick(e);
break; break;
case 'ArrowDown': case 'ArrowDown':
element = this.node.childNodes[index + 1]; element = this.node.childNodes[index + 1] || this.node.firstChild;
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break; break;
case 'ArrowUp': case 'ArrowUp':
element = this.node.childNodes[index - 1]; element = this.node.childNodes[index - 1] || this.node.lastChild;
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break; break;
case 'Tab': case 'Tab':
if (e.shiftKey) { if (e.shiftKey) {
@ -79,28 +71,21 @@ class PrivacyDropdownMenu extends React.PureComponent {
} else { } else {
element = this.node.childNodes[index + 1] || this.node.firstChild; 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; break;
case 'Home': case 'Home':
element = this.node.firstChild; element = this.node.firstChild;
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break; break;
case 'End': case 'End':
element = this.node.lastChild; element = this.node.lastChild;
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break; break;
} }
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
e.preventDefault();
e.stopPropagation();
}
} }
handleClick = e => { handleClick = e => {
@ -115,7 +100,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
componentDidMount () { componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem) this.focusedItem.focus(); if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
this.setState({ mounted: 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 => ({ const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'follow_requests', 'items']), 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']), hasMore: !!state.getIn(['user_lists', 'follow_requests', 'next']),
locked: !!state.getIn(['accounts', me, 'locked']), locked: !!state.getIn(['accounts', me, 'locked']),
domain: state.getIn(['meta', 'domain']), domain: state.getIn(['meta', 'domain']),
@ -33,6 +34,7 @@ class FollowRequests extends ImmutablePureComponent {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func, shouldUpdateScroll: PropTypes.func,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
accountIds: ImmutablePropTypes.list, accountIds: ImmutablePropTypes.list,
locked: PropTypes.bool, locked: PropTypes.bool,
domain: PropTypes.string, domain: PropTypes.string,
@ -49,7 +51,7 @@ class FollowRequests extends ImmutablePureComponent {
}, 300, { leading: true }); }, 300, { leading: true });
render () { render () {
const { intl, shouldUpdateScroll, accountIds, hasMore, multiColumn, locked, domain } = this.props; const { intl, shouldUpdateScroll, accountIds, hasMore, multiColumn, locked, domain, isLoading } = this.props;
if (!accountIds) { if (!accountIds) {
return ( return (
@ -77,6 +79,7 @@ class FollowRequests extends ImmutablePureComponent {
scrollKey='follow_requests' scrollKey='follow_requests'
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
hasMore={hasMore} hasMore={hasMore}
isLoading={isLoading}
shouldUpdateScroll={shouldUpdateScroll} shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}

View File

@ -22,6 +22,7 @@ const mapStateToProps = (state, props) => ({
isAccount: !!state.getIn(['accounts', props.params.accountId]), isAccount: !!state.getIn(['accounts', props.params.accountId]),
accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']), accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']), 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), blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
}); });
@ -34,6 +35,7 @@ class Followers extends ImmutablePureComponent {
shouldUpdateScroll: PropTypes.func, shouldUpdateScroll: PropTypes.func,
accountIds: ImmutablePropTypes.list, accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
blockedBy: PropTypes.bool, blockedBy: PropTypes.bool,
isAccount: PropTypes.bool, isAccount: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
@ -58,7 +60,7 @@ class Followers extends ImmutablePureComponent {
}, 300, { leading: true }); }, 300, { leading: true });
render () { render () {
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn } = this.props; const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading } = this.props;
if (!isAccount) { if (!isAccount) {
return ( return (
@ -85,6 +87,7 @@ class Followers extends ImmutablePureComponent {
<ScrollableList <ScrollableList
scrollKey='followers' scrollKey='followers'
hasMore={hasMore} hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
shouldUpdateScroll={shouldUpdateScroll} shouldUpdateScroll={shouldUpdateScroll}
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />} 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]), isAccount: !!state.getIn(['accounts', props.params.accountId]),
accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']), accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']), 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), blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
}); });
@ -34,6 +35,7 @@ class Following extends ImmutablePureComponent {
shouldUpdateScroll: PropTypes.func, shouldUpdateScroll: PropTypes.func,
accountIds: ImmutablePropTypes.list, accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
blockedBy: PropTypes.bool, blockedBy: PropTypes.bool,
isAccount: PropTypes.bool, isAccount: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
@ -58,7 +60,7 @@ class Following extends ImmutablePureComponent {
}, 300, { leading: true }); }, 300, { leading: true });
render () { render () {
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn } = this.props; const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading } = this.props;
if (!isAccount) { if (!isAccount) {
return ( return (
@ -85,6 +87,7 @@ class Following extends ImmutablePureComponent {
<ScrollableList <ScrollableList
scrollKey='following' scrollKey='following'
hasMore={hasMore} hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
shouldUpdateScroll={shouldUpdateScroll} shouldUpdateScroll={shouldUpdateScroll}
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />} 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 { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle'; import Toggle from 'react-toggle';
import AsyncSelect from 'react-select/async'; import AsyncSelect from 'react-select/async';
import SettingToggle from '../../notifications/components/setting_toggle';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' }, placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' },
@ -87,6 +88,8 @@ class ColumnSettings extends React.PureComponent {
}; };
render () { render () {
const { settings, onChange } = this.props;
return ( return (
<div> <div>
<div className='column-settings__row'> <div className='column-settings__row'>
@ -106,6 +109,10 @@ class ColumnSettings extends React.PureComponent {
{this.modeSelect('none')} {this.modeSelect('none')}
</div> </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> </div>
); );
} }

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