This commit is contained in:
Tykayn 2021-10-22 10:36:12 +02:00 committed by tykayn
commit 2a7cb03875
559 changed files with 21434 additions and 10153 deletions

View File

@ -1,255 +1,152 @@
version: 2 version: 2.1
aliases: orbs:
- &defaults ruby: circleci/ruby@1.2.0
node: circleci/node@4.7.0
executors:
default:
parameters:
ruby-version:
type: string
docker: docker:
- image: circleci/ruby:2.7-buster-node - image: cimg/ruby:<< parameters.ruby-version >>
environment: &ruby_environment environment:
BUNDLE_JOBS: 3 BUNDLE_JOBS: 3
BUNDLE_RETRY: 3 BUNDLE_RETRY: 3
BUNDLE_APP_CONFIG: ./.bundle/ CONTINUOUS_INTEGRATION: true
BUNDLE_PATH: ./vendor/bundle/
DB_HOST: localhost DB_HOST: localhost
DB_USER: root DB_USER: root
RAILS_ENV: test
ALLOW_NOPAM: true
CONTINUOUS_INTEGRATION: true
DISABLE_SIMPLECOV: true DISABLE_SIMPLECOV: true
PAM_ENABLED: true RAILS_ENV: test
PAM_DEFAULT_SERVICE: pam_test - image: cimg/postgres:12.7
PAM_CONTROLLED_SERVICE: pam_test_controlled environment:
working_directory: ~/projects/mastodon/ POSTGRES_USER: root
POSTGRES_HOST_AUTH_METHOD: trust
- image: circleci/redis:5-alpine
- &attach_workspace commands:
attach_workspace: install-system-dependencies:
at: ~/projects/ steps:
- run:
name: Install system dependencies
command: |
sudo apt-get update
sudo apt-get install -y libicu-dev libidn11-dev libprotobuf-dev protobuf-compiler
install-ruby-dependencies:
parameters:
ruby-version:
type: string
steps:
- run:
command: |
bundle config clean 'true'
bundle config frozen 'true'
bundle config without 'development production'
name: Set bundler settings
- ruby/install-deps:
bundler-version: '2.2.29'
key: ruby<< parameters.ruby-version >>-gems-v1
wait-db:
steps:
- run:
command: dockerize -wait tcp://localhost:5432 -wait tcp://localhost:6379 -timeout 1m
name: Wait for PostgreSQL and Redis
- &persist_to_workspace jobs:
persist_to_workspace: build:
root: ~/projects/ docker:
paths: - image: cimg/ruby:2.7-node
- ./mastodon/ environment:
RAILS_ENV: test
- &restore_ruby_dependencies
restore_cache:
keys:
- v3-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }}
- v3-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-
- v3-ruby-dependencies-
- &install_steps
steps: steps:
- checkout - checkout
- *attach_workspace - install-system-dependencies
- restore_cache: - install-ruby-dependencies:
keys: ruby-version: '2.7'
- v2-node-dependencies-{{ checksum "yarn.lock" }} - node/install-packages:
- v2-node-dependencies- cache-version: v1
pkg-manager: yarn
- run: - run:
name: Install yarn dependencies
command: yarn install --frozen-lockfile
- save_cache:
key: v2-node-dependencies-{{ checksum "yarn.lock" }}
paths:
- ./node_modules/
- *persist_to_workspace
- &install_system_dependencies
run:
name: Install system dependencies
command: |
sudo apt-get update
sudo apt-get install -y libicu-dev libidn11-dev libprotobuf-dev protobuf-compiler
- &install_ruby_dependencies
steps:
- *attach_workspace
- *install_system_dependencies
- run:
name: Set Ruby version
command: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version
- *restore_ruby_dependencies
- run:
name: Set bundler settings
command: |
bundle config --local clean 'true'
bundle config --local deployment 'true'
bundle config --local with 'pam_authentication'
bundle config --local without 'development production'
bundle config --local frozen 'true'
bundle config --local path $BUNDLE_PATH
- run:
name: Install bundler dependencies
command: bundle check || (bundle install && bundle clean)
- save_cache:
key: v3-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }}
paths:
- ./.bundle/
- ./vendor/bundle/
- persist_to_workspace:
root: ~/projects/
paths:
- ./mastodon/.bundle/
- ./mastodon/vendor/bundle/
- &test_steps
parallelism: 4
steps:
- *attach_workspace
- *install_system_dependencies
- run:
name: Install FFMPEG
command: sudo apt-get install -y ffmpeg
- run:
name: Load database schema
command: ./bin/rails db:create db:schema:load db:seed
- run:
name: Run rspec in parallel
command: |
bundle exec rspec --profile 10 \
--format RspecJunitFormatter \
--out test_results/rspec.xml \
--format progress \
$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
- store_test_results:
path: test_results
jobs:
install:
<<: *defaults
<<: *install_steps
install-ruby2.7:
<<: *defaults
<<: *install_ruby_dependencies
install-ruby2.6:
<<: *defaults
docker:
- image: circleci/ruby:2.6-buster-node
environment: *ruby_environment
<<: *install_ruby_dependencies
install-ruby3.0:
<<: *defaults
docker:
- image: circleci/ruby:3.0-buster-node
environment: *ruby_environment
<<: *install_ruby_dependencies
build:
<<: *defaults
steps:
- *attach_workspace
- *install_system_dependencies
- run:
name: Precompile assets
command: ./bin/rails assets:precompile command: ./bin/rails assets:precompile
name: Precompile assets
- persist_to_workspace: - persist_to_workspace:
root: ~/projects/
paths: paths:
- ./mastodon/public/assets - public/assets
- ./mastodon/public/packs-test/ - public/packs-test
root: .
test:
parameters:
ruby-version:
type: string
executor:
name: default
ruby-version: << parameters.ruby-version >>
environment:
ALLOW_NOPAM: true
PAM_ENABLED: true
PAM_DEFAULT_SERVICE: pam_test
PAM_CONTROLLED_SERVICE: pam_test_controlled
parallelism: 4
steps:
- checkout
- install-system-dependencies
- run:
command: sudo apt-get install -y ffmpeg imagemagick libpam-dev
name: Install additional system dependencies
- run:
command: bundle config with 'pam_authentication'
name: Enable PAM authentication
- install-ruby-dependencies:
ruby-version: << parameters.ruby-version >>
- attach_workspace:
at: .
- wait-db
- run:
command: ./bin/rails db:create db:schema:load db:seed
name: Load database schema
- ruby/rspec-test
test-migrations: test-migrations:
<<: *defaults executor:
docker: name: default
- image: circleci/ruby:2.7-buster-node ruby-version: '2.7'
environment: *ruby_environment
- image: circleci/postgres:12.2
environment:
POSTGRES_USER: root
POSTGRES_HOST_AUTH_METHOD: trust
- image: circleci/redis:5-alpine
steps: steps:
- *attach_workspace - checkout
- *install_system_dependencies - install-system-dependencies
- install-ruby-dependencies:
ruby-version: '2.7'
- wait-db
- run: - run:
name: Create database
command: ./bin/rails db:create command: ./bin/rails db:create
name: Create database
- run: - run:
name: Run migrations
command: ./bin/rails db:migrate command: ./bin/rails db:migrate
name: Run migrations
test-ruby2.7:
<<: *defaults
docker:
- image: circleci/ruby:2.7-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-ruby2.6:
<<: *defaults
docker:
- image: circleci/ruby:2.6-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-ruby3.0:
<<: *defaults
docker:
- image: circleci/ruby:3.0-buster-node
environment: *ruby_environment
- image: circleci/postgres:12.2
environment:
POSTGRES_USER: root
POSTGRES_HOST_AUTH_METHOD: trust
- image: circleci/redis:5-alpine
<<: *test_steps
test-webui:
<<: *defaults
docker:
- image: circleci/node:12-buster
steps:
- *attach_workspace
- run:
name: Run jest
command: yarn test:jest
workflows: workflows:
version: 2 version: 2
build-and-test: build-and-test:
jobs: jobs:
- install - build
- install-ruby2.7: - test:
matrix:
parameters:
ruby-version:
- '2.7'
- '3.0'
name: test-ruby<< matrix.ruby-version >>
requires: requires:
- install - build
- install-ruby2.6:
requires:
- install
- install-ruby2.7
- install-ruby3.0:
requires:
- install
- install-ruby2.7
- build:
requires:
- install-ruby2.7
- test-migrations: - test-migrations:
requires: requires:
- install-ruby2.7
- test-ruby2.7:
requires:
- install-ruby2.7
- build - build
- test-ruby2.6: - node/run:
cache-version: v1
name: test-webui
pkg-manager: yarn
requires: requires:
- install-ruby2.6
- build - build
- test-ruby3.0: version: lts
requires: yarn-run: test:jest
- install-ruby3.0
- build
- test-webui:
requires:
- install

View File

@ -35,4 +35,7 @@ plugins:
enabled: true enabled: true
exclude_patterns: exclude_patterns:
- spec/ - spec/
- vendor/asset - vendor/asset/
- app/javascript/mastodon/locales/**/*.json
- config/locales/**/*.yml

View File

@ -228,6 +228,7 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io
# CAS_LOCATION_KEY='location' # CAS_LOCATION_KEY='location'
# CAS_IMAGE_KEY='image' # CAS_IMAGE_KEY='image'
# CAS_PHONE_KEY='phone' # CAS_PHONE_KEY='phone'
# CAS_SECURITY_ASSUME_EMAIL_IS_VERIFIED=true
# Optional SAML authentication (cf. omniauth-saml) # Optional SAML authentication (cf. omniauth-saml)
# SAML_ENABLED=true # SAML_ENABLED=true

2
.github/CODEOWNERS vendored
View File

@ -1,4 +1,4 @@
# CODEOWNERS for tootsuite/mastodon # CODEOWNERS for mastodon/mastodon
# Translators # Translators
# To add translator, copy these lines, replace `fr` with appropriate language code and replace `@żelipapą` with user's GitHub nickname preceded by `@` sign or e-mail address. # To add translator, copy these lines, replace `fr` with appropriate language code and replace `@żelipapą` with user's GitHub nickname preceded by `@` sign or e-mail address.

42
.github/ISSUE_TEMPLATE/1.bug_report.yml vendored Normal file
View File

@ -0,0 +1,42 @@
name: Bug Report
description: If something isn't working as expected
labels: bug
body:
- type: markdown
attributes:
value: |
Make sure that you are submitting a new bug that was not previously reported or already fixed.
Please use a concise and distinct title for the issue.
- type: input
attributes:
label: Expected behaviour
description: What should have happened?
validations:
required: true
- type: input
attributes:
label: Actual behaviour
description: What happened?
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce the problem
description: What were you trying to do?
value: |
1.
2.
3.
...
validations:
required: true
- type: textarea
attributes:
label: Specifications
description: |
What version or commit hash of Mastodon did you find this bug in?
If a front-end issue, what browser and operating systems were you using?
validations:
required: true

View File

@ -0,0 +1,21 @@
name: Feature Request
description: I have a suggestion
body:
- type: markdown
attributes:
value: |
Please use a concise and distinct title for the issue.
Consider: Could it be implemented as a 3rd party app using the REST API instead?
- type: textarea
attributes:
label: Pitch
description: Describe your idea for a feature. Make sure it has not already been suggested/implemented/turned down before.
validations:
required: true
- type: textarea
attributes:
label: Motivation
description: Why do you think this feature is needed? Who would benefit from it?
validations:
required: true

View File

@ -1,7 +1,7 @@
--- ---
name: Support name: Support
about: Ask for help with your deployment about: Ask for help with your deployment
title: DO NOT CREATE THIS ISSUE
--- ---
We primarily use GitHub as a bug and feature tracker. For usage questions, troubleshooting of deployments and other individual technical assistance, please use one of the resources below: We primarily use GitHub as a bug and feature tracker. For usage questions, troubleshooting of deployments and other individual technical assistance, please use one of the resources below:

View File

@ -1,27 +0,0 @@
---
name: Bug Report
about: If something isn't working as expected
labels: bug
---
<!-- Make sure that you are submitting a new bug that was not previously reported or already fixed -->
<!-- Please use a concise and distinct title for the issue -->
### Expected behaviour
<!-- What should have happened? -->
### Actual behaviour
<!-- What happened? -->
### Steps to reproduce the problem
<!-- What were you trying to do? -->
### Specifications
<!-- What version or commit hash of Mastodon did you find this bug in? -->
<!-- If a front-end issue, what browser and operating systems were you using? -->

View File

@ -1,16 +0,0 @@
---
name: Feature Request
about: I have a suggestion
---
<!-- Please use a concise and distinct title for the issue -->
<!-- Consider: Could it be implemented as a 3rd party app using the REST API instead? -->
### Pitch
<!-- Describe your idea for a feature. Make sure it has not already been suggested/implemented/turned down before -->
### Motivation
<!-- Why do you think this feature is needed? Who would benefit from it? -->

2
.nvmrc
View File

@ -1 +1 @@
12 14

View File

@ -1 +1 @@
2.7.2 2.7.4

View File

@ -1,7 +1,7 @@
Authors Authors
======= =======
Mastodon is available on [GitHub](https://github.com/tootsuite/mastodon) Mastodon is available on [GitHub](https://github.com/mastodon/mastodon)
and provided thanks to the work of the following contributors: and provided thanks to the work of the following contributors:
* [Gargron](https://github.com/Gargron) * [Gargron](https://github.com/Gargron)
@ -719,7 +719,7 @@ and provided thanks to the work of the following contributors:
* [西小倉宏信](mailto:nishiko@mindia.jp) * [西小倉宏信](mailto:nishiko@mindia.jp)
* [雨宮美羽](mailto:k737566@gmail.com) * [雨宮美羽](mailto:k737566@gmail.com)
This document is provided for informational purposes only. Since it is only updated once per release, the version you are looking at may be currently out of date. To see the full list of contributors, consider looking at the [git history](https://github.com/tootsuite/mastodon/graphs/contributors) instead. This document is provided for informational purposes only. Since it is only updated once per release, the version you are looking at may be currently out of date. To see the full list of contributors, consider looking at the [git history](https://github.com/mastodon/mastodon/graphs/contributors) instead.
## Translators ## Translators

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,7 @@ If your contributions are accepted into Mastodon, you can request to be paid thr
## Bug reports ## Bug reports
Bug reports and feature suggestions must use descriptive and concise titles and be submitted to [GitHub Issues](https://github.com/tootsuite/mastodon/issues). Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected. Bug reports and feature suggestions must use descriptive and concise titles and be submitted to [GitHub Issues](https://github.com/mastodon/mastodon/issues). Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected.
## Translations ## Translations
@ -44,4 +44,4 @@ It is not always possible to phrase every change in such a manner, but it is des
## Documentation ## Documentation
The [Mastodon documentation](https://docs.joinmastodon.org) is a statically generated site. You can [submit merge requests to tootsuite/documentation](https://github.com/tootsuite/documentation). The [Mastodon documentation](https://docs.joinmastodon.org) is a statically generated site. You can [submit merge requests to mastodon/documentation](https://github.com/mastodon/documentation).

View File

@ -3,8 +3,8 @@ FROM ubuntu:20.04 as build-dep
# Use bash for the shell # Use bash for the shell
SHELL ["/bin/bash", "-c"] SHELL ["/bin/bash", "-c"]
# Install Node v12 (LTS) # Install Node v14 (LTS)
ENV NODE_VER="12.21.0" ENV NODE_VER="14.17.6"
RUN ARCH= && \ RUN ARCH= && \
dpkgArch="$(dpkg --print-architecture)" && \ dpkgArch="$(dpkg --print-architecture)" && \
case "${dpkgArch##*-}" in \ case "${dpkgArch##*-}" in \
@ -26,7 +26,7 @@ RUN ARCH= && \
mv node-v$NODE_VER-linux-$ARCH /opt/node mv node-v$NODE_VER-linux-$ARCH /opt/node
# Install Ruby # Install Ruby
ENV RUBY_VER="2.7.2" ENV RUBY_VER="2.7.4"
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential \ apt-get install -y --no-install-recommends build-essential \
bison libyaml-dev libgdbm-dev libreadline-dev libjemalloc-dev \ bison libyaml-dev libgdbm-dev libreadline-dev libjemalloc-dev \
@ -56,6 +56,7 @@ COPY Gemfile* package.json yarn.lock /opt/mastodon/
RUN cd /opt/mastodon && \ RUN cd /opt/mastodon && \
bundle config set deployment 'true' && \ bundle config set deployment 'true' && \
bundle config set without 'development test' && \ bundle config set without 'development test' && \
bundle config set silence_root_warning true && \
bundle install -j"$(nproc)" && \ bundle install -j"$(nproc)" && \
yarn install --pure-lockfile yarn install --pure-lockfile

44
Gemfile
View File

@ -5,8 +5,8 @@ ruby '>= 2.5.0', '< 3.1.0'
gem 'pkg-config', '~> 1.4' gem 'pkg-config', '~> 1.4'
gem 'puma', '~> 5.3' gem 'puma', '~> 5.5'
gem 'rails', '~> 6.1.3' gem 'rails', '~> 6.1.4'
gem 'sprockets', '~> 3.7.2' gem 'sprockets', '~> 3.7.2'
gem 'thor', '~> 1.1' gem 'thor', '~> 1.1'
gem 'rack', '~> 2.2.3' gem 'rack', '~> 2.2.3'
@ -17,15 +17,15 @@ gem 'makara', '~> 0.5'
gem 'pghero', '~> 2.8' gem 'pghero', '~> 2.8'
gem 'dotenv-rails', '~> 2.7' gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.95', require: false gem 'aws-sdk-s3', '~> 1.103', 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 'kt-paperclip', '~> 7.0'
gem 'blurhash', '~> 0.1' gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10' gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.7' gem 'addressable', '~> 2.8'
gem 'bootsnap', '~> 1.6.0', require: false gem 'bootsnap', '~> 1.9.1', require: false
gem 'browser' gem 'browser'
gem 'charlock_holmes', '~> 0.7.7' gem 'charlock_holmes', '~> 0.7.7'
gem 'iso-639' gem 'iso-639'
@ -53,19 +53,18 @@ gem 'fastimage'
gem 'hiredis', '~> 0.6' gem 'hiredis', '~> 0.6'
gem 'redis-namespace', '~> 1.8' gem 'redis-namespace', '~> 1.8'
gem 'htmlentities', '~> 4.3' gem 'htmlentities', '~> 4.3'
gem 'http', '~> 4.4' gem 'http', '~> 5.0'
gem 'http_accept_language', '~> 2.1' gem 'http_accept_language', '~> 2.1'
gem 'httplog', '~> 1.5.0' gem 'httplog', '~> 1.5.0'
gem 'idn-ruby', require: 'idn' gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.2' 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 'nokogiri', '~> 1.11' gem 'nokogiri', '~> 1.12'
gem 'nsa', '~> 0.2' gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.11' gem 'oj', '~> 3.13'
gem 'ox', '~> 2.14' gem 'ox', '~> 2.14'
gem 'parslet' gem 'parslet'
gem 'parallel', '~> 1.20'
gem 'posix-spawn' gem 'posix-spawn'
gem 'pundit', '~> 2.1' gem 'pundit', '~> 2.1'
gem 'premailer-rails' gem 'premailer-rails'
@ -73,15 +72,15 @@ gem 'rack-attack', '~> 6.5'
gem 'rack-cors', '~> 1.1', require: 'rack/cors' gem 'rack-cors', '~> 1.1', require: 'rack/cors'
gem 'rails-i18n', '~> 6.0' gem 'rails-i18n', '~> 6.0'
gem 'rails-settings-cached', '~> 0.6' gem 'rails-settings-cached', '~> 0.6'
gem 'redis', '~> 4.2', require: ['redis', 'redis/connection/hiredis'] gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis']
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'rqrcode', '~> 2.0' gem 'rqrcode', '~> 2.1'
gem 'ruby-progressbar', '~> 1.11' gem 'ruby-progressbar', '~> 1.11'
gem 'sanitize', '~> 5.2' gem 'sanitize', '~> 6.0'
gem 'scenic', '~> 1.5' gem 'scenic', '~> 1.5'
gem 'sidekiq', '~> 6.2' gem 'sidekiq', '~> 6.2'
gem 'sidekiq-scheduler', '~> 3.0' gem 'sidekiq-scheduler', '~> 3.1'
gem 'sidekiq-unique-jobs', '~> 7.0' gem 'sidekiq-unique-jobs', '~> 7.1'
gem 'sidekiq-bulk', '~>0.2.0' gem 'sidekiq-bulk', '~>0.2.0'
gem 'simple-navigation', '~> 4.3' gem 'simple-navigation', '~> 4.3'
gem 'simple_form', '~> 5.1' gem 'simple_form', '~> 5.1'
@ -115,13 +114,12 @@ end
group :test do group :test do
gem 'capybara', '~> 3.35' gem 'capybara', '~> 3.35'
gem 'climate_control', '~> 0.2' gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.18' gem 'faker', '~> 2.19'
gem 'microformats', '~> 4.2' gem 'microformats', '~> 4.2'
gem 'rails-controller-testing', '~> 1.0' gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.1' gem 'rspec-sidekiq', '~> 3.1'
gem 'simplecov', '~> 0.21', require: false gem 'simplecov', '~> 0.21', require: false
gem 'webmock', '~> 3.13' gem 'webmock', '~> 3.14'
gem 'parallel_tests', '~> 3.7'
gem 'rspec_junit_formatter', '~> 0.4' gem 'rspec_junit_formatter', '~> 0.4'
end end
@ -134,10 +132,10 @@ group :development do
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', '~> 1.15', require: false gem 'rubocop', '~> 1.22', require: false
gem 'rubocop-rails', '~> 2.10', require: false gem 'rubocop-rails', '~> 2.12', require: false
gem 'brakeman', '~> 5.0', require: false gem 'brakeman', '~> 5.1', require: false
gem 'bundler-audit', '~> 0.8', require: false gem 'bundler-audit', '~> 0.9', require: false
gem 'capistrano', '~> 3.16' gem 'capistrano', '~> 3.16'
gem 'capistrano-rails', '~> 1.6' gem 'capistrano-rails', '~> 1.6'
@ -155,5 +153,3 @@ gem 'concurrent-ruby', require: false
gem 'connection_pool', require: false gem 'connection_pool', require: false
gem 'xorcist', '~> 1.1' gem 'xorcist', '~> 1.1'
gem 'resolv', '~> 0.1.0'

View File

@ -1,40 +1,40 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (6.1.3.2) actioncable (6.1.4.1)
actionpack (= 6.1.3.2) actionpack (= 6.1.4.1)
activesupport (= 6.1.3.2) activesupport (= 6.1.4.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (6.1.3.2) actionmailbox (6.1.4.1)
actionpack (= 6.1.3.2) actionpack (= 6.1.4.1)
activejob (= 6.1.3.2) activejob (= 6.1.4.1)
activerecord (= 6.1.3.2) activerecord (= 6.1.4.1)
activestorage (= 6.1.3.2) activestorage (= 6.1.4.1)
activesupport (= 6.1.3.2) activesupport (= 6.1.4.1)
mail (>= 2.7.1) mail (>= 2.7.1)
actionmailer (6.1.3.2) actionmailer (6.1.4.1)
actionpack (= 6.1.3.2) actionpack (= 6.1.4.1)
actionview (= 6.1.3.2) actionview (= 6.1.4.1)
activejob (= 6.1.3.2) activejob (= 6.1.4.1)
activesupport (= 6.1.3.2) activesupport (= 6.1.4.1)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (6.1.3.2) actionpack (6.1.4.1)
actionview (= 6.1.3.2) actionview (= 6.1.4.1)
activesupport (= 6.1.3.2) activesupport (= 6.1.4.1)
rack (~> 2.0, >= 2.0.9) rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.3.2) actiontext (6.1.4.1)
actionpack (= 6.1.3.2) actionpack (= 6.1.4.1)
activerecord (= 6.1.3.2) activerecord (= 6.1.4.1)
activestorage (= 6.1.3.2) activestorage (= 6.1.4.1)
activesupport (= 6.1.3.2) activesupport (= 6.1.4.1)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (6.1.3.2) actionview (6.1.4.1)
activesupport (= 6.1.3.2) activesupport (= 6.1.4.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
@ -45,28 +45,28 @@ GEM
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3) jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
active_record_query_trace (1.8) active_record_query_trace (1.8)
activejob (6.1.3.2) activejob (6.1.4.1)
activesupport (= 6.1.3.2) activesupport (= 6.1.4.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (6.1.3.2) activemodel (6.1.4.1)
activesupport (= 6.1.3.2) activesupport (= 6.1.4.1)
activerecord (6.1.3.2) activerecord (6.1.4.1)
activemodel (= 6.1.3.2) activemodel (= 6.1.4.1)
activesupport (= 6.1.3.2) activesupport (= 6.1.4.1)
activestorage (6.1.3.2) activestorage (6.1.4.1)
actionpack (= 6.1.3.2) actionpack (= 6.1.4.1)
activejob (= 6.1.3.2) activejob (= 6.1.4.1)
activerecord (= 6.1.3.2) activerecord (= 6.1.4.1)
activesupport (= 6.1.3.2) activesupport (= 6.1.4.1)
marcel (~> 1.0.0) marcel (~> 1.0.0)
mini_mime (~> 1.0.2) mini_mime (>= 1.1.0)
activesupport (6.1.3.2) activesupport (6.1.4.1)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
tzinfo (~> 2.0) tzinfo (~> 2.0)
zeitwerk (~> 2.3) zeitwerk (~> 2.3)
addressable (2.7.0) addressable (2.8.0)
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)
@ -78,44 +78,44 @@ GEM
attr_encrypted (3.1.0) attr_encrypted (3.1.0)
encryptor (~> 3.0.0) encryptor (~> 3.0.0)
awrence (1.1.1) awrence (1.1.1)
aws-eventstream (1.1.1) aws-eventstream (1.2.0)
aws-partitions (1.465.0) aws-partitions (1.503.0)
aws-sdk-core (3.114.0) aws-sdk-core (3.121.0)
aws-eventstream (~> 1, >= 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.43.0) aws-sdk-kms (1.48.0)
aws-sdk-core (~> 3, >= 3.112.0) aws-sdk-core (~> 3, >= 3.120.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.95.1) aws-sdk-s3 (1.103.0)
aws-sdk-core (~> 3, >= 3.112.0) aws-sdk-core (~> 3, >= 3.120.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.4)
aws-sigv4 (1.2.3) aws-sigv4 (1.4.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
bcrypt (3.1.16) bcrypt (3.1.16)
better_errors (2.9.1) better_errors (2.9.1)
coderay (>= 1.0.0) coderay (>= 1.0.0)
erubi (>= 1.0.0) erubi (>= 1.0.0)
rack (>= 0.9.0) rack (>= 0.9.0)
bindata (2.4.8) bindata (2.4.10)
binding_of_caller (1.0.0) binding_of_caller (1.0.0)
debug_inspector (>= 0.0.1) debug_inspector (>= 0.0.1)
blurhash (0.1.5) blurhash (0.1.5)
ffi (~> 1.14) ffi (~> 1.14)
bootsnap (1.6.0) bootsnap (1.9.1)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (5.0.1) brakeman (5.1.1)
browser (4.2.0) browser (4.2.0)
brpoplpush-redis_script (0.1.2) brpoplpush-redis_script (0.1.2)
concurrent-ruby (~> 1.0, >= 1.0.5) concurrent-ruby (~> 1.0, >= 1.0.5)
redis (>= 1.0, <= 5.0) redis (>= 1.0, <= 5.0)
builder (3.2.4) builder (3.2.4)
bullet (6.1.4) bullet (6.1.5)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.11) uniform_notifier (~> 1.11)
bundler-audit (0.8.0) bundler-audit (0.9.0.1)
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (~> 1.0) thor (~> 1.0)
byebug (11.1.3) byebug (11.1.3)
@ -156,7 +156,7 @@ GEM
climate_control (0.2.0) climate_control (0.2.0)
coderay (1.1.3) coderay (1.1.3)
color_diff (0.1) color_diff (0.1)
concurrent-ruby (1.1.8) concurrent-ruby (1.1.9)
connection_pool (2.2.5) connection_pool (2.2.5)
cose (1.0.0) cose (1.0.0)
cbor (~> 0.5.9) cbor (~> 0.5.9)
@ -173,7 +173,7 @@ GEM
railties (>= 4.1.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
devise-two-factor (4.0.0) devise-two-factor (4.0.1)
activesupport (< 6.2) activesupport (< 6.2)
attr_encrypted (>= 1.3, < 4, != 2) attr_encrypted (>= 1.3, < 4, != 2)
devise (~> 4.0) devise (~> 4.0)
@ -188,7 +188,7 @@ GEM
docile (1.3.4) docile (1.3.4)
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.5.1) doorkeeper (5.5.4)
railties (>= 5) railties (>= 5)
dotenv (2.7.6) dotenv (2.7.6)
dotenv-rails (2.7.6) dotenv-rails (2.7.6)
@ -211,16 +211,16 @@ GEM
tzinfo tzinfo
excon (0.76.0) excon (0.76.0)
fabrication (2.22.0) fabrication (2.22.0)
faker (2.18.0) faker (2.19.0)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
faraday (1.3.0) faraday (1.3.0)
faraday-net_http (~> 1.0) faraday-net_http (~> 1.0)
multipart-post (>= 1.2, < 3) multipart-post (>= 1.2, < 3)
ruby2_keywords ruby2_keywords
faraday-net_http (1.0.1) faraday-net_http (1.0.1)
fast_blank (1.0.0) fast_blank (1.0.1)
fastimage (2.2.3) fastimage (2.2.5)
ffi (1.15.0) ffi (1.15.4)
ffi-compiler (1.0.1) ffi-compiler (1.0.1)
ffi (>= 1.0.0) ffi (>= 1.0.0)
rake rake
@ -237,14 +237,14 @@ 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.9) fugit (1.4.5)
et-orbi (~> 1.1, >= 1.1.8) et-orbi (~> 1.1, >= 1.1.8)
raabro (~> 1.3) raabro (~> 1.4)
fuubar (2.5.1) fuubar (2.5.1)
rspec-core (~> 3.0) rspec-core (~> 3.0)
ruby-progressbar (~> 1.4) ruby-progressbar (~> 1.4)
globalid (0.4.2) globalid (0.5.2)
activesupport (>= 4.2.0) activesupport (>= 5.0)
hamlit (2.13.0) hamlit (2.13.0)
temple (>= 0.8.2) temple (>= 0.8.2)
thor thor
@ -262,16 +262,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.4.1) http (5.0.4)
addressable (~> 2.3) addressable (~> 2.8)
http-cookie (~> 1.0) http-cookie (~> 1.0)
http-form_data (~> 2.2) http-form_data (~> 2.2)
http-parser (~> 1.2.0) llhttp-ffi (~> 0.4.0)
http-cookie (1.0.3) http-cookie (1.0.4)
domain_name (~> 0.5) domain_name (~> 0.5)
http-form_data (2.3.0) http-form_data (2.3.0)
http-parser (1.2.1)
ffi-compiler (>= 1.0, < 2.0)
http_accept_language (2.1.1) http_accept_language (2.1.1)
httplog (1.5.0) httplog (1.5.0)
rack (>= 1.0) rack (>= 1.0)
@ -288,20 +286,20 @@ GEM
rails-i18n rails-i18n
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1) terminal-table (>= 1.5.1)
idn-ruby (0.1.0) idn-ruby (0.1.2)
ipaddress (0.8.3) ipaddress (0.8.3)
iso-639 (0.3.5) iso-639 (0.3.5)
jmespath (1.4.0) jmespath (1.4.0)
json (2.5.1) json (2.5.1)
json-canonicalization (0.2.1) json-canonicalization (0.2.1)
json-ld (3.1.9) json-ld (3.1.10)
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)
multi_json (~> 1.14) multi_json (~> 1.14)
rack (~> 2.0) rack (~> 2.0)
rdf (~> 3.1) rdf (~> 3.1)
json-ld-preloaded (3.1.5) json-ld-preloaded (3.1.6)
json-ld (~> 3.1) json-ld (~> 3.1)
rdf (~> 3.1) rdf (~> 3.1)
jsonapi-renderer (0.2.2) jsonapi-renderer (0.2.2)
@ -318,27 +316,36 @@ GEM
activerecord activerecord
kaminari-core (= 1.2.1) kaminari-core (= 1.2.1)
kaminari-core (1.2.1) kaminari-core (1.2.1)
kt-paperclip (7.0.1)
activemodel (>= 4.2.0)
activesupport (>= 4.2.0)
marcel (~> 1.0.1)
mime-types
terrapin (~> 0.6.0)
launchy (2.5.0) launchy (2.5.0)
addressable (~> 2.7) 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.1)
actionmailer (>= 3.2) actionmailer (>= 3.2)
letter_opener (~> 1.0) letter_opener (~> 1.0)
railties (>= 3.2) railties (>= 3.2)
link_header (0.0.8) link_header (0.0.8)
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
lograge (0.11.2) lograge (0.11.2)
actionpack (>= 4) actionpack (>= 4)
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.9.1) loofah (2.12.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)
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
makara (0.5.0) makara (0.5.1)
activerecord (>= 3.0.0) activerecord (>= 5.2.0)
marcel (1.0.1) marcel (1.0.1)
mario-redis-lock (1.2.1) mario-redis-lock (1.2.1)
redis (>= 3.0.5) redis (>= 3.0.5)
@ -349,37 +356,27 @@ GEM
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.2020.0512) mime-types-data (3.2021.0901)
mimemagic (0.3.10) mini_mime (1.1.2)
nokogiri (~> 1) mini_portile2 (2.6.1)
rake
mini_mime (1.0.3)
mini_portile2 (2.5.2)
net-ftp (~> 0.1)
minitest (5.14.4) minitest (5.14.4)
msgpack (1.4.2) msgpack (1.4.2)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.1.1) multipart-post (2.1.1)
net-ftp (0.1.2)
net-protocol
time
net-ldap (0.17.0) net-ldap (0.17.0)
net-protocol (0.1.0)
net-scp (3.0.0) net-scp (3.0.0)
net-ssh (>= 2.6.5, < 7.0.0) net-ssh (>= 2.6.5, < 7.0.0)
net-ssh (6.1.0) net-ssh (6.1.0)
nio4r (2.5.7) nio4r (2.5.8)
nokogiri (1.11.6) nokogiri (1.12.5)
mini_portile2 (~> 2.5.0) mini_portile2 (~> 2.6.1)
racc (~> 1.4) racc (~> 1.4)
nokogumbo (2.0.4)
nokogiri (~> 1.8, >= 1.8.4)
nsa (0.2.8) nsa (0.2.8)
activesupport (>= 4.2, < 7) activesupport (>= 4.2, < 7)
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.11.5) oj (3.13.9)
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)
@ -396,17 +393,9 @@ GEM
openssl (2.2.0) openssl (2.2.0)
openssl-signature_algorithm (0.4.0) openssl-signature_algorithm (0.4.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ox (2.14.4) ox (2.14.5)
paperclip (6.0.0) parallel (1.21.0)
activemodel (>= 4.2.0) parser (3.0.2.0)
activesupport (>= 4.2.0)
mime-types
mimemagic (~> 0.3.0)
terrapin (~> 0.6.0)
parallel (1.20.1)
parallel_tests (3.7.0)
parallel
parser (3.0.1.1)
ast (~> 2.4.1) ast (~> 2.4.1)
parslet (2.0.0) parslet (2.0.0)
pastel (0.8.0) pastel (0.8.0)
@ -433,35 +422,35 @@ GEM
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.6) public_suffix (4.0.6)
puma (5.3.2) puma (5.5.2)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.1.0) pundit (2.1.1)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.3.3) raabro (1.4.0)
racc (1.5.2) racc (1.5.2)
rack (2.2.3) rack (2.2.3)
rack-attack (6.5.0) rack-attack (6.5.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)
rack-proxy (0.6.5) rack-proxy (0.7.0)
rack rack
rack-test (1.1.0) rack-test (1.1.0)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rails (6.1.3.2) rails (6.1.4.1)
actioncable (= 6.1.3.2) actioncable (= 6.1.4.1)
actionmailbox (= 6.1.3.2) actionmailbox (= 6.1.4.1)
actionmailer (= 6.1.3.2) actionmailer (= 6.1.4.1)
actionpack (= 6.1.3.2) actionpack (= 6.1.4.1)
actiontext (= 6.1.3.2) actiontext (= 6.1.4.1)
actionview (= 6.1.3.2) actionview (= 6.1.4.1)
activejob (= 6.1.3.2) activejob (= 6.1.4.1)
activemodel (= 6.1.3.2) activemodel (= 6.1.4.1)
activerecord (= 6.1.3.2) activerecord (= 6.1.4.1)
activestorage (= 6.1.3.2) activestorage (= 6.1.4.1)
activesupport (= 6.1.3.2) activesupport (= 6.1.4.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 6.1.3.2) railties (= 6.1.4.1)
sprockets-rails (>= 2.0.0) sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.5) rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1) actionpack (>= 5.0.1.rc1)
@ -470,43 +459,42 @@ GEM
rails-dom-testing (2.0.3) rails-dom-testing (2.0.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.3.0) rails-html-sanitizer (1.4.2)
loofah (~> 2.3) loofah (~> 2.3)
rails-i18n (6.0.0) rails-i18n (6.0.0)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 7) railties (>= 6.0.0, < 7)
rails-settings-cached (0.6.6) rails-settings-cached (0.6.6)
rails (>= 4.2.0) rails (>= 4.2.0)
railties (6.1.3.2) railties (6.1.4.1)
actionpack (= 6.1.3.2) actionpack (= 6.1.4.1)
activesupport (= 6.1.3.2) activesupport (= 6.1.4.1)
method_source method_source
rake (>= 0.8.7) rake (>= 0.13)
thor (~> 1.0) thor (~> 1.0)
rainbow (3.0.0) rainbow (3.0.0)
rake (13.0.3) rake (13.0.6)
rdf (3.1.13) rdf (3.1.15)
hamster (~> 3.0) hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.4.0) rdf-normalize (0.4.0)
rdf (~> 3.1) rdf (~> 3.1)
redis (4.2.5) redis (4.5.1)
redis-namespace (1.8.1) redis-namespace (1.8.1)
redis (>= 3.0.4) redis (>= 3.0.4)
regexp_parser (2.1.1) regexp_parser (2.1.1)
request_store (1.5.0) request_store (1.5.0)
rack (>= 1.4) rack (>= 1.4)
resolv (0.1.0)
responders (3.0.1) responders (3.0.1)
actionpack (>= 5.0) actionpack (>= 5.0)
railties (>= 5.0) railties (>= 5.0)
rexml (3.2.5) rexml (3.2.5)
rotp (6.2.0) rotp (6.2.0)
rpam2 (4.0.2) rpam2 (4.0.2)
rqrcode (2.0.0) rqrcode (2.1.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 1.0) rqrcode_core (~> 1.0)
rqrcode_core (1.0.0) rqrcode_core (1.2.0)
rspec-core (3.10.1) rspec-core (3.10.1)
rspec-support (~> 3.10.0) rspec-support (~> 3.10.0)
rspec-expectations (3.10.1) rspec-expectations (3.10.1)
@ -515,7 +503,7 @@ GEM
rspec-mocks (3.10.2) rspec-mocks (3.10.2)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.10.0) rspec-support (~> 3.10.0)
rspec-rails (5.0.1) rspec-rails (5.0.2)
actionpack (>= 5.2) actionpack (>= 5.2)
activesupport (>= 5.2) activesupport (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
@ -529,56 +517,56 @@ GEM
rspec-support (3.10.2) rspec-support (3.10.2)
rspec_junit_formatter (0.4.1) rspec_junit_formatter (0.4.1)
rspec-core (>= 2, < 4, != 2.12.0) rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.15.0) rubocop (1.22.1)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.0.0.0) parser (>= 3.0.0.0)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml rexml
rubocop-ast (>= 1.5.0, < 2.0) rubocop-ast (>= 1.12.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0) unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.5.0) rubocop-ast (1.12.0)
parser (>= 3.0.1.1) parser (>= 3.0.1.1)
rubocop-rails (2.10.1) rubocop-rails (2.12.4)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0) rubocop (>= 1.7.0, < 2.0)
ruby-progressbar (1.11.0) ruby-progressbar (1.11.0)
ruby-saml (1.11.0) ruby-saml (1.13.0)
nokogiri (>= 1.5.10) nokogiri (>= 1.10.5)
rexml
ruby2_keywords (0.0.4) ruby2_keywords (0.0.4)
rufus-scheduler (3.6.0) rufus-scheduler (3.7.0)
fugit (~> 1.1, >= 1.1.6) fugit (~> 1.1, >= 1.1.6)
safety_net_attestation (0.4.0) safety_net_attestation (0.4.0)
jwt (~> 2.0) jwt (~> 2.0)
sanitize (5.2.3) sanitize (6.0.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.8.0) nokogiri (>= 1.12.0)
nokogumbo (~> 2.0)
scenic (1.5.4) scenic (1.5.4)
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
securecompare (1.0.0) securecompare (1.0.0)
semantic_range (3.0.0) semantic_range (3.0.0)
sidekiq (6.2.1) sidekiq (6.2.2)
connection_pool (>= 2.2.2) connection_pool (>= 2.2.2)
rack (~> 2.0) rack (~> 2.0)
redis (>= 4.2.0) redis (>= 4.2.0)
sidekiq-bulk (0.2.0) sidekiq-bulk (0.2.0)
sidekiq sidekiq
sidekiq-scheduler (3.0.1) sidekiq-scheduler (3.1.0)
e2mmap e2mmap
redis (>= 3, < 5) redis (>= 3, < 5)
rufus-scheduler (~> 3.2) rufus-scheduler (~> 3.2)
sidekiq (>= 3) sidekiq (>= 3)
thwait thwait
tilt (>= 1.4.0) tilt (>= 1.4.0)
sidekiq-unique-jobs (7.0.11) sidekiq-unique-jobs (7.1.8)
brpoplpush-redis_script (> 0.1.1, <= 2.0.0) brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
concurrent-ruby (~> 1.0, >= 1.0.5) concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 5.0, < 7.0) sidekiq (>= 5.0, < 8.0)
thor (>= 0.20, < 2.0) thor (>= 0.20, < 3.0)
simple-navigation (4.3.0) simple-navigation (4.3.0)
activesupport (>= 2.3.2) activesupport (>= 2.3.2)
simple_form (5.1.0) simple_form (5.1.0)
@ -603,7 +591,7 @@ GEM
stackprof (0.2.17) stackprof (0.2.17)
statsd-ruby (1.5.0) statsd-ruby (1.5.0)
stoplight (2.2.1) stoplight (2.2.1)
strong_migrations (0.7.6) strong_migrations (0.7.8)
activerecord (>= 5) activerecord (>= 5)
temple (0.8.2) temple (0.8.2)
terminal-table (3.0.0) terminal-table (3.0.0)
@ -614,7 +602,6 @@ GEM
thwait (0.2.0) thwait (0.2.0)
e2mmap e2mmap
tilt (2.0.10) tilt (2.0.10)
time (0.1.0)
tpm-key_attestation (0.9.0) tpm-key_attestation (0.9.0)
bindata (~> 2.4) bindata (~> 2.4)
openssl-signature_algorithm (~> 0.4.0) openssl-signature_algorithm (~> 0.4.0)
@ -633,13 +620,13 @@ GEM
unf (~> 0.1.0) unf (~> 0.1.0)
tzinfo (2.0.4) tzinfo (2.0.4)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
tzinfo-data (1.2021.1) tzinfo-data (1.2021.4)
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.7) unf_ext (0.0.8)
unicode-display_width (1.7.0) unicode-display_width (1.8.0)
uniform_notifier (1.14.1) uniform_notifier (1.14.2)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
webauthn (3.0.0.alpha1) webauthn (3.0.0.alpha1)
@ -652,11 +639,11 @@ GEM
safety_net_attestation (~> 0.4.0) safety_net_attestation (~> 0.4.0)
securecompare (~> 1.0) securecompare (~> 1.0)
tpm-key_attestation (~> 0.9.0) tpm-key_attestation (~> 0.9.0)
webmock (3.13.0) webmock (3.14.0)
addressable (>= 2.3.6) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webpacker (5.4.0) webpacker (5.4.3)
activesupport (>= 5.2) activesupport (>= 5.2)
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
@ -664,7 +651,7 @@ GEM
webpush (0.3.8) webpush (0.3.8)
hkdf (~> 0.2) hkdf (~> 0.2)
jwt (~> 2.0) jwt (~> 2.0)
websocket-driver (0.7.3) websocket-driver (0.7.5)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
wisper (2.0.1) wisper (2.0.1)
@ -679,17 +666,17 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
active_model_serializers (~> 0.10) active_model_serializers (~> 0.10)
active_record_query_trace (~> 1.8) active_record_query_trace (~> 1.8)
addressable (~> 2.7) addressable (~> 2.8)
annotate (~> 3.1) annotate (~> 3.1)
aws-sdk-s3 (~> 1.95) aws-sdk-s3 (~> 1.103)
better_errors (~> 2.9) better_errors (~> 2.9)
binding_of_caller (~> 1.0) binding_of_caller (~> 1.0)
blurhash (~> 0.1) blurhash (~> 0.1)
bootsnap (~> 1.6.0) bootsnap (~> 1.9.1)
brakeman (~> 5.0) brakeman (~> 5.1)
browser browser
bullet (~> 6.1) bullet (~> 6.1)
bundler-audit (~> 0.8) bundler-audit (~> 0.9)
capistrano (~> 3.16) capistrano (~> 3.16)
capistrano-rails (~> 1.6) capistrano-rails (~> 1.6)
capistrano-rbenv (~> 2.2) capistrano-rbenv (~> 2.2)
@ -710,7 +697,7 @@ DEPENDENCIES
dotenv-rails (~> 2.7) dotenv-rails (~> 2.7)
ed25519 (~> 1.2) ed25519 (~> 1.2)
fabrication (~> 2.22) fabrication (~> 2.22)
faker (~> 2.18) faker (~> 2.19)
fast_blank (~> 1.0) fast_blank (~> 1.0)
fastimage fastimage
fog-core (<= 2.1.0) fog-core (<= 2.1.0)
@ -719,7 +706,7 @@ DEPENDENCIES
hamlit-rails (~> 0.2) hamlit-rails (~> 0.2)
hiredis (~> 0.6) hiredis (~> 0.6)
htmlentities (~> 4.3) htmlentities (~> 4.3)
http (~> 4.4) http (~> 5.0)
http_accept_language (~> 2.1) http_accept_language (~> 2.1)
httplog (~> 1.5.0) httplog (~> 1.5.0)
i18n-tasks (~> 0.9) i18n-tasks (~> 0.9)
@ -728,6 +715,7 @@ DEPENDENCIES
json-ld json-ld
json-ld-preloaded (~> 3.1) json-ld-preloaded (~> 3.1)
kaminari (~> 1.2) kaminari (~> 1.2)
kt-paperclip (~> 7.0)
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)
@ -738,17 +726,14 @@ DEPENDENCIES
microformats (~> 4.2) microformats (~> 4.2)
mime-types (~> 3.3.1) mime-types (~> 3.3.1)
net-ldap (~> 0.17) net-ldap (~> 0.17)
nokogiri (~> 1.11) nokogiri (~> 1.12)
nsa (~> 0.2) nsa (~> 0.2)
oj (~> 3.11) oj (~> 3.13)
omniauth (~> 1.9) omniauth (~> 1.9)
omniauth-cas (~> 2.0) omniauth-cas (~> 2.0)
omniauth-rails_csrf_protection (~> 0.1) omniauth-rails_csrf_protection (~> 0.1)
omniauth-saml (~> 1.10) omniauth-saml (~> 1.10)
ox (~> 2.14) ox (~> 2.14)
paperclip (~> 6.0)
parallel (~> 1.20)
parallel_tests (~> 3.7)
parslet parslet
pg (~> 1.2) pg (~> 1.2)
pghero (~> 2.8) pghero (~> 2.8)
@ -758,32 +743,31 @@ DEPENDENCIES
private_address_check (~> 0.5) private_address_check (~> 0.5)
pry-byebug (~> 3.9) pry-byebug (~> 3.9)
pry-rails (~> 0.3) pry-rails (~> 0.3)
puma (~> 5.3) puma (~> 5.5)
pundit (~> 2.1) pundit (~> 2.1)
rack (~> 2.2.3) rack (~> 2.2.3)
rack-attack (~> 6.5) rack-attack (~> 6.5)
rack-cors (~> 1.1) rack-cors (~> 1.1)
rails (~> 6.1.3) rails (~> 6.1.4)
rails-controller-testing (~> 1.0) rails-controller-testing (~> 1.0)
rails-i18n (~> 6.0) rails-i18n (~> 6.0)
rails-settings-cached (~> 0.6) rails-settings-cached (~> 0.6)
rdf-normalize (~> 0.4) rdf-normalize (~> 0.4)
redis (~> 4.2) redis (~> 4.5)
redis-namespace (~> 1.8) redis-namespace (~> 1.8)
resolv (~> 0.1.0) rqrcode (~> 2.1)
rqrcode (~> 2.0)
rspec-rails (~> 5.0) rspec-rails (~> 5.0)
rspec-sidekiq (~> 3.1) rspec-sidekiq (~> 3.1)
rspec_junit_formatter (~> 0.4) rspec_junit_formatter (~> 0.4)
rubocop (~> 1.15) rubocop (~> 1.22)
rubocop-rails (~> 2.10) rubocop-rails (~> 2.12)
ruby-progressbar (~> 1.11) ruby-progressbar (~> 1.11)
sanitize (~> 5.2) sanitize (~> 6.0)
scenic (~> 1.5) scenic (~> 1.5)
sidekiq (~> 6.2) sidekiq (~> 6.2)
sidekiq-bulk (~> 0.2.0) sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 3.0) sidekiq-scheduler (~> 3.1)
sidekiq-unique-jobs (~> 7.0) sidekiq-unique-jobs (~> 7.1)
simple-navigation (~> 4.3) simple-navigation (~> 4.3)
simple_form (~> 5.1) simple_form (~> 5.1)
simplecov (~> 0.21) simplecov (~> 0.21)
@ -797,7 +781,7 @@ DEPENDENCIES
twitter-text (~> 3.1.0) twitter-text (~> 3.1.0)
tzinfo-data (~> 1.2021) tzinfo-data (~> 1.2021)
webauthn (~> 3.0.0.alpha1) webauthn (~> 3.0.0.alpha1)
webmock (~> 3.13) webmock (~> 3.14)
webpacker (~> 5.4) webpacker (~> 5.4)
webpush (~> 0.3) webpush (~> 0.3)
xorcist (~> 1.1) xorcist (~> 1.1)

View File

@ -1,15 +1,15 @@
![Mastodon](https://i.imgur.com/NhZc40l.png) - [mastodon.CipherBliss.com](https://mastodon.CipherBliss.com) version ![Mastodon](https://i.imgur.com/NhZc40l.png) - [mastodon.CipherBliss.com](https://mastodon.CipherBliss.com) version
======== ========
[![GitHub release](https://img.shields.io/github/release/tootsuite/mastodon.svg)][releases] [![GitHub release](https://img.shields.io/github/release/mastodon/mastodon.svg)][releases]
[![Build Status](https://img.shields.io/circleci/project/github/tootsuite/mastodon.svg)][circleci] [![Build Status](https://img.shields.io/circleci/project/github/mastodon/mastodon.svg)][circleci]
[![Code Climate](https://img.shields.io/codeclimate/maintainability/tootsuite/mastodon.svg)][code_climate] [![Code Climate](https://img.shields.io/codeclimate/maintainability/mastodon/mastodon.svg)][code_climate]
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin] [![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin]
[![Docker Pulls](https://img.shields.io/docker/pulls/tootsuite/mastodon.svg)][docker] [![Docker Pulls](https://img.shields.io/docker/pulls/tootsuite/mastodon.svg)][docker]
[releases]: https://github.com/tootsuite/mastodon/releases [releases]: https://github.com/mastodon/mastodon/releases
[circleci]: https://circleci.com/gh/tootsuite/mastodon [circleci]: https://circleci.com/gh/mastodon/mastodon
[code_climate]: https://codeclimate.com/github/tootsuite/mastodon [code_climate]: https://codeclimate.com/github/mastodon/mastodon
[crowdin]: https://crowdin.com/project/mastodon [crowdin]: https://crowdin.com/project/mastodon
[docker]: https://hub.docker.com/r/tootsuite/mastodon/ [docker]: https://hub.docker.com/r/tootsuite/mastodon/
@ -74,7 +74,12 @@ Mastodon acts as an OAuth2 provider so 3rd party apps can use the REST and Strea
The repository includes deployment configurations for **Docker and docker-compose**, but also a few specific platforms like **Heroku**, **Scalingo**, and **Nanobox**. The [**stand-alone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation. The repository includes deployment configurations for **Docker and docker-compose**, but also a few specific platforms like **Heroku**, **Scalingo**, and **Nanobox**. The [**stand-alone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation.
A **Vagrant** configuration is included for development purposes. A **Vagrant** configuration is included for development purposes. To use it, complete following steps:
- Install Vagrant and Virtualbox
- Run `vagrant up`
- Run `vagrant ssh -c "cd /vagrant && foreman start"`
- Open `http://mastodon.local` in your browser
## Contributing ## Contributing

14
Vagrantfile vendored
View File

@ -12,7 +12,7 @@ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main' sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main'
# Add repo for NodeJS # Add repo for NodeJS
curl -sL https://deb.nodesource.com/setup_12.x | sudo bash - curl -sL https://deb.nodesource.com/setup_14.x | sudo bash -
# Add firewall rule to redirect 80 to PORT and save # Add firewall rule to redirect 80 to PORT and save
sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port #{ENV["PORT"]} sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port #{ENV["PORT"]}
@ -45,16 +45,8 @@ sudo apt-get install \
# Install rvm # Install rvm
read RUBY_VERSION < .ruby-version read RUBY_VERSION < .ruby-version
gpg_command="gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB" curl -sSL https://rvm.io/mpapis.asc | gpg --import
$($gpg_command) curl -sSL https://rvm.io/pkuczynski.asc | gpg --import
if [ $? -ne 0 ];then
echo "GPG command failed, This prevented RVM from installing."
echo "Retrying once..." && $($gpg_command)
if [ $? -ne 0 ];then
echo "GPG failed for the second time, please ensure network connectivity."
echo "Exiting..." && exit 1
fi
fi
curl -sSL https://raw.githubusercontent.com/rvm/rvm/stable/binscripts/rvm-installer | bash -s stable --ruby=$RUBY_VERSION curl -sSL https://raw.githubusercontent.com/rvm/rvm/stable/binscripts/rvm-installer | bash -s stable --ruby=$RUBY_VERSION
source /home/vagrant/.rvm/scripts/rvm source /home/vagrant/.rvm/scripts/rvm

View File

@ -1,8 +1,8 @@
{ {
"name": "Mastodon", "name": "Mastodon",
"description": "A GNU Social-compatible microblogging server", "description": "A GNU Social-compatible microblogging server",
"repository": "https://github.com/tootsuite/mastodon", "repository": "https://github.com/mastodon/mastodon",
"logo": "https://github.com/tootsuite.png", "logo": "https://github.com/mastodon.png",
"env": { "env": {
"HEROKU": { "HEROKU": {
"description": "Leave this as true", "description": "Leave this as true",

View File

@ -19,11 +19,11 @@ class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseContro
private private
def uri_prefix def uri_prefix
signed_request_account.uri[/http(s?):\/\/[^\/]+\//] signed_request_account.uri[Account::URL_PREFIX_RE]
end end
def set_items def set_items
@items = @account.followers.where(Account.arel_table[:uri].matches(uri_prefix + '%', false, true)).pluck(:uri) @items = @account.followers.where(Account.arel_table[:uri].matches("#{Account.sanitize_sql_like(uri_prefix)}/%", false, true)).or(@account.followers.where(uri: uri_prefix)).pluck(:uri)
end end
def collection_presenter def collection_presenter

View File

@ -11,7 +11,11 @@ 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? && !(signed_request_account.present? && page_requested?)) if page_requested?
expires_in(1.minute, public: public_fetch_mode? && signed_request_account.nil?)
else
expires_in(3.minutes, public: public_fetch_mode?)
end
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
@ -76,4 +80,8 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
def set_account def set_account
@account = params[:account_username].present? ? Account.find_local!(username_param) : Account.representative @account = params[:account_username].present? ? Account.find_local!(username_param) : Account.representative
end end
def set_cache_headers
response.headers['Vary'] = 'Signature' if authorized_fetch_mode? || page_requested?
end
end end

View File

@ -1,49 +1,17 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'sidekiq/api'
module Admin module Admin
class DashboardController < BaseController class DashboardController < BaseController
def index def index
@system_checks = Admin::SystemCheck.perform @system_checks = Admin::SystemCheck.perform
@users_count = User.count @time_period = (1.month.ago.to_date...Time.now.utc.to_date)
@pending_users_count = User.pending.count @pending_users_count = User.pending.count
@registrations_week = Redis.current.get("activity:accounts:local:#{current_week}") || 0 @pending_reports_count = Report.unresolved.count
@logins_week = Redis.current.pfcount("activity:logins:#{current_week}")
@interactions_week = Redis.current.get("activity:interactions:#{current_week}") || 0
@relay_enabled = Relay.enabled.exists?
@single_user_mode = Rails.configuration.x.single_user_mode
@registrations_enabled = Setting.registrations_mode != 'none'
@deletions_enabled = Setting.open_deletion
@invites_enabled = Setting.min_invite_role == 'user'
@search_enabled = Chewy.enabled?
@version = Mastodon::Version.to_s
@database_version = ActiveRecord::Base.connection.execute('SELECT VERSION()').first['version'].match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1]
@redis_version = redis_info['redis_version']
@reports_count = Report.unresolved.count
@queue_backlog = Sidekiq::Stats.new.enqueued
@recent_users = User.confirmed.recent.includes(:account).limit(8)
@database_size = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size']
@redis_size = redis_info['used_memory']
@ldap_enabled = ENV['LDAP_ENABLED'] == 'true'
@cas_enabled = ENV['CAS_ENABLED'] == 'true'
@saml_enabled = ENV['SAML_ENABLED'] == 'true'
@pam_enabled = ENV['PAM_ENABLED'] == 'true'
@hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true'
@trending_hashtags = TrendingTags.get(10, filtered: false)
@pending_tags_count = Tag.pending_review.count @pending_tags_count = Tag.pending_review.count
@authorized_fetch = authorized_fetch_mode?
@whitelist_enabled = whitelist_mode?
@profile_directory = Setting.profile_directory
@timeline_preview = Setting.timeline_preview
@trends_enabled = Setting.trends
end end
private private
def current_week
@current_week ||= Time.now.utc.to_date.cweek
end
def redis_info def redis_info
@redis_info ||= begin @redis_info ||= begin
if Redis.current.is_a?(Redis::Namespace) if Redis.current.is_a?(Redis::Namespace)

View File

@ -6,9 +6,9 @@ module Admin
def create def create
authorize @user, :reset_password? authorize @user, :reset_password?
@user.send_reset_password_instructions @user.reset_password!
log_action :reset_password, @user log_action :reset_password, @user
redirect_to admin_accounts_path redirect_to admin_account_path(@user.account_id)
end end
end end
end end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
module Admin
class SignInTokenAuthenticationsController < BaseController
before_action :set_target_user
def create
authorize @user, :enable_sign_in_token_auth?
@user.update(skip_sign_in_token: false)
log_action :enable_sign_in_token_auth, @user
redirect_to admin_account_path(@user.account_id)
end
def destroy
authorize @user, :disable_sign_in_token_auth?
@user.update(skip_sign_in_token: true)
log_action :disable_sign_in_token_auth, @user
redirect_to admin_account_path(@user.account_id)
end
private
def set_target_user
@user = User.find(params[:user_id])
end
end
end

View File

@ -9,7 +9,7 @@ module Admin
@user.disable_two_factor! @user.disable_two_factor!
log_action :disable_2fa, @user log_action :disable_2fa, @user
UserMailer.two_factor_disabled(@user).deliver_later! UserMailer.two_factor_disabled(@user).deliver_later!
redirect_to admin_accounts_path redirect_to admin_account_path(@user.account_id)
end end
private private

View File

@ -40,7 +40,12 @@ class Api::BaseController < ApplicationController
render json: { error: 'This action is not allowed' }, status: 403 render json: { error: 'This action is not allowed' }, status: 403
end end
rescue_from Mastodon::RaceConditionError, Seahorse::Client::NetworkingError, Stoplight::Error::RedLight do rescue_from Seahorse::Client::NetworkingError do |e|
Rails.logger.warn "Storage server error: #{e}"
render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
end
rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight do
render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
end end

View File

@ -1,8 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::AccountsController < Api::BaseController class Api::V1::AccountsController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:accounts' }, except: [:create, :follow, :unfollow, :block, :unblock, :mute, :unmute] before_action -> { authorize_if_got_token! :read, :'read:accounts' }, except: [:create, :follow, :unfollow, :remove_from_followers, :block, :unblock, :mute, :unmute]
before_action -> { doorkeeper_authorize! :follow, :'write:follows' }, only: [:follow, :unfollow] before_action -> { doorkeeper_authorize! :follow, :'write:follows' }, only: [:follow, :unfollow, :remove_from_followers]
before_action -> { doorkeeper_authorize! :follow, :'write:mutes' }, only: [:mute, :unmute] before_action -> { doorkeeper_authorize! :follow, :'write:mutes' }, only: [:mute, :unmute]
before_action -> { doorkeeper_authorize! :follow, :'write:blocks' }, only: [:block, :unblock] before_action -> { doorkeeper_authorize! :follow, :'write:blocks' }, only: [:block, :unblock]
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:create] before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:create]
@ -53,6 +53,11 @@ class Api::V1::AccountsController < Api::BaseController
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end end
def remove_from_followers
RemoveFromFollowersService.new.call(current_user.account, @account)
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end
def unblock def unblock
UnblockService.new.call(current_user.account, @account) UnblockService.new.call(current_user.account, @account)
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Api::V1::Admin::DimensionsController < Api::BaseController
protect_from_forgery with: :exception
before_action :require_staff!
before_action :set_dimensions
def create
render json: @dimensions, each_serializer: REST::Admin::DimensionSerializer
end
private
def set_dimensions
@dimensions = Admin::Metrics::Dimension.retrieve(
params[:keys],
params[:start_at],
params[:end_at],
params[:limit]
)
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class Api::V1::Admin::MeasuresController < Api::BaseController
protect_from_forgery with: :exception
before_action :require_staff!
before_action :set_measures
def create
render json: @measures, each_serializer: REST::Admin::MeasureSerializer
end
private
def set_measures
@measures = Admin::Metrics::Measure.retrieve(
params[:keys],
params[:start_at],
params[:end_at]
)
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class Api::V1::Admin::RetentionController < Api::BaseController
protect_from_forgery with: :exception
before_action :require_staff!
before_action :set_cohorts
def create
render json: @cohorts, each_serializer: REST::Admin::CohortSerializer
end
private
def set_cohorts
@cohorts = Admin::Metrics::Retention.new(
params[:start_at],
params[:end_at],
params[:frequency]
).cohorts
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Api::V1::Admin::TrendsController < Api::BaseController
before_action :require_staff!
before_action :set_trends
def index
render json: @trends, each_serializer: REST::Admin::TagSerializer
end
private
def set_trends
@trends = TrendingTags.get(10, filtered: false)
end
end

View File

@ -14,22 +14,21 @@ class Api::V1::Instances::ActivityController < Api::BaseController
private private
def activity def activity
weeks = [] statuses_tracker = ActivityTracker.new('activity:statuses:local', :basic)
logins_tracker = ActivityTracker.new('activity:logins', :unique)
registrations_tracker = ActivityTracker.new('activity:accounts:local', :basic)
12.times do |i| (0...12).map do |i|
day = i.weeks.ago.to_date start_of_week = i.weeks.ago
week_id = day.cweek end_of_week = start_of_week + 6.days
week = Date.commercial(day.cwyear, week_id)
weeks << { {
week: week.to_time.to_i.to_s, week: start_of_week.to_i.to_s,
statuses: Redis.current.get("activity:statuses:local:#{week_id}") || '0', statuses: statuses_tracker.sum(start_of_week, end_of_week).to_s,
logins: Redis.current.pfcount("activity:logins:#{week_id}").to_s, logins: logins_tracker.sum(start_of_week, end_of_week).to_s,
registrations: Redis.current.get("activity:accounts:local:#{week_id}") || '0', registrations: registrations_tracker.sum(start_of_week, end_of_week).to_s,
} }
end end
weeks
end end
def require_enabled_api! def require_enabled_api!

View File

@ -26,7 +26,12 @@ class ApplicationController < ActionController::Base
rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error
rescue_from Mastodon::RaceConditionError, Seahorse::Client::NetworkingError, Stoplight::Error::RedLight, ActiveRecord::SerializationFailure, with: :service_unavailable rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight, ActiveRecord::SerializationFailure, with: :service_unavailable
rescue_from Seahorse::Client::NetworkingError do |e|
Rails.logger.warn "Storage server error: #{e}"
service_unavailable
end
before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
before_action :require_functional!, if: :user_signed_in? before_action :require_functional!, if: :user_signed_in?

View File

@ -10,6 +10,15 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
@user = User.find_for_oauth(request.env['omniauth.auth'], current_user) @user = User.find_for_oauth(request.env['omniauth.auth'], current_user)
if @user.persisted? if @user.persisted?
LoginActivity.create(
user: @user,
success: true,
authentication_method: :omniauth,
provider: provider,
ip: request.remote_ip,
user_agent: request.user_agent
)
sign_in_and_redirect @user, event: :authentication sign_in_and_redirect @user, event: :authentication
set_flash_message(:notice, :success, kind: provider_id.capitalize) if is_navigational_format? set_flash_message(:notice, :success, kind: provider_id.capitalize) if is_navigational_format?
else else

View File

@ -25,9 +25,11 @@ class Auth::SessionsController < Devise::SessionsController
def create def create
super do |resource| super do |resource|
resource.update_sign_in!(request, new_sign_in: true) # We only need to call this if this hasn't already been
remember_me(resource) # called from one of the two-factor or sign-in token
flash.delete(:notice) # authentication methods
on_authentication_success(resource, :password) unless @on_authentication_success_called
end end
end end
@ -40,11 +42,12 @@ class Auth::SessionsController < Devise::SessionsController
end end
def webauthn_options def webauthn_options
user = find_user user = User.find_by(id: session[:attempt_user_id])
if user.webauthn_enabled? if user&.webauthn_enabled?
options_for_get = WebAuthn::Credential.options_for_get( options_for_get = WebAuthn::Credential.options_for_get(
allow: user.webauthn_credentials.pluck(:external_id) allow: user.webauthn_credentials.pluck(:external_id),
user_verification: 'discouraged'
) )
session[:webauthn_challenge] = options_for_get.challenge session[:webauthn_challenge] = options_for_get.challenge
@ -58,16 +61,20 @@ class Auth::SessionsController < Devise::SessionsController
protected protected
def find_user def find_user
if session[:attempt_user_id] if user_params[:email].present?
find_user_from_params
elsif session[:attempt_user_id]
User.find_by(id: session[:attempt_user_id]) User.find_by(id: session[:attempt_user_id])
else
user = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication
user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication
user ||= User.find_for_authentication(email: user_params[:email])
user
end end
end end
def find_user_from_params
user = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication
user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication
user ||= User.find_for_authentication(email: user_params[:email])
user
end
def user_params def user_params
params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt, credential: {}) params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt, credential: {})
end end
@ -136,4 +143,34 @@ class Auth::SessionsController < Devise::SessionsController
session.delete(:attempt_user_id) session.delete(:attempt_user_id)
session.delete(:attempt_user_updated_at) session.delete(:attempt_user_updated_at)
end end
def on_authentication_success(user, security_measure)
@on_authentication_success_called = true
clear_attempt_from_session
user.update_sign_in!(request, new_sign_in: true)
remember_me(user)
sign_in(user)
flash.delete(:notice)
LoginActivity.create(
user: user,
success: true,
authentication_method: security_measure,
ip: request.remote_ip,
user_agent: request.user_agent
)
end
def on_authentication_failure(user, security_measure, failure_reason)
LoginActivity.create(
user: user,
success: false,
authentication_method: security_measure,
failure_reason: failure_reason,
ip: request.remote_ip,
user_agent: request.user_agent
)
end
end end

View File

@ -16,23 +16,26 @@ module SignInTokenAuthenticationConcern
end end
def authenticate_with_sign_in_token def authenticate_with_sign_in_token
user = self.resource = find_user if user_params[:email].present?
user = self.resource = find_user_from_params
prompt_for_sign_in_token(user) if user&.external_or_valid_password?(user_params[:password])
elsif session[:attempt_user_id]
user = self.resource = User.find_by(id: session[:attempt_user_id])
return if user.nil?
if user.present? && session[:attempt_user_id].present? && session[:attempt_user_updated_at] != user.updated_at.to_s if session[:attempt_user_updated_at] != user.updated_at.to_s
restart_session restart_session
elsif user_params.key?(:sign_in_token_attempt) && session[:attempt_user_id] elsif user_params.key?(:sign_in_token_attempt)
authenticate_with_sign_in_token_attempt(user) authenticate_with_sign_in_token_attempt(user)
elsif user.present? && user.external_or_valid_password?(user_params[:password]) end
prompt_for_sign_in_token(user)
end end
end end
def authenticate_with_sign_in_token_attempt(user) def authenticate_with_sign_in_token_attempt(user)
if valid_sign_in_token_attempt?(user) if valid_sign_in_token_attempt?(user)
clear_attempt_from_session on_authentication_success(user, :sign_in_token)
remember_me(user)
sign_in(user)
else else
on_authentication_failure(user, :sign_in_token, :invalid_sign_in_token)
flash.now[:alert] = I18n.t('users.invalid_sign_in_token') flash.now[:alert] = I18n.t('users.invalid_sign_in_token')
prompt_for_sign_in_token(user) prompt_for_sign_in_token(user)
end end

View File

@ -35,16 +35,20 @@ module TwoFactorAuthenticationConcern
end end
def authenticate_with_two_factor def authenticate_with_two_factor
user = self.resource = find_user if user_params[:email].present?
user = self.resource = find_user_from_params
prompt_for_two_factor(user) if user&.external_or_valid_password?(user_params[:password])
elsif session[:attempt_user_id]
user = self.resource = User.find_by(id: session[:attempt_user_id])
return if user.nil?
if user.present? && session[:attempt_user_id].present? && session[:attempt_user_updated_at] != user.updated_at.to_s if session[:attempt_user_updated_at] != user.updated_at.to_s
restart_session restart_session
elsif user.webauthn_enabled? && user_params.key?(:credential) && session[:attempt_user_id] elsif user.webauthn_enabled? && user_params.key?(:credential)
authenticate_with_two_factor_via_webauthn(user) authenticate_with_two_factor_via_webauthn(user)
elsif user_params.key?(:otp_attempt) && session[:attempt_user_id] elsif user_params.key?(:otp_attempt)
authenticate_with_two_factor_via_otp(user) authenticate_with_two_factor_via_otp(user)
elsif user.present? && user.external_or_valid_password?(user_params[:password]) end
prompt_for_two_factor(user)
end end
end end
@ -52,21 +56,19 @@ module TwoFactorAuthenticationConcern
webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential]) webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential])
if valid_webauthn_credential?(user, webauthn_credential) if valid_webauthn_credential?(user, webauthn_credential)
clear_attempt_from_session on_authentication_success(user, :webauthn)
remember_me(user)
sign_in(user)
render json: { redirect_path: root_path }, status: :ok render json: { redirect_path: root_path }, status: :ok
else else
on_authentication_failure(user, :webauthn, :invalid_credential)
render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity
end end
end end
def authenticate_with_two_factor_via_otp(user) def authenticate_with_two_factor_via_otp(user)
if valid_otp_attempt?(user) if valid_otp_attempt?(user)
clear_attempt_from_session on_authentication_success(user, :otp)
remember_me(user)
sign_in(user)
else else
on_authentication_failure(user, :otp, :invalid_otp_token)
flash.now[:alert] = I18n.t('users.invalid_otp_token') flash.now[:alert] = I18n.t('users.invalid_otp_token')
prompt_for_two_factor(user) prompt_for_two_factor(user)
end end

View File

@ -85,7 +85,7 @@ class FollowerAccountsController < ApplicationController
if page_requested? || !@account.user_hides_network? if page_requested? || !@account.user_hides_network?
# Return all fields # Return all fields
else else
%i(id type totalItems) %i(id type total_items)
end end
end end
end end

View File

@ -85,7 +85,7 @@ class FollowingAccountsController < ApplicationController
if page_requested? || !@account.user_hides_network? if page_requested? || !@account.user_hides_network?
# Return all fields # Return all fields
else else
%i(id type totalItems) %i(id type total_items)
end end
end end
end end

View File

@ -14,30 +14,7 @@ class HomeController < ApplicationController
def redirect_unauthenticated_to_permalinks! def redirect_unauthenticated_to_permalinks!
return if user_signed_in? return if user_signed_in?
matches = request.path.match(/\A\/web\/(statuses|accounts)\/([\d]+)\z/) redirect_to(PermalinkRedirector.new(request.path).redirect_path || default_redirect_path)
if matches
case matches[1]
when 'statuses'
status = Status.find_by(id: matches[2])
if status&.distributable?
redirect_to(ActivityPub::TagManager.instance.url_for(status))
return
end
when 'accounts'
account = Account.find_by(id: matches[2])
if account
redirect_to(ActivityPub::TagManager.instance.url_for(account))
return
end
end
end
matches = request.path.match(%r{\A/web/timelines/tag/(?<tag>.+)\z})
redirect_to(matches ? tag_path(CGI.unescape(matches[:tag])) : default_redirect_path)
end end
def default_redirect_path def default_redirect_path

View File

@ -27,7 +27,12 @@ class MediaController < ApplicationController
private private
def set_media_attachment def set_media_attachment
@media_attachment = MediaAttachment.attached.find_by!(shortcode: params[:id] || params[:medium_id]) id = params[:id] || params[:medium_id]
return if id.nil?
scope = MediaAttachment.local.attached
# If id is 19 characters long, it's a shortcode, otherwise it's an identifier
@media_attachment = id.size == 19 ? scope.find_by!(shortcode: id) : scope.find_by!(id: id)
end end
def verify_permitted_status! def verify_permitted_status!

View File

@ -42,7 +42,7 @@ class Settings::DeletesController < Settings::BaseController
end end
def destroy_account! def destroy_account!
current_account.suspend!(origin: :local) current_account.suspend!(origin: :local, block_email: false)
AccountDeletionWorker.perform_async(current_user.account_id) AccountDeletionWorker.perform_async(current_user.account_id)
sign_out sign_out
end end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class Settings::LoginActivitiesController < Settings::BaseController
def index
@login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page])
end
end

View File

@ -21,7 +21,8 @@ module Settings
display_name: current_user.account.username, display_name: current_user.account.username,
id: current_user.webauthn_id, id: current_user.webauthn_id,
}, },
exclude: current_user.webauthn_credentials.pluck(:external_id) exclude: current_user.webauthn_credentials.pluck(:external_id),
authenticator_selection: { user_verification: 'discouraged' }
) )
session[:webauthn_challenge] = options_for_create.challenge session[:webauthn_challenge] = options_for_create.challenge

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
class StatusesCleanupController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_policy
before_action :set_body_classes
def show; end
def update
if @policy.update(resource_params)
redirect_to statuses_cleanup_path, notice: I18n.t('generic.changes_saved_msg')
else
render action: :show
end
rescue ActionController::ParameterMissing
# Do nothing
end
private
def set_policy
@policy = current_account.statuses_cleanup_policy || current_account.build_statuses_cleanup_policy(enabled: false)
end
def resource_params
params.require(:account_statuses_cleanup_policy).permit(:enabled, :min_status_age, :keep_direct, :keep_pinned, :keep_polls, :keep_media, :keep_self_fav, :keep_self_bookmark, :min_favs, :min_reblogs)
end
def set_body_classes
@body_classes = 'admin'
end
end

View File

@ -4,7 +4,6 @@ module WellKnown
class WebfingerController < ActionController::Base class WebfingerController < ActionController::Base
include RoutingHelper include RoutingHelper
before_action { response.headers['Vary'] = 'Accept' }
before_action :set_account before_action :set_account
before_action :check_account_suspension before_action :check_account_suspension
@ -39,10 +38,12 @@ module WellKnown
end end
def bad_request def bad_request
expires_in(3.minutes, public: true)
head 400 head 400
end end
def not_found def not_found
expires_in(3.minutes, public: true)
head 404 head 404
end end

View File

@ -80,17 +80,17 @@ module AccountsHelper
def account_description(account) def account_description(account)
prepend_str = [ prepend_str = [
[ [
number_to_human(account.statuses_count, strip_insignificant_zeros: true), number_to_human(account.statuses_count, precision: 3, strip_insignificant_zeros: true),
I18n.t('accounts.posts', count: account.statuses_count), I18n.t('accounts.posts', count: account.statuses_count),
].join(' '), ].join(' '),
[ [
number_to_human(account.following_count, strip_insignificant_zeros: true), number_to_human(account.following_count, precision: 3, strip_insignificant_zeros: true),
I18n.t('accounts.following', count: account.following_count), I18n.t('accounts.following', count: account.following_count),
].join(' '), ].join(' '),
[ [
number_to_human(account.followers_count, strip_insignificant_zeros: true), number_to_human(account.followers_count, precision: 3, strip_insignificant_zeros: true),
I18n.t('accounts.followers', count: account.followers_count), I18n.t('accounts.followers', count: account.followers_count),
].join(' '), ].join(' '),
].join(', ') ].join(', ')

View File

@ -14,6 +14,17 @@ module ApplicationHelper
ku ku
).freeze ).freeze
def friendly_number_to_human(number, **options)
# By default, the number of precision digits used by number_to_human
# is looked up from the locales definition, and rails-i18n comes with
# values that don't seem to make much sense for many languages, so
# override these values with a default of 3 digits of precision.
options[:precision] = 3
options[:strip_insignificant_zeros] = true
number_to_human(number, **options)
end
def active_nav_class(*paths) def active_nav_class(*paths)
paths.any? { |path| current_page?(path) } ? 'active' : '' paths.any? { |path| current_page?(path) } ? 'active' : ''
end end
@ -126,6 +137,10 @@ module ApplicationHelper
end end
end end
def react_admin_component(name, props = {})
content_tag(:div, nil, data: { 'admin-component': name.to_s.camelcase, props: Oj.dump({ locale: I18n.locale }.merge(props)) })
end
def body_classes def body_classes
output = (@body_classes || '').split(' ') output = (@body_classes || '').split(' ')
output << "theme-#{current_theme.parameterize}" output << "theme-#{current_theme.parameterize}"

View File

@ -41,6 +41,7 @@ module SettingsHelper
ka: 'ქართული', ka: 'ქართული',
kab: 'Taqbaylit', kab: 'Taqbaylit',
kk: 'Қазақша', kk: 'Қазақша',
kmr: 'Kurmancî',
kn: 'ಕನ್ನಡ', kn: 'ಕನ್ನಡ',
ko: '한국어', ko: '한국어',
ku: 'سۆرانی', ku: 'سۆرانی',

View File

@ -5,6 +5,10 @@ export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL'; export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL';
export const ACCOUNT_LOOKUP_REQUEST = 'ACCOUNT_LOOKUP_REQUEST';
export const ACCOUNT_LOOKUP_SUCCESS = 'ACCOUNT_LOOKUP_SUCCESS';
export const ACCOUNT_LOOKUP_FAIL = 'ACCOUNT_LOOKUP_FAIL';
export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST'; export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST';
export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS'; export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS';
export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL'; export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL';
@ -87,6 +91,34 @@ export function fetchAccount(id) {
}; };
} }
export const lookupAccount = acct => (dispatch, getState) => {
dispatch(lookupAccountRequest(acct));
api(getState).get('/api/v1/accounts/lookup', { params: { acct } }).then(response => {
dispatch(fetchRelationships([response.data.id]));
dispatch(importFetchedAccount(response.data));
dispatch(lookupAccountSuccess());
}).catch(error => {
dispatch(lookupAccountFail(acct, error));
});
};
export const lookupAccountRequest = (acct) => ({
type: ACCOUNT_LOOKUP_REQUEST,
acct,
});
export const lookupAccountSuccess = () => ({
type: ACCOUNT_LOOKUP_SUCCESS,
});
export const lookupAccountFail = (acct, error) => ({
type: ACCOUNT_LOOKUP_FAIL,
acct,
error,
skipAlert: true,
});
export function fetchAccountRequest(id) { export function fetchAccountRequest(id) {
return { return {
type: ACCOUNT_FETCH_REQUEST, type: ACCOUNT_FETCH_REQUEST,

View File

@ -7,7 +7,9 @@ import { useEmoji } from './emojis';
import resizeImage from '../utils/resize_image'; import resizeImage from '../utils/resize_image';
import { importFetchedAccounts } from './importer'; import { importFetchedAccounts } from './importer';
import { updateTimeline } from './timelines'; import { updateTimeline } from './timelines';
import { showAlert, showAlertForError } from './alerts'; import { showAlertForError } from './alerts';
import { showAlert } from './alerts';
import { openModal } from './modal';
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags; let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags;
@ -62,6 +64,11 @@ export const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE';
export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE'; export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE';
export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE'; export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE';
export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL';
export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION';
export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS';
const messages = defineMessages({ const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
@ -71,7 +78,7 @@ const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
export const ensureComposeIsVisible = (getState, routerHistory) => { export const ensureComposeIsVisible = (getState, routerHistory) => {
if (!getState().getIn(['compose', 'mounted']) && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) { if (!getState().getIn(['compose', 'mounted']) && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) {
routerHistory.push('/statuses/new'); routerHistory.push('/publish');
} }
}; };
@ -80,7 +87,7 @@ export function changeCompose(text) {
type: COMPOSE_CHANGE, type: COMPOSE_CHANGE,
text: text, text: text,
}; };
} };
export function replyCompose(status, routerHistory) { export function replyCompose(status, routerHistory) {
return (dispatch, getState) => { return (dispatch, getState) => {
@ -91,19 +98,19 @@ export function replyCompose(status, routerHistory) {
ensureComposeIsVisible(getState, routerHistory); ensureComposeIsVisible(getState, routerHistory);
}; };
} };
export function cancelReplyCompose() { export function cancelReplyCompose() {
return { return {
type: COMPOSE_REPLY_CANCEL, type: COMPOSE_REPLY_CANCEL,
}; };
} };
export function resetCompose() { export function resetCompose() {
return { return {
type: COMPOSE_RESET, type: COMPOSE_RESET,
}; };
} };
export function mentionCompose(account, routerHistory) { export function mentionCompose(account, routerHistory) {
return (dispatch, getState) => { return (dispatch, getState) => {
@ -114,7 +121,7 @@ export function mentionCompose(account, routerHistory) {
ensureComposeIsVisible(getState, routerHistory); ensureComposeIsVisible(getState, routerHistory);
}; };
} };
export function directCompose(account, routerHistory) { export function directCompose(account, routerHistory) {
return (dispatch, getState) => { return (dispatch, getState) => {
@ -125,7 +132,7 @@ export function directCompose(account, routerHistory) {
ensureComposeIsVisible(getState, routerHistory); ensureComposeIsVisible(getState, routerHistory);
}; };
} };
export function submitCompose(routerHistory) { export function submitCompose(routerHistory) {
return function (dispatch, getState) { return function (dispatch, getState) {
@ -151,7 +158,7 @@ export function submitCompose(routerHistory) {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
}, },
}).then(function (response) { }).then(function (response) {
if (routerHistory && routerHistory.location.pathname === '/statuses/new' && window.history.state) { if (routerHistory && (routerHistory.location.pathname === '/publish' || routerHistory.location.pathname === '/statuses/new') && window.history.state) {
routerHistory.goBack(); routerHistory.goBack();
} }
@ -181,27 +188,27 @@ export function submitCompose(routerHistory) {
dispatch(submitComposeFail(error)); dispatch(submitComposeFail(error));
}); });
}; };
} };
export function submitComposeRequest() { export function submitComposeRequest() {
return { return {
type: COMPOSE_SUBMIT_REQUEST, type: COMPOSE_SUBMIT_REQUEST,
}; };
} };
export function submitComposeSuccess(status) { export function submitComposeSuccess(status) {
return { return {
type: COMPOSE_SUBMIT_SUCCESS, type: COMPOSE_SUBMIT_SUCCESS,
status: status, status: status,
}; };
} };
export function submitComposeFail(error) { export function submitComposeFail(error) {
return { return {
type: COMPOSE_SUBMIT_FAIL, type: COMPOSE_SUBMIT_FAIL,
error: error, error: error,
}; };
} };
export function uploadCompose(files) { export function uploadCompose(files) {
return function (dispatch, getState) { return function (dispatch, getState) {
@ -233,7 +240,7 @@ export function uploadCompose(files) {
total += file.size - f.size; total += file.size - f.size;
return api(getState).post('/api/v2/media', data, { return api(getState).post('/api/v2/media', data, {
onUploadProgress: function ({ loaded }) { onUploadProgress: function({ loaded }){
progress[i] = loaded; progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
}, },
@ -258,9 +265,9 @@ export function uploadCompose(files) {
} }
}); });
}).catch(error => dispatch(uploadComposeFail(error))); }).catch(error => dispatch(uploadComposeFail(error)));
} };
}; };
} };
export const uploadThumbnail = (id, file) => (dispatch, getState) => { export const uploadThumbnail = (id, file) => (dispatch, getState) => {
dispatch(uploadThumbnailRequest()); dispatch(uploadThumbnailRequest());
@ -305,6 +312,32 @@ export const uploadThumbnailFail = error => ({
skipLoading: true, skipLoading: true,
}); });
export function initMediaEditModal(id) {
return dispatch => {
dispatch({
type: INIT_MEDIA_EDIT_MODAL,
id,
});
dispatch(openModal('FOCAL_POINT', { id }));
};
};
export function onChangeMediaDescription(description) {
return {
type: COMPOSE_CHANGE_MEDIA_DESCRIPTION,
description,
};
};
export function onChangeMediaFocus(focusX, focusY) {
return {
type: COMPOSE_CHANGE_MEDIA_FOCUS,
focusX,
focusY,
};
};
export function changeUploadCompose(id, params) { export function changeUploadCompose(id, params) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(changeUploadComposeRequest()); dispatch(changeUploadComposeRequest());
@ -315,7 +348,7 @@ export function changeUploadCompose(id, params) {
dispatch(changeUploadComposeFail(id, error)); dispatch(changeUploadComposeFail(id, error));
}); });
}; };
} };
export function changeUploadComposeRequest() { export function changeUploadComposeRequest() {
return { return {
@ -330,7 +363,7 @@ export function changeUploadComposeSuccess(media) {
media: media, media: media,
skipLoading: true, skipLoading: true,
}; };
} };
export function changeUploadComposeFail(error) { export function changeUploadComposeFail(error) {
return { return {
@ -338,14 +371,14 @@ export function changeUploadComposeFail(error) {
error: error, error: error,
skipLoading: true, skipLoading: true,
}; };
} };
export function uploadComposeRequest() { export function uploadComposeRequest() {
return { return {
type: COMPOSE_UPLOAD_REQUEST, type: COMPOSE_UPLOAD_REQUEST,
skipLoading: true, skipLoading: true,
}; };
} };
export function uploadComposeProgress(loaded, total) { export function uploadComposeProgress(loaded, total) {
return { return {
@ -353,7 +386,7 @@ export function uploadComposeProgress(loaded, total) {
loaded: loaded, loaded: loaded,
total: total, total: total,
}; };
} };
export function uploadComposeSuccess(media, file) { export function uploadComposeSuccess(media, file) {
return { return {
@ -362,7 +395,7 @@ export function uploadComposeSuccess(media, file) {
file: file, file: file,
skipLoading: true, skipLoading: true,
}; };
} };
export function uploadComposeFail(error) { export function uploadComposeFail(error) {
return { return {
@ -370,14 +403,14 @@ export function uploadComposeFail(error) {
error: error, error: error,
skipLoading: true, skipLoading: true,
}; };
} };
export function undoUploadCompose(media_id) { export function undoUploadCompose(media_id) {
return { return {
type: COMPOSE_UPLOAD_UNDO, type: COMPOSE_UPLOAD_UNDO,
media_id: media_id, media_id: media_id,
}; };
} };
export function clearComposeSuggestions() { export function clearComposeSuggestions() {
if (cancelFetchComposeSuggestionsAccounts) { if (cancelFetchComposeSuggestionsAccounts) {
@ -386,7 +419,7 @@ export function clearComposeSuggestions() {
return { return {
type: COMPOSE_SUGGESTIONS_CLEAR, type: COMPOSE_SUGGESTIONS_CLEAR,
}; };
} };
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
if (cancelFetchComposeSuggestionsAccounts) { if (cancelFetchComposeSuggestionsAccounts) {
@ -460,7 +493,7 @@ export function fetchComposeSuggestions(token) {
break; break;
} }
}; };
} };
export function readyComposeSuggestionsEmojis(token, emojis) { export function readyComposeSuggestionsEmojis(token, emojis) {
return { return {
@ -468,7 +501,7 @@ export function readyComposeSuggestionsEmojis(token, emojis) {
token, token,
emojis, emojis,
}; };
} };
export function readyComposeSuggestionsAccounts(token, accounts) { export function readyComposeSuggestionsAccounts(token, accounts) {
return { return {
@ -476,7 +509,7 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
token, token,
accounts, accounts,
}; };
} };
export const readyComposeSuggestionsTags = (token, tags) => ({ export const readyComposeSuggestionsTags = (token, tags) => ({
type: COMPOSE_SUGGESTIONS_READY, type: COMPOSE_SUGGESTIONS_READY,
@ -509,7 +542,7 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
path, path,
}); });
}; };
} };
export function updateSuggestionTags(token) { export function updateSuggestionTags(token) {
return { return {
@ -557,39 +590,39 @@ export function mountCompose() {
return { return {
type: COMPOSE_MOUNT, type: COMPOSE_MOUNT,
}; };
} };
export function unmountCompose() { export function unmountCompose() {
return { return {
type: COMPOSE_UNMOUNT, type: COMPOSE_UNMOUNT,
}; };
} };
export function changeComposeSensitivity() { export function changeComposeSensitivity() {
return { return {
type: COMPOSE_SENSITIVITY_CHANGE, type: COMPOSE_SENSITIVITY_CHANGE,
}; };
} };
export function changeComposeSpoilerness() { export function changeComposeSpoilerness() {
return { return {
type: COMPOSE_SPOILERNESS_CHANGE, type: COMPOSE_SPOILERNESS_CHANGE,
}; };
} };
export function changeComposeSpoilerText(text) { export function changeComposeSpoilerText(text) {
return { return {
type: COMPOSE_SPOILER_TEXT_CHANGE, type: COMPOSE_SPOILER_TEXT_CHANGE,
text, text,
}; };
} };
export function changeComposeVisibility(value) { export function changeComposeVisibility(value) {
return { return {
type: COMPOSE_VISIBILITY_CHANGE, type: COMPOSE_VISIBILITY_CHANGE,
value, value,
}; };
} };
export function insertEmojiCompose(position, emoji, needsSpace) { export function insertEmojiCompose(position, emoji, needsSpace) {
return { return {
@ -598,33 +631,33 @@ export function insertEmojiCompose(position, emoji, needsSpace) {
emoji, emoji,
needsSpace, needsSpace,
}; };
} };
export function changeComposing(value) { export function changeComposing(value) {
return { return {
type: COMPOSE_COMPOSING_CHANGE, type: COMPOSE_COMPOSING_CHANGE,
value, value,
}; };
} };
export function addPoll() { export function addPoll() {
return { return {
type: COMPOSE_POLL_ADD, type: COMPOSE_POLL_ADD,
}; };
} };
export function removePoll() { export function removePoll() {
return { return {
type: COMPOSE_POLL_REMOVE, type: COMPOSE_POLL_REMOVE,
}; };
} };
export function addPollOption(title) { export function addPollOption(title) {
return { return {
type: COMPOSE_POLL_OPTION_ADD, type: COMPOSE_POLL_OPTION_ADD,
title, title,
}; };
} };
export function changePollOption(index, title) { export function changePollOption(index, title) {
return { return {
@ -632,14 +665,14 @@ export function changePollOption(index, title) {
index, index,
title, title,
}; };
} };
export function removePollOption(index) { export function removePollOption(index) {
return { return {
type: COMPOSE_POLL_OPTION_REMOVE, type: COMPOSE_POLL_OPTION_REMOVE,
index, index,
}; };
} };
export function changePollSettings(expiresIn, isMultiple) { export function changePollSettings(expiresIn, isMultiple) {
return { return {
@ -647,4 +680,4 @@ export function changePollSettings(expiresIn, isMultiple) {
expiresIn, expiresIn,
isMultiple, isMultiple,
}; };
} };

View File

@ -1,6 +1,6 @@
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
import IntlMessageFormat from 'intl-messageformat'; import IntlMessageFormat from 'intl-messageformat';
import { fetchRelationships } from './accounts'; import { fetchFollowRequests, fetchRelationships } from './accounts';
import { import {
importFetchedAccount, importFetchedAccount,
importFetchedAccounts, importFetchedAccounts,
@ -78,6 +78,10 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
filtered = regex && regex.test(searchIndex); filtered = regex && regex.test(searchIndex);
} }
if (['follow_request'].includes(notification.type)) {
dispatch(fetchFollowRequests());
}
dispatch(submitMarkers()); dispatch(submitMarkers());
if (showInColumn) { if (showInColumn) {

View File

@ -22,13 +22,20 @@ export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE';
* @param {MediaProps} props * @param {MediaProps} props
* @return {object} * @return {object}
*/ */
export const deployPictureInPicture = (statusId, accountId, playerType, props) => ({ export const deployPictureInPicture = (statusId, accountId, playerType, props) => {
type: PICTURE_IN_PICTURE_DEPLOY, return (dispatch, getState) => {
statusId, // Do not open a player for a toot that does not exist
accountId, if (getState().hasIn(['statuses', statusId])) {
playerType, dispatch({
props, type: PICTURE_IN_PICTURE_DEPLOY,
}); statusId,
accountId,
playerType,
props,
});
}
};
};
/* /*
* @return {object} * @return {object}

View File

@ -12,21 +12,35 @@ export const getLinks = response => {
return LinkHeader.parse(value); return LinkHeader.parse(value);
}; };
let csrfHeader = {}; const csrfHeader = {};
function setCSRFHeader() { const setCSRFHeader = () => {
const csrfToken = document.querySelector('meta[name=csrf-token]'); const csrfToken = document.querySelector('meta[name=csrf-token]');
if (csrfToken) { if (csrfToken) {
csrfHeader['X-CSRF-Token'] = csrfToken.content; csrfHeader['X-CSRF-Token'] = csrfToken.content;
} }
} };
ready(setCSRFHeader); ready(setCSRFHeader);
const authorizationHeaderFromState = getState => {
const accessToken = getState && getState().getIn(['meta', 'access_token'], '');
if (!accessToken) {
return {};
}
return {
'Authorization': `Bearer ${accessToken}`,
};
};
export default getState => axios.create({ export default getState => axios.create({
headers: Object.assign(csrfHeader, getState ? { headers: {
'Authorization': `Bearer ${getState().getIn(['meta', 'access_token'], '')}`, ...csrfHeader,
} : {}), ...authorizationHeaderFromState(getState),
},
transformResponse: [function (data) { transformResponse: [function (data) {
try { try {

View File

@ -118,7 +118,7 @@ class Account extends ImmutablePureComponent {
return ( return (
<div className='account'> <div className='account'>
<div className='account__wrapper'> <div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}> <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
{mute_expires_at} {mute_expires_at}
<DisplayName account={account} /> <DisplayName account={account} />

View File

@ -0,0 +1,115 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { FormattedNumber } from 'react-intl';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import classNames from 'classnames';
import Skeleton from 'mastodon/components/skeleton';
const percIncrease = (a, b) => {
let percent;
if (b !== 0) {
if (a !== 0) {
percent = (b - a) / a;
} else {
percent = 1;
}
} else if (b === 0 && a === 0) {
percent = 0;
} else {
percent = - 1;
}
return percent;
};
export default class Counter extends React.PureComponent {
static propTypes = {
measure: PropTypes.string.isRequired,
start_at: PropTypes.string.isRequired,
end_at: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
href: PropTypes.string,
};
state = {
loading: true,
data: null,
};
componentDidMount () {
const { measure, start_at, end_at } = this.props;
api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at }).then(res => {
this.setState({
loading: false,
data: res.data,
});
}).catch(err => {
console.error(err);
});
}
render () {
const { label, href } = this.props;
const { loading, data } = this.state;
let content;
if (loading) {
content = (
<React.Fragment>
<span className='sparkline__value__total'><Skeleton width={43} /></span>
<span className='sparkline__value__change'><Skeleton width={43} /></span>
</React.Fragment>
);
} else {
const measure = data[0];
const percentChange = percIncrease(measure.previous_total * 1, measure.total * 1);
content = (
<React.Fragment>
<span className='sparkline__value__total'><FormattedNumber value={measure.total} /></span>
<span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>
</React.Fragment>
);
}
const inner = (
<React.Fragment>
<div className='sparkline__value'>
{content}
</div>
<div className='sparkline__label'>
{label}
</div>
<div className='sparkline__graph'>
{!loading && (
<Sparklines width={259} height={55} data={data[0].data.map(x => x.value * 1)}>
<SparklinesCurve />
</Sparklines>
)}
</div>
</React.Fragment>
);
if (href) {
return (
<a href={href} className='sparkline'>
{inner}
</a>
);
} else {
return (
<div className='sparkline'>
{inner}
</div>
);
}
}
}

View File

@ -0,0 +1,92 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { FormattedNumber } from 'react-intl';
import { roundTo10 } from 'mastodon/utils/numbers';
import Skeleton from 'mastodon/components/skeleton';
export default class Dimension extends React.PureComponent {
static propTypes = {
dimension: PropTypes.string.isRequired,
start_at: PropTypes.string.isRequired,
end_at: PropTypes.string.isRequired,
limit: PropTypes.number.isRequired,
label: PropTypes.string.isRequired,
};
state = {
loading: true,
data: null,
};
componentDidMount () {
const { start_at, end_at, dimension, limit } = this.props;
api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit }).then(res => {
this.setState({
loading: false,
data: res.data,
});
}).catch(err => {
console.error(err);
});
}
render () {
const { label, limit } = this.props;
const { loading, data } = this.state;
let content;
if (loading) {
content = (
<table>
<tbody>
{Array.from(Array(limit)).map((_, i) => (
<tr className='dimension__item' key={i}>
<td className='dimension__item__key'>
<Skeleton width={100} />
</td>
<td className='dimension__item__value'>
<Skeleton width={60} />
</td>
</tr>
))}
</tbody>
</table>
);
} else {
const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0);
content = (
<table>
<tbody>
{data[0].data.map(item => (
<tr className='dimension__item' key={item.key}>
<td className='dimension__item__key'>
<span className={`dimension__item__indicator dimension__item__indicator--${roundTo10(((item.value * 1) / sum) * 100)}`} />
<span title={item.key}>{item.human_key}</span>
</td>
<td className='dimension__item__value'>
{typeof item.human_value !== 'undefined' ? item.human_value : <FormattedNumber value={item.value} />}
</td>
</tr>
))}
</tbody>
</table>
);
}
return (
<div className='dimension'>
<h4>{label}</h4>
{content}
</div>
);
}
}

View File

@ -0,0 +1,141 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
import classNames from 'classnames';
import { roundTo10 } from 'mastodon/utils/numbers';
const dateForCohort = cohort => {
switch(cohort.frequency) {
case 'day':
return <FormattedDate value={cohort.period} month='long' day='2-digit' />;
default:
return <FormattedDate value={cohort.period} month='long' year='numeric' />;
}
};
export default class Retention extends React.PureComponent {
static propTypes = {
start_at: PropTypes.string,
end_at: PropTypes.string,
frequency: PropTypes.string,
};
state = {
loading: true,
data: null,
};
componentDidMount () {
const { start_at, end_at, frequency } = this.props;
api().post('/api/v1/admin/retention', { start_at, end_at, frequency }).then(res => {
this.setState({
loading: false,
data: res.data,
});
}).catch(err => {
console.error(err);
});
}
render () {
const { loading, data } = this.state;
let content;
if (loading) {
content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />;
} else {
content = (
<table className='retention__table'>
<thead>
<tr>
<th>
<div className='retention__table__date retention__table__label'>
<FormattedMessage id='admin.dashboard.retention.cohort' defaultMessage='Sign-up month' />
</div>
</th>
<th>
<div className='retention__table__number retention__table__label'>
<FormattedMessage id='admin.dashboard.retention.cohort_size' defaultMessage='New users' />
</div>
</th>
{data[0].data.slice(1).map((retention, i) => (
<th key={retention.date}>
<div className='retention__table__number retention__table__label'>
{i + 1}
</div>
</th>
))}
</tr>
<tr>
<td>
<div className='retention__table__date retention__table__average'>
<FormattedMessage id='admin.dashboard.retention.average' defaultMessage='Average' />
</div>
</td>
<td>
<div className='retention__table__size'>
<FormattedNumber value={data.reduce((sum, cohort, i) => sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} />
</div>
</td>
{data[0].data.slice(1).map((retention, i) => {
const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].percent - sum)/(k + 1) : sum, 0);
return (
<td key={retention.date}>
<div className={classNames('retention__table__box', 'retention__table__average', `retention__table__box--${roundTo10(average * 100)}`)}>
<FormattedNumber value={average} style='percent' />
</div>
</td>
);
})}
</tr>
</thead>
<tbody>
{data.slice(0, -1).map(cohort => (
<tr key={cohort.period}>
<td>
<div className='retention__table__date'>
{dateForCohort(cohort)}
</div>
</td>
<td>
<div className='retention__table__size'>
<FormattedNumber value={cohort.data[0].value} />
</div>
</td>
{cohort.data.slice(1).map(retention => (
<td key={retention.date}>
<div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.percent * 100)}`)}>
<FormattedNumber value={retention.percent} style='percent' />
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
return (
<div className='retention'>
<h4><FormattedMessage id='admin.dashboard.retention' defaultMessage='Retention' /></h4>
{content}
</div>
);
}
}

View File

@ -0,0 +1,73 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Hashtag from 'mastodon/components/hashtag';
export default class Trends extends React.PureComponent {
static propTypes = {
limit: PropTypes.number.isRequired,
};
state = {
loading: true,
data: null,
};
componentDidMount () {
const { limit } = this.props;
api().get('/api/v1/admin/trends', { params: { limit } }).then(res => {
this.setState({
loading: false,
data: res.data,
});
}).catch(err => {
console.error(err);
});
}
render () {
const { limit } = this.props;
const { loading, data } = this.state;
let content;
if (loading) {
content = (
<div>
{Array.from(Array(limit)).map((_, i) => (
<Hashtag key={i} />
))}
</div>
);
} else {
content = (
<div>
{data.map(hashtag => (
<Hashtag
key={hashtag.name}
name={hashtag.name}
href={`/admin/tags/${hashtag.id}`}
people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
history={hashtag.history.reverse().map(day => day.uses)}
className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')}
/>
))}
</div>
);
}
return (
<div className='trends trends--compact'>
<h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
{content}
</div>
);
}
}

View File

@ -2,6 +2,8 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
@ -16,29 +18,13 @@ export default class AttachmentList extends ImmutablePureComponent {
render () { render () {
const { media, compact } = this.props; const { media, compact } = this.props;
if (compact) {
return (
<div className='attachment-list compact'>
<ul className='attachment-list__list'>
{media.map(attachment => {
const displayUrl = attachment.get('remote_url') || attachment.get('url');
return (
<li key={attachment.get('id')}>
<a href={displayUrl} target='_blank' rel='noopener noreferrer'><Icon id='link' /> {filename(displayUrl)}</a>
</li>
);
})}
</ul>
</div>
);
}
return ( return (
<div className='attachment-list'> <div className={classNames('attachment-list', { compact })}>
<div className='attachment-list__icon'> {!compact && (
<Icon id='link' /> <div className='attachment-list__icon'>
</div> <Icon id='link' />
</div>
)}
<ul className='attachment-list__list'> <ul className='attachment-list__list'>
{media.map(attachment => { {media.map(attachment => {
@ -46,7 +32,11 @@ export default class AttachmentList extends ImmutablePureComponent {
return ( return (
<li key={attachment.get('id')}> <li key={attachment.get('id')}>
<a href={displayUrl} target='_blank' rel='noopener noreferrer'>{filename(displayUrl)}</a> <a href={displayUrl} target='_blank' rel='noopener noreferrer'>
{compact && <Icon id='link' />}
{compact && ' ' }
{displayUrl ? filename(displayUrl) : <FormattedMessage id='attachments_list.unprocessed' defaultMessage='(unprocessed)' />}
</a>
</li> </li>
); );
})} })}

View File

@ -119,8 +119,8 @@ class ColumnHeader extends React.PureComponent {
moveButtons = ( moveButtons = (
<div key='move-buttons' className='column-header__setting-arrows'> <div key='move-buttons' className='column-header__setting-arrows'>
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button> <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button>
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button> <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
</div> </div>
); );
} else if (multiColumn && this.props.onPin) { } else if (multiColumn && this.props.onPin) {
@ -141,8 +141,8 @@ class ColumnHeader extends React.PureComponent {
]; ];
if (multiColumn) { if (multiColumn) {
collapsedContent.push(moveButtons);
collapsedContent.push(pinButton); collapsedContent.push(pinButton);
collapsedContent.push(moveButtons);
} }
if (children || (multiColumn && this.props.onPin)) { if (children || (multiColumn && this.props.onPin)) {

View File

@ -6,6 +6,8 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import Permalink from './permalink'; import Permalink from './permalink';
import ShortNumber from 'mastodon/components/short_number'; import ShortNumber from 'mastodon/components/short_number';
import Skeleton from 'mastodon/components/skeleton';
import classNames from 'classnames';
class SilentErrorBoundary extends React.Component { class SilentErrorBoundary extends React.Component {
@ -47,45 +49,38 @@ const accountsCountRenderer = (displayNumber, pluralReady) => (
/> />
); );
const Hashtag = ({ hashtag }) => ( export const ImmutableHashtag = ({ hashtag }) => (
<div className='trends__item'> <Hashtag
name={hashtag.get('name')}
href={hashtag.get('url')}
to={`/tags/${hashtag.get('name')}`}
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
uses={hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1}
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
/>
);
ImmutableHashtag.propTypes = {
hashtag: ImmutablePropTypes.map.isRequired,
};
const Hashtag = ({ name, href, to, people, uses, history, className }) => (
<div className={classNames('trends__item', className)}>
<div className='trends__item__name'> <div className='trends__item__name'>
<Permalink <Permalink href={href} to={to}>
href={hashtag.get('url')} {name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
to={`/timelines/tag/${hashtag.get('name')}`}
>
#<span>{hashtag.get('name')}</span>
</Permalink> </Permalink>
<ShortNumber {typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}
value={
hashtag.getIn(['history', 0, 'accounts']) * 1 +
hashtag.getIn(['history', 1, 'accounts']) * 1
}
renderer={accountsCountRenderer}
/>
</div> </div>
<div className='trends__item__current'> <div className='trends__item__current'>
<ShortNumber {typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />}
value={
hashtag.getIn(['history', 0, 'uses']) * 1 +
hashtag.getIn(['history', 1, 'uses']) * 1
}
/>
</div> </div>
<div className='trends__item__sparkline'> <div className='trends__item__sparkline'>
<SilentErrorBoundary> <SilentErrorBoundary>
<Sparklines <Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
width={50}
height={28}
data={hashtag
.get('history')
.reverse()
.map((day) => day.get('uses'))
.toArray()}
>
<SparklinesCurve style={{ fill: 'none' }} /> <SparklinesCurve style={{ fill: 'none' }} />
</Sparklines> </Sparklines>
</SilentErrorBoundary> </SilentErrorBoundary>
@ -94,7 +89,13 @@ const Hashtag = ({ hashtag }) => (
); );
Hashtag.propTypes = { Hashtag.propTypes = {
hashtag: ImmutablePropTypes.map.isRequired, name: PropTypes.string,
href: PropTypes.string,
to: PropTypes.string,
people: PropTypes.number,
uses: PropTypes.number,
history: PropTypes.arrayOf(PropTypes.number),
className: PropTypes.string,
}; };
export default Hashtag; export default Hashtag;

View File

@ -93,7 +93,7 @@ export default class IntersectionObserverArticle extends React.Component {
// When the browser gets a chance, test if we're still not intersecting, // When the browser gets a chance, test if we're still not intersecting,
// and if so, set our isHidden to true to trigger an unrender. The point of // and if so, set our isHidden to true to trigger an unrender. The point of
// this is to save DOM nodes and avoid using up too much memory. // this is to save DOM nodes and avoid using up too much memory.
// See: https://github.com/tootsuite/mastodon/issues/2900 // See: https://github.com/mastodon/mastodon/issues/2900
this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
} }

View File

@ -1,10 +1,15 @@
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 { createBrowserHistory } from 'history';
import { multiply } from 'color-blend'; import { multiply } from 'color-blend';
export default class ModalRoot extends React.PureComponent { export default class ModalRoot extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = { static propTypes = {
children: PropTypes.node, children: PropTypes.node,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
@ -48,6 +53,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 : createBrowserHistory();
} }
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
@ -69,6 +75,14 @@ export default class ModalRoot extends React.PureComponent {
this.activeElement.focus({ preventScroll: true }); this.activeElement.focus({ preventScroll: true });
this.activeElement = null; this.activeElement = null;
}).catch(console.error); }).catch(console.error);
this._handleModalClose();
}
if (this.props.children && !prevProps.children) {
this._handleModalOpen();
}
if (this.props.children) {
this._ensureHistoryBuffer();
} }
} }
@ -77,6 +91,32 @@ export default class ModalRoot extends React.PureComponent {
window.removeEventListener('keydown', this.handleKeyDown); window.removeEventListener('keydown', this.handleKeyDown);
} }
_handleModalOpen () {
this._modalHistoryKey = Date.now();
this.unlistenHistory = this.history.listen((_, action) => {
if (action === 'POP') {
this.props.onClose();
}
});
}
_handleModalClose () {
if (this.unlistenHistory) {
this.unlistenHistory();
}
const { state } = this.history.location;
if (state && state.mastodonModalKey === this._modalHistoryKey) {
this.history.goBack();
}
}
_ensureHistoryBuffer () {
const { pathname, state } = this.history.location;
if (!state || state.mastodonModalKey !== this._modalHistoryKey) {
this.history.push(pathname, { ...state, mastodonModalKey: this._modalHistoryKey });
}
}
getSiblings = () => { getSiblings = () => {
return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node); return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
} }

View File

@ -12,8 +12,18 @@ import RelativeTimestamp from './relative_timestamp';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
const messages = defineMessages({ const messages = defineMessages({
closed: { id: 'poll.closed', defaultMessage: 'Closed' }, closed: {
voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', description: 'Tooltip of the "voted" checkmark in polls' }, id: 'poll.closed',
defaultMessage: 'Closed',
},
voted: {
id: 'poll.voted',
defaultMessage: 'You voted for this answer',
},
votes: {
id: 'poll.votes',
defaultMessage: '{votes, plural, one {# vote} other {# votes}}',
},
}); });
const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => { const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
@ -148,9 +158,16 @@ class Poll extends ImmutablePureComponent {
data-index={optionIndex} data-index={optionIndex}
/> />
)} )}
{showResults && <span className='poll__number'> {showResults && (
{Math.round(percent)}% <span
</span>} className='poll__number'
title={intl.formatMessage(messages.votes, {
votes: option.get('votes_count'),
})}
>
{Math.round(percent)}%
</span>
)}
<span <span
className='poll__option__text translate' className='poll__option__text translate'

View File

@ -1,5 +1,5 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { ScrollContainer } from 'react-router-scroll-4'; import ScrollContainer from 'mastodon/containers/scroll_container';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
import LoadMore from './load_more'; import LoadMore from './load_more';
@ -34,7 +34,6 @@ class ScrollableList extends PureComponent {
onScrollToTop: PropTypes.func, onScrollToTop: PropTypes.func,
onScroll: PropTypes.func, onScroll: PropTypes.func,
trackScroll: PropTypes.bool, trackScroll: PropTypes.bool,
shouldUpdateScroll: PropTypes.func,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
showLoading: PropTypes.bool, showLoading: PropTypes.bool,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
@ -290,7 +289,7 @@ class ScrollableList extends PureComponent {
}; };
render () { render () {
const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props; const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
const { fullscreen } = this.state; const { fullscreen } = this.state;
const childrenCount = React.Children.count(children); const childrenCount = React.Children.count(children);
@ -356,7 +355,7 @@ class ScrollableList extends PureComponent {
if (trackScroll) { if (trackScroll) {
return ( return (
<ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}> <ScrollContainer scrollKey={scrollKey}>
{scrollableArea} {scrollableArea}
</ScrollContainer> </ScrollContainer>
); );

View File

@ -0,0 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>&zwnj;</span>;
Skeleton.propTypes = {
width: PropTypes.number,
height: PropTypes.number,
};
export default Skeleton;

View File

@ -134,42 +134,28 @@ class Status extends ImmutablePureComponent {
this.setState({ showMedia: !this.state.showMedia }); this.setState({ showMedia: !this.state.showMedia });
} }
handleClick = () => { handleClick = e => {
if (this.props.onClick) { if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
this.props.onClick();
return; return;
} }
if (!this.context.router) { if (e) {
return;
}
const { status } = this.props;
this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
}
handleExpandClick = (e) => {
if (this.props.onClick) {
this.props.onClick();
return;
}
if (e.button === 0) {
if (!this.context.router) {
return;
}
const { status } = this.props;
this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
}
}
handleAccountClick = (e) => {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
const id = e.currentTarget.getAttribute('data-id');
e.preventDefault(); e.preventDefault();
this.context.router.history.push(`/accounts/${id}`);
} }
this.handleHotkeyOpen();
}
handleAccountClick = e => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
}
if (e) {
e.preventDefault();
}
this.handleHotkeyOpenProfile();
} }
handleExpandedToggle = () => { handleExpandedToggle = () => {
@ -242,11 +228,30 @@ class Status extends ImmutablePureComponent {
} }
handleHotkeyOpen = () => { handleHotkeyOpen = () => {
this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`); if (this.props.onClick) {
this.props.onClick();
return;
}
const { router } = this.context;
const status = this._properStatus();
if (!router) {
return;
}
router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
} }
handleHotkeyOpenProfile = () => { handleHotkeyOpenProfile = () => {
this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`); const { router } = this.context;
const status = this._properStatus();
if (!router) {
return;
}
router.history.push(`/@${status.getIn(['account', 'acct'])}`);
} }
handleHotkeyMoveUp = e => { handleHotkeyMoveUp = e => {
@ -309,8 +314,8 @@ class Status extends ImmutablePureComponent {
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers}>
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex='0'> <div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex='0'>
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} <span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
{status.get('content')} <span>{status.get('content')}</span>
</div> </div>
</HotKeys> </HotKeys>
); );
@ -465,14 +470,15 @@ class Status extends ImmutablePureComponent {
{prepend} {prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted })} data-id={status.get('id')}> <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted })} data-id={status.get('id')}>
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' /> <div className='status__expand' onClick={this.handleClick} role='presentation' />
<div className='status__info'> <div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'> <a onClick={this.handleClick} href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span> <span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<RelativeTimestamp timestamp={status.get('created_at')} /> <RelativeTimestamp timestamp={status.get('created_at')} />
</a> </a>
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'> <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar'> <div className='status__avatar'>
{statusAvatar} {statusAvatar}
</div> </div>

View File

@ -187,8 +187,8 @@ class StatusActionBar extends ImmutablePureComponent {
}; };
handleOpen = () => { handleOpen = () => {
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`);
}; }
handleEmbed = () => { handleEmbed = () => {
this.props.onEmbed(this.props.status); this.props.onEmbed(this.props.status);

View File

@ -111,7 +111,7 @@ export default class StatusContent extends React.PureComponent {
onMentionClick = (mention, e) => { onMentionClick = (mention, e) => {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.preventDefault();
this.context.router.history.push(`/accounts/${mention.get('id')}`); this.context.router.history.push(`/@${mention.get('acct')}`);
} }
}; };
@ -120,7 +120,7 @@ export default class StatusContent extends React.PureComponent {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.preventDefault();
this.context.router.history.push(`/timelines/tag/${hashtag}`); this.context.router.history.push(`/tags/${hashtag}`);
} }
}; };
@ -217,14 +217,9 @@ export default class StatusContent extends React.PureComponent {
let mentionsPlaceholder = ''; let mentionsPlaceholder = '';
const mentionLinks = status.get('mentions').map(item => ( const mentionLinks = status.get('mentions').map(item => (
<Permalink <Permalink to={`/@${item.get('acct')}`} href={item.get('url')} key={item.get('id')} className='mention'>
to={`/accounts/${item.get('id')}`} @<span>{item.get('username')}</span>
href={item.get('url')} </Permalink>
key={item.get('id')}
className='mention'
>
@<span >{item.get('username')}</span >
</Permalink >
)).reduce((aggregate, item) => [...aggregate, item, ' '], []); )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
const toggleText = hidden ? (<FormattedMessage const toggleText = hidden ? (<FormattedMessage

View File

@ -18,7 +18,6 @@ export default class StatusList extends ImmutablePureComponent {
onScrollToTop: PropTypes.func, onScrollToTop: PropTypes.func,
onScroll: PropTypes.func, onScroll: PropTypes.func,
trackScroll: PropTypes.bool, trackScroll: PropTypes.bool,
shouldUpdateScroll: PropTypes.func,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
isPartial: PropTypes.bool, isPartial: PropTypes.bool,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
@ -77,7 +76,7 @@ export default class StatusList extends ImmutablePureComponent {
} }
render () { render () {
const { statusIds, featuredStatusIds, shouldUpdateScroll, onLoadMore, timelineId, ...other } = this.props; const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other } = this.props;
const { isLoading, isPartial } = other; const { isLoading, isPartial } = other;
if (isPartial) { if (isPartial) {
@ -120,7 +119,7 @@ export default class StatusList extends ImmutablePureComponent {
} }
return ( return (
<ScrollableList {...other} showLoading={isLoading && statusIds.size === 0} onLoadMore={onLoadMore && this.handleLoadOlder} shouldUpdateScroll={shouldUpdateScroll} ref={this.setRef}> <ScrollableList {...other} showLoading={isLoading && statusIds.size === 0} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
{scrollableContent} {scrollableContent}
</ScrollableList> </ScrollableList>
); );

View File

@ -0,0 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
export default class AdminComponent extends React.PureComponent {
static propTypes = {
locale: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
};
render () {
const { locale, children } = this.props;
return (
<IntlProvider locale={locale} messages={messages}>
{children}
</IntlProvider>
);
}
}

View File

@ -10,8 +10,6 @@ import { hydrateStore } from '../actions/store';
import { connectUserStream } from '../actions/streaming'; import { connectUserStream } from '../actions/streaming';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales'; import { getLocale } from '../locales';
import { previewState as previewMediaState } from 'mastodon/features/ui/components/media_modal';
import { previewState as previewVideoState } from 'mastodon/features/ui/components/video_modal';
import initialState from '../initial_state'; import initialState from '../initial_state';
import ErrorBoundary from '../components/error_boundary'; import ErrorBoundary from '../components/error_boundary';
@ -24,14 +22,38 @@ const hydrateAction = hydrateStore(initialState);
store.dispatch(hydrateAction); store.dispatch(hydrateAction);
store.dispatch(fetchCustomEmojis()); store.dispatch(fetchCustomEmojis());
const createIdentityContext = state => ({
signedIn: !!state.meta.me,
accountId: state.meta.me,
accessToken: state.meta.access_token,
});
export default class Mastodon extends React.PureComponent { export default class Mastodon extends React.PureComponent {
static propTypes = { static propTypes = {
locale: PropTypes.string.isRequired, locale: PropTypes.string.isRequired,
}; };
static childContextTypes = {
identity: PropTypes.shape({
signedIn: PropTypes.bool.isRequired,
accountId: PropTypes.string,
accessToken: PropTypes.string,
}).isRequired,
};
identity = createIdentityContext(initialState);
getChildContext() {
return {
identity: this.identity,
};
}
componentDidMount() { componentDidMount() {
this.disconnect = store.dispatch(connectUserStream()); if (this.identity.signedIn) {
this.disconnect = store.dispatch(connectUserStream());
}
} }
componentWillUnmount () { componentWillUnmount () {
@ -41,8 +63,8 @@ export default class Mastodon extends React.PureComponent {
} }
} }
shouldUpdateScroll (_, { location }) { shouldUpdateScroll (prevRouterProps, { location }) {
return location.state !== previewMediaState && location.state !== previewVideoState; return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
} }
render () { render () {

View File

@ -7,7 +7,7 @@ import { getLocale } from 'mastodon/locales';
import { getScrollbarWidth } from 'mastodon/utils/scrollbar'; import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
import MediaGallery from 'mastodon/components/media_gallery'; import MediaGallery from 'mastodon/components/media_gallery';
import Poll from 'mastodon/components/poll'; import Poll from 'mastodon/components/poll';
import Hashtag from 'mastodon/components/hashtag'; import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
import ModalRoot from 'mastodon/components/modal_root'; import ModalRoot from 'mastodon/components/modal_root';
import MediaModal from 'mastodon/features/ui/components/media_modal'; import MediaModal from 'mastodon/features/ui/components/media_modal';
import Video from 'mastodon/features/video'; import Video from 'mastodon/features/video';

View File

@ -0,0 +1,18 @@
import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4';
// ScrollContainer is used to automatically scroll to the top when pushing a
// new history state and remembering the scroll position when going back.
// There are a few things we need to do differently, though.
const defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
// If the change is caused by opening a modal, do not scroll to top
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
};
export default
class ScrollContainer extends OriginalScrollContainer {
static defaultProps = {
shouldUpdateScroll: defaultShouldUpdateScroll,
};
}

View File

@ -332,21 +332,21 @@ class Header extends ImmutablePureComponent {
{!suspended && ( {!suspended && (
<div className='account__header__extra__links'> <div className='account__header__extra__links'>
<NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}> <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/@${account.get('acct')}`} title={intl.formatNumber(account.get('statuses_count'))}>
<ShortNumber <ShortNumber
value={account.get('statuses_count')} value={account.get('statuses_count')}
renderer={counterRenderer('statuses')} renderer={counterRenderer('statuses')}
/> />
</NavLink> </NavLink>
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}> <NavLink exact activeClassName='active' to={`/@${account.get('acct')}/following`} title={intl.formatNumber(account.get('following_count'))}>
<ShortNumber <ShortNumber
value={account.get('following_count')} value={account.get('following_count')}
renderer={counterRenderer('following')} renderer={counterRenderer('following')}
/> />
</NavLink> </NavLink>
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}> <NavLink exact activeClassName='active' to={`/@${account.get('acct')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
<ShortNumber <ShortNumber
value={account.get('followers_count')} value={account.get('followers_count')}
renderer={counterRenderer('followers')} renderer={counterRenderer('followers')}

View File

@ -2,7 +2,7 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { fetchAccount } from 'mastodon/actions/accounts'; import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
import { expandAccountMediaTimeline } from '../../actions/timelines'; import { expandAccountMediaTimeline } from '../../actions/timelines';
import LoadingIndicator from 'mastodon/components/loading_indicator'; import LoadingIndicator from 'mastodon/components/loading_indicator';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
@ -11,25 +11,35 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { getAccountGallery } from 'mastodon/selectors'; import { getAccountGallery } from 'mastodon/selectors';
import MediaItem from './components/media_item'; import MediaItem from './components/media_item';
import HeaderContainer from '../account_timeline/containers/header_container'; import HeaderContainer from '../account_timeline/containers/header_container';
import { ScrollContainer } from 'react-router-scroll-4'; import ScrollContainer from 'mastodon/containers/scroll_container';
import LoadMore from 'mastodon/components/load_more'; import LoadMore from 'mastodon/components/load_more';
import MissingIndicator from 'mastodon/components/missing_indicator'; import MissingIndicator from 'mastodon/components/missing_indicator';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, { params: { acct, id } }) => {
isAccount: !!state.getIn(['accounts', props.params.accountId]), const accountId = id || state.getIn(['accounts_map', acct]);
attachments: getAccountGallery(state, props.params.accountId),
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), if (!accountId) {
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']), return {
suspended: state.getIn(['accounts', props.params.accountId, 'suspended'], false), isLoading: true,
blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false), };
}); }
return {
accountId,
isAccount: !!state.getIn(['accounts', accountId]),
attachments: getAccountGallery(state, accountId),
isLoading: state.getIn(['timelines', `account:${accountId}:media`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${accountId}:media`, 'hasMore']),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
};
};
class LoadMoreMedia extends ImmutablePureComponent { class LoadMoreMedia extends ImmutablePureComponent {
static propTypes = { static propTypes = {
shouldUpdateScroll: PropTypes.func,
maxId: PropTypes.string, maxId: PropTypes.string,
onLoadMore: PropTypes.func.isRequired, onLoadMore: PropTypes.func.isRequired,
}; };
@ -53,7 +63,11 @@ export default @connect(mapStateToProps)
class AccountGallery extends ImmutablePureComponent { class AccountGallery extends ImmutablePureComponent {
static propTypes = { static propTypes = {
params: PropTypes.object.isRequired, params: PropTypes.shape({
acct: PropTypes.string,
id: PropTypes.string,
}).isRequired,
accountId: PropTypes.string,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
attachments: ImmutablePropTypes.list.isRequired, attachments: ImmutablePropTypes.list.isRequired,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
@ -68,15 +82,30 @@ class AccountGallery extends ImmutablePureComponent {
width: 323, width: 323,
}; };
componentDidMount () { _load () {
this.props.dispatch(fetchAccount(this.props.params.accountId)); const { accountId, isAccount, dispatch } = this.props;
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
if (!isAccount) dispatch(fetchAccount(accountId));
dispatch(expandAccountMediaTimeline(accountId));
} }
componentWillReceiveProps (nextProps) { componentDidMount () {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { const { params: { acct }, accountId, dispatch } = this.props;
this.props.dispatch(fetchAccount(nextProps.params.accountId));
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); if (accountId) {
this._load();
} else {
dispatch(lookupAccount(acct));
}
}
componentDidUpdate (prevProps) {
const { params: { acct }, accountId, dispatch } = this.props;
if (prevProps.accountId !== accountId && accountId) {
this._load();
} else if (prevProps.params.acct !== acct) {
dispatch(lookupAccount(acct));
} }
} }
@ -96,7 +125,7 @@ class AccountGallery extends ImmutablePureComponent {
} }
handleLoadMore = maxId => { handleLoadMore = maxId => {
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId })); this.props.dispatch(expandAccountMediaTimeline(this.props.accountId, { maxId }));
}; };
handleLoadOlder = e => { handleLoadOlder = e => {
@ -127,7 +156,7 @@ class AccountGallery extends ImmutablePureComponent {
} }
render () { render () {
const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount, multiColumn, blockedBy, suspended } = this.props; const { attachments, isLoading, hasMore, isAccount, multiColumn, blockedBy, suspended } = this.props;
const { width } = this.state; const { width } = this.state;
if (!isAccount) { if (!isAccount) {
@ -164,9 +193,9 @@ class AccountGallery extends ImmutablePureComponent {
<Column> <Column>
<ColumnBackButton multiColumn={multiColumn} /> <ColumnBackButton multiColumn={multiColumn} />
<ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={shouldUpdateScroll}> <ScrollContainer scrollKey='account_gallery'>
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}> <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
<HeaderContainer accountId={this.props.params.accountId} /> <HeaderContainer accountId={this.props.accountId} />
{(suspended || blockedBy) ? ( {(suspended || blockedBy) ? (
<div className='empty-column-indicator'> <div className='empty-column-indicator'>

View File

@ -123,9 +123,9 @@ export default class Header extends ImmutablePureComponent {
{!hideTabs && ( {!hideTabs && (
<div className='account__section-headline'> <div className='account__section-headline'>
<NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink> <NavLink exact to={`/@${account.get('acct')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink>
<NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots and replies' /></NavLink> <NavLink exact to={`/@${account.get('acct')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots and replies' /></NavLink>
<NavLink exact to={`/accounts/${account.get('id')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink> <NavLink exact to={`/@${account.get('acct')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
</div> </div>
)} )}
</div> </div>

View File

@ -21,7 +21,7 @@ export default class MovedNote extends ImmutablePureComponent {
handleAccountClick = e => { handleAccountClick = e => {
if (e.button === 0) { if (e.button === 0) {
e.preventDefault(); e.preventDefault();
this.context.router.history.push(`/accounts/${this.props.to.get('id')}`); this.context.router.history.push(`/@${this.props.to.get('acct')}`);
} }
e.stopPropagation(); e.stopPropagation();

View File

@ -2,7 +2,7 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { fetchAccount } from '../../actions/accounts'; import { lookupAccount, fetchAccount } from '../../actions/accounts';
import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines'; import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines';
import StatusList from '../../components/status_list'; import StatusList from '../../components/status_list';
import LoadingIndicator from '../../components/loading_indicator'; import LoadingIndicator from '../../components/loading_indicator';
@ -20,10 +20,19 @@ import { connectTimeline, disconnectTimeline } from 'mastodon/actions/timelines'
const emptyList = ImmutableList(); const emptyList = ImmutableList();
const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => { const mapStateToProps = (state, { params: { acct, id }, withReplies = false }) => {
const accountId = id || state.getIn(['accounts_map', acct]);
if (!accountId) {
return {
isLoading: true,
};
}
const path = withReplies ? `${accountId}:with_replies` : accountId; const path = withReplies ? `${accountId}:with_replies` : accountId;
return { return {
accountId,
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])), remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
remoteUrl: state.getIn(['accounts', accountId, 'url']), remoteUrl: state.getIn(['accounts', accountId, 'url']),
isAccount: !!state.getIn(['accounts', accountId]), isAccount: !!state.getIn(['accounts', accountId]),
@ -48,9 +57,12 @@ export default @connect(mapStateToProps)
class AccountTimeline extends ImmutablePureComponent { class AccountTimeline extends ImmutablePureComponent {
static propTypes = { static propTypes = {
params: PropTypes.object.isRequired, params: PropTypes.shape({
acct: PropTypes.string,
id: PropTypes.string,
}).isRequired,
accountId: PropTypes.string,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func,
statusIds: ImmutablePropTypes.list, statusIds: ImmutablePropTypes.list,
featuredStatusIds: ImmutablePropTypes.list, featuredStatusIds: ImmutablePropTypes.list,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
@ -64,8 +76,8 @@ class AccountTimeline extends ImmutablePureComponent {
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
}; };
componentWillMount () { _load () {
const { params: { accountId }, withReplies, dispatch } = this.props; const { accountId, withReplies, dispatch } = this.props;
dispatch(fetchAccount(accountId)); dispatch(fetchAccount(accountId));
dispatch(fetchAccountIdentityProofs(accountId)); dispatch(fetchAccountIdentityProofs(accountId));
@ -81,29 +93,32 @@ class AccountTimeline extends ImmutablePureComponent {
} }
} }
componentWillReceiveProps (nextProps) { componentDidMount () {
const { dispatch } = this.props; const { params: { acct }, accountId, dispatch } = this.props;
if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) { if (accountId) {
dispatch(fetchAccount(nextProps.params.accountId)); this._load();
dispatch(fetchAccountIdentityProofs(nextProps.params.accountId)); } else {
dispatch(lookupAccount(acct));
}
}
if (!nextProps.withReplies) { componentDidUpdate (prevProps) {
dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId)); const { params: { acct }, accountId, dispatch } = this.props;
}
dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies })); if (prevProps.accountId !== accountId && accountId) {
this._load();
} else if (prevProps.params.acct !== acct) {
dispatch(lookupAccount(acct));
} }
if (nextProps.params.accountId === me && this.props.params.accountId !== me) { if (prevProps.accountId === me && accountId !== me) {
dispatch(connectTimeline(`account:${me}`));
} else if (this.props.params.accountId === me && nextProps.params.accountId !== me) {
dispatch(disconnectTimeline(`account:${me}`)); dispatch(disconnectTimeline(`account:${me}`));
} }
} }
componentWillUnmount () { componentWillUnmount () {
const { dispatch, params: { accountId } } = this.props; const { dispatch, accountId } = this.props;
if (accountId === me) { if (accountId === me) {
dispatch(disconnectTimeline(`account:${me}`)); dispatch(disconnectTimeline(`account:${me}`));
@ -111,11 +126,11 @@ class AccountTimeline extends ImmutablePureComponent {
} }
handleLoadMore = maxId => { handleLoadMore = maxId => {
this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies })); this.props.dispatch(expandAccountTimeline(this.props.accountId, { maxId, withReplies: this.props.withReplies }));
} }
render () { render () {
const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props; const { statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props;
if (!isAccount) { if (!isAccount) {
return ( return (
@ -153,7 +168,7 @@ class AccountTimeline extends ImmutablePureComponent {
<ColumnBackButton multiColumn={multiColumn} /> <ColumnBackButton multiColumn={multiColumn} />
<StatusList <StatusList
prepend={<HeaderContainer accountId={this.props.params.accountId} />} prepend={<HeaderContainer accountId={this.props.accountId} />}
alwaysPrepend alwaysPrepend
append={remoteMessage} append={remoteMessage}
scrollKey='account_timeline' scrollKey='account_timeline'
@ -162,7 +177,6 @@ class AccountTimeline extends ImmutablePureComponent {
isLoading={isLoading} isLoading={isLoading}
hasMore={hasMore} hasMore={hasMore}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
timelineId='account' timelineId='account'

View File

@ -29,7 +29,6 @@ class Blocks extends ImmutablePureComponent {
static propTypes = { static propTypes = {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func,
accountIds: ImmutablePropTypes.list, accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
@ -46,7 +45,7 @@ class Blocks extends ImmutablePureComponent {
}, 300, { leading: true }); }, 300, { leading: true });
render () { render () {
const { intl, accountIds, shouldUpdateScroll, hasMore, multiColumn, isLoading } = this.props; const { intl, accountIds, hasMore, multiColumn, isLoading } = this.props;
if (!accountIds) { if (!accountIds) {
return ( return (
@ -66,7 +65,6 @@ class Blocks extends ImmutablePureComponent {
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
hasMore={hasMore} hasMore={hasMore}
isLoading={isLoading} isLoading={isLoading}
shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >

View File

@ -27,7 +27,6 @@ class Bookmarks extends ImmutablePureComponent {
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func,
statusIds: ImmutablePropTypes.list.isRequired, statusIds: ImmutablePropTypes.list.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
columnId: PropTypes.string, columnId: PropTypes.string,
@ -68,7 +67,7 @@ class Bookmarks extends ImmutablePureComponent {
}, 300, { leading: true }) }, 300, { leading: true })
render () { render () {
const { intl, shouldUpdateScroll, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked toots yet. When you bookmark one, it will show up here." />; const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked toots yet. When you bookmark one, it will show up here." />;
@ -93,7 +92,6 @@ class Bookmarks extends ImmutablePureComponent {
hasMore={hasMore} hasMore={hasMore}
isLoading={isLoading} isLoading={isLoading}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
/> />

View File

@ -41,7 +41,6 @@ class CommunityTimeline extends React.PureComponent {
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func,
columnId: PropTypes.string, columnId: PropTypes.string,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool, hasUnread: PropTypes.bool,
@ -103,7 +102,7 @@ class CommunityTimeline extends React.PureComponent {
} }
render () { render () {
const { intl, shouldUpdateScroll, hasUnread, columnId, multiColumn, onlyMedia } = this.props; const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
return ( return (
@ -127,7 +126,6 @@ class CommunityTimeline extends React.PureComponent {
timelineId={`community${onlyMedia ? ':media' : ''}`} timelineId={`community${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
shouldUpdateScroll={shouldUpdateScroll}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
/> />
</Column> </Column>

View File

@ -21,7 +21,7 @@ export default class NavigationBar extends ImmutablePureComponent {
<div className='navigation-bar'> <div className='navigation-bar'>
<Permalink <Permalink
href={this.props.account.get('url')} href={this.props.account.get('url')}
to={`/accounts/${this.props.account.get('id')}`} to={`/@${this.props.account.get('acct')}`}
> >
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span > <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span >
<Avatar <Avatar
@ -33,7 +33,7 @@ export default class NavigationBar extends ImmutablePureComponent {
<div className='navigation-bar__profile'> <div className='navigation-bar__profile'>
<Permalink <Permalink
href={this.props.account.get('url')} href={this.props.account.get('url')}
to={`/accounts/${this.props.account.get('id')}`} to={`/@${this.props.account.get('acct')}`}
> >
<strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong > <strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong >
</Permalink > </Permalink >

View File

@ -32,7 +32,7 @@ class ReplyIndicator extends ImmutablePureComponent {
handleAccountClick = (e) => { handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.preventDefault();
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
} }
} }

View File

@ -5,7 +5,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import AccountContainer from '../../../containers/account_container'; import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container'; import StatusContainer from '../../../containers/status_container';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Hashtag from '../../../components/hashtag'; import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import { searchEnabled } from '../../../initial_state'; import { searchEnabled } from '../../../initial_state';
import LoadMore from 'mastodon/components/load_more'; import LoadMore from 'mastodon/components/load_more';

View File

@ -21,6 +21,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(openModal('CONFIRM', { dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.logoutMessage), message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm), confirm: intl.formatMessage(messages.logoutConfirm),
closeWhenConfirm: false,
onConfirm: () => logOut(), onConfirm: () => logOut(),
})); }));
}, },

View File

@ -1,7 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import Upload from '../components/upload'; import Upload from '../components/upload';
import { undoUploadCompose } from '../../../actions/compose'; import { undoUploadCompose, initMediaEditModal } from '../../../actions/compose';
import { openModal } from '../../../actions/modal';
import { submitCompose } from '../../../actions/compose'; import { submitCompose } from '../../../actions/compose';
const mapStateToProps = (state, { id }) => ({ const mapStateToProps = (state, { id }) => ({
@ -15,7 +14,7 @@ const mapDispatchToProps = dispatch => ({
}, },
onOpenFocalPoint: id => { onOpenFocalPoint: id => {
dispatch(openModal('FOCAL_POINT', { id })); dispatch(initMediaEditModal(id));
}, },
onSubmit (router) { onSubmit (router) {

View File

@ -4,13 +4,14 @@ import NavigationContainer from './containers/navigation_container';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose'; import { mountCompose, unmountCompose } from '../../actions/compose';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { defineMessages, injectIntl } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import SearchContainer from './containers/search_container'; import SearchContainer from './containers/search_container';
import Motion from '../ui/util/optional_motion'; import Motion from '../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import SearchResultsContainer from './containers/search_results_container'; import SearchResultsContainer from './containers/search_results_container';
import { changeComposing } from '../../actions/compose';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import cipherblissLogo from '../../../images/logo_cipherbliss.png'; import cipherblissLogo from '../../../images/logo_cipherbliss.png';
import { mascot } from '../../initial_state'; import { mascot } from '../../initial_state';
@ -48,7 +49,7 @@ class Compose extends React.PureComponent {
intl : PropTypes.object.isRequired, intl : PropTypes.object.isRequired,
}; };
componentDidMount() { componentDidMount () {
const { isSearchPage } = this.props; const { isSearchPage } = this.props;
if (!isSearchPage) { if (!isSearchPage) {
@ -56,7 +57,7 @@ class Compose extends React.PureComponent {
} }
} }
componentWillUnmount() { componentWillUnmount () {
const { isSearchPage } = this.props; const { isSearchPage } = this.props;
if (!isSearchPage) { if (!isSearchPage) {
@ -71,23 +72,24 @@ class Compose extends React.PureComponent {
e.stopPropagation(); e.stopPropagation();
dispatch(openModal('CONFIRM', { dispatch(openModal('CONFIRM', {
message : intl.formatMessage(messages.logoutMessage), message: intl.formatMessage(messages.logoutMessage),
confirm : intl.formatMessage(messages.logoutConfirm), confirm: intl.formatMessage(messages.logoutConfirm),
closeWhenConfirm: false,
onConfirm: () => logOut(), onConfirm: () => logOut(),
})); }));
return false; return false;
}; }
onFocus = () => { onFocus = () => {
this.props.dispatch(changeComposing(true)); this.props.dispatch(changeComposing(true));
}; }
onBlur = () => { onBlur = () => {
this.props.dispatch(changeComposing(false)); this.props.dispatch(changeComposing(false));
}; }
render() { render () {
const { multiColumn, showSearch, isSearchPage, intl } = this.props; const { multiColumn, showSearch, isSearchPage, intl } = this.props;
let header = ''; let header = '';
@ -96,97 +98,33 @@ class Compose extends React.PureComponent {
const { columns } = this.props; const { columns } = this.props;
header = ( header = (
<nav className='drawer__header'> <nav className='drawer__header'>
<Link <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><Icon id='bars' fixedWidth /></Link>
to='/getting-started'
className='drawer__tab'
title={intl.formatMessage(messages.start)}
aria-label={intl.formatMessage(messages.start)}
><Icon
id='bars'
fixedWidth
/></Link >
{!columns.some(column => column.get('id') === 'HOME') && ( {!columns.some(column => column.get('id') === 'HOME') && (
<Link <Link to='/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><Icon id='home' fixedWidth /></Link>
to='/timelines/home'
className='drawer__tab'
title={intl.formatMessage(messages.home_timeline)}
aria-label={intl.formatMessage(messages.home_timeline)}
><Icon
id='home'
fixedWidth
/></Link >
)} )}
{!columns.some(column => column.get('id') === 'NOTIFICATIONS') && ( {!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
<Link <Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><Icon id='bell' fixedWidth /></Link>
to='/notifications'
className='drawer__tab'
title={intl.formatMessage(messages.notifications)}
aria-label={intl.formatMessage(messages.notifications)}
><Icon
id='bell'
fixedWidth
/></Link >
)} )}
{!columns.some(column => column.get('id') === 'COMMUNITY') && ( {!columns.some(column => column.get('id') === 'COMMUNITY') && (
<Link <Link to='/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><Icon id='users' fixedWidth /></Link>
to='/timelines/public/local'
className='drawer__tab'
title={intl.formatMessage(messages.community)}
aria-label={intl.formatMessage(messages.community)}
><Icon
id='users'
fixedWidth
/></Link >
)} )}
{!columns.some(column => column.get('id') === 'PUBLIC') && ( {!columns.some(column => column.get('id') === 'PUBLIC') && (
<Link <Link to='/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' fixedWidth /></Link>
to='/timelines/public'
className='drawer__tab'
title={intl.formatMessage(messages.public)}
aria-label={intl.formatMessage(messages.public)}
><Icon
id='globe'
fixedWidth
/></Link >
)} )}
<a <a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><Icon id='cog' fixedWidth /></a>
href='/settings/preferences' <a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' fixedWidth /></a>
className='drawer__tab' </nav>
title={intl.formatMessage(messages.preferences)}
aria-label={intl.formatMessage(messages.preferences)}
><Icon
id='cog'
fixedWidth
/></a >
<a
href='/auth/sign_out'
className='drawer__tab'
title={intl.formatMessage(messages.logout)}
aria-label={intl.formatMessage(messages.logout)}
onClick={this.handleLogoutClick}
><Icon
id='sign-out'
fixedWidth
/></a >
</nav >
); );
} }
return ( return (
<div <div className='drawer' role='region' aria-label={intl.formatMessage(messages.compose)}>
className='drawer'
role='region'
aria-label={intl.formatMessage(messages.compose)}
>
{header} {header}
{(multiColumn || isSearchPage) && <SearchContainer />} {(multiColumn || isSearchPage) && <SearchContainer /> }
<div className='drawer__pager'> <div className='drawer__pager'>
{!isSearchPage && <div {!isSearchPage && <div className='drawer__inner' onFocus={this.onFocus}>
className='drawer__inner'
onFocus={this.onFocus}
>
<NavigationContainer onClose={this.onBlur} /> <NavigationContainer onClose={this.onBlur} />
<ComposeFormContainer /> <ComposeFormContainer />
@ -201,21 +139,15 @@ class Compose extends React.PureComponent {
</div > </div >
</div >} </div >}
<Motion <Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
defaultStyle={{ x: isSearchPage ? 0 : -100 }}
style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}
>
{({ x }) => ( {({ x }) => (
<div <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
className='drawer__inner darker'
style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}
>
<SearchResultsContainer /> <SearchResultsContainer />
</div > </div>
)} )}
</Motion > </Motion>
</div > </div>
</div > </div>
); );
} }

View File

@ -81,7 +81,7 @@ class Conversation extends ImmutablePureComponent {
markRead(); markRead();
} }
this.context.router.history.push(`/statuses/${lastStatus.get('id')}`); this.context.router.history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
} }
handleMarkAsRead = () => { handleMarkAsRead = () => {
@ -133,7 +133,7 @@ class Conversation extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
const names = accounts.map(a => <Permalink to={`/accounts/${a.get('id')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]); const names = accounts.map(a => <Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]);
const handlers = { const handlers = {
reply: this.handleReply, reply: this.handleReply,

View File

@ -14,7 +14,6 @@ export default class ConversationsList extends ImmutablePureComponent {
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
onLoadMore: PropTypes.func, onLoadMore: PropTypes.func,
shouldUpdateScroll: PropTypes.func,
}; };
getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id) getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id)

View File

@ -19,7 +19,6 @@ class DirectTimeline extends React.PureComponent {
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func,
columnId: PropTypes.string, columnId: PropTypes.string,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool, hasUnread: PropTypes.bool,
@ -71,7 +70,7 @@ class DirectTimeline extends React.PureComponent {
} }
render () { render () {
const { intl, hasUnread, columnId, multiColumn, shouldUpdateScroll } = this.props; const { intl, hasUnread, columnId, multiColumn } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
return ( return (
@ -93,7 +92,6 @@ class DirectTimeline extends React.PureComponent {
timelineId='direct' timelineId='direct'
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />} emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
shouldUpdateScroll={shouldUpdateScroll}
/> />
</Column> </Column>
); );

View File

@ -208,20 +208,15 @@ class AccountCard extends ImmutablePureComponent {
/> />
</div > </div >
<div className='columns'> <div className='directory__card__bar'>
<div className='column'> <Permalink
<div className='directory__card__bar'> className='directory__card__bar__name'
<Permalink href={account.get('url')}
className='directory__card__bar__name' to={`/@${account.get('acct')}`}
href={account.get('url')} >
to={`/accounts/${account.get('id')}`} <Avatar account={account} size={48} />
> <DisplayName account={account} />
<Avatar </Permalink>
account={account}
size={48}
/>
<DisplayName account={account} />
</Permalink >
<div className='directory__card__bar__relationship account__relationship'> <div className='directory__card__bar__relationship account__relationship'>
{buttons} {buttons}
@ -276,8 +271,6 @@ class AccountCard extends ImmutablePureComponent {
</div > </div >
</div > </div >
</div > </div >
</div >
</div >
); );
} }

View File

@ -12,7 +12,7 @@ import AccountCard from './components/account_card';
import RadioButton from 'mastodon/components/radio_button'; import RadioButton from 'mastodon/components/radio_button';
import classNames from 'classnames'; import classNames from 'classnames';
import LoadMore from 'mastodon/components/load_more'; import LoadMore from 'mastodon/components/load_more';
import { ScrollContainer } from 'react-router-scroll-4'; import ScrollContainer from 'mastodon/containers/scroll_container';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.directory', defaultMessage: 'Browse profiles' }, title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
@ -40,7 +40,6 @@ class Directory extends React.PureComponent {
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
accountIds: ImmutablePropTypes.list.isRequired, accountIds: ImmutablePropTypes.list.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func,
columnId: PropTypes.string, columnId: PropTypes.string,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
@ -125,7 +124,7 @@ class Directory extends React.PureComponent {
} }
render () { render () {
const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props; const { isLoading, accountIds, intl, columnId, multiColumn, domain } = this.props;
const { order, local } = this.getParams(this.props, this.state); const { order, local } = this.getParams(this.props, this.state);
const pinned = !!columnId; const pinned = !!columnId;
@ -163,7 +162,7 @@ class Directory extends React.PureComponent {
multiColumn={multiColumn} multiColumn={multiColumn}
/> />
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea} {multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
</Column> </Column>
); );
} }

View File

@ -29,7 +29,6 @@ class Blocks extends ImmutablePureComponent {
static propTypes = { static propTypes = {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
domains: ImmutablePropTypes.orderedSet, domains: ImmutablePropTypes.orderedSet,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
@ -45,7 +44,7 @@ class Blocks extends ImmutablePureComponent {
}, 300, { leading: true }); }, 300, { leading: true });
render () { render () {
const { intl, domains, shouldUpdateScroll, hasMore, multiColumn } = this.props; const { intl, domains, hasMore, multiColumn } = this.props;
if (!domains) { if (!domains) {
return ( return (
@ -67,7 +66,6 @@ class Blocks extends ImmutablePureComponent {
scrollKey='domain_blocks' scrollKey='domain_blocks'
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
hasMore={hasMore} hasMore={hasMore}
shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >

File diff suppressed because one or more lines are too long

View File

@ -27,7 +27,6 @@ class Favourites extends ImmutablePureComponent {
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func,
statusIds: ImmutablePropTypes.list.isRequired, statusIds: ImmutablePropTypes.list.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
columnId: PropTypes.string, columnId: PropTypes.string,
@ -68,7 +67,7 @@ class Favourites extends ImmutablePureComponent {
}, 300, { leading: true }) }, 300, { leading: true })
render () { render () {
const { intl, shouldUpdateScroll, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite toots yet. When you favourite one, it will show up here." />; const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite toots yet. When you favourite one, it will show up here." />;
@ -93,7 +92,6 @@ class Favourites extends ImmutablePureComponent {
hasMore={hasMore} hasMore={hasMore}
isLoading={isLoading} isLoading={isLoading}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
/> />

View File

@ -27,7 +27,6 @@ class Favourites extends ImmutablePureComponent {
static propTypes = { static propTypes = {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func,
accountIds: ImmutablePropTypes.list, accountIds: ImmutablePropTypes.list,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
@ -50,7 +49,7 @@ class Favourites extends ImmutablePureComponent {
} }
render () { render () {
const { intl, shouldUpdateScroll, accountIds, multiColumn } = this.props; const { intl, accountIds, multiColumn } = this.props;
if (!accountIds) { if (!accountIds) {
return ( return (
@ -74,7 +73,6 @@ class Favourites extends ImmutablePureComponent {
<ScrollableList <ScrollableList
scrollKey='favourites' scrollKey='favourites'
shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >

View File

@ -66,7 +66,7 @@ class Account extends ImmutablePureComponent {
return ( return (
<div className='account follow-recommendations-account'> <div className='account follow-recommendations-account'>
<div className='account__wrapper'> <div className='account__wrapper'>
<Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}> <Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} /> <DisplayName account={account} />

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