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:
- &defaults
orbs:
ruby: circleci/ruby@1.2.0
node: circleci/node@4.7.0
executors:
default:
parameters:
ruby-version:
type: string
docker:
- image: circleci/ruby:2.7-buster-node
environment: &ruby_environment
- image: cimg/ruby:<< parameters.ruby-version >>
environment:
BUNDLE_JOBS: 3
BUNDLE_RETRY: 3
BUNDLE_APP_CONFIG: ./.bundle/
BUNDLE_PATH: ./vendor/bundle/
CONTINUOUS_INTEGRATION: true
DB_HOST: localhost
DB_USER: root
RAILS_ENV: test
ALLOW_NOPAM: true
CONTINUOUS_INTEGRATION: true
DISABLE_SIMPLECOV: true
PAM_ENABLED: true
PAM_DEFAULT_SERVICE: pam_test
PAM_CONTROLLED_SERVICE: pam_test_controlled
working_directory: ~/projects/mastodon/
RAILS_ENV: test
- image: cimg/postgres:12.7
environment:
POSTGRES_USER: root
POSTGRES_HOST_AUTH_METHOD: trust
- image: circleci/redis:5-alpine
- &attach_workspace
attach_workspace:
at: ~/projects/
commands:
install-system-dependencies:
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
persist_to_workspace:
root: ~/projects/
paths:
- ./mastodon/
- &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
jobs:
build:
docker:
- image: cimg/ruby:2.7-node
environment:
RAILS_ENV: test
steps:
- checkout
- *attach_workspace
- restore_cache:
keys:
- v2-node-dependencies-{{ checksum "yarn.lock" }}
- v2-node-dependencies-
- install-system-dependencies
- install-ruby-dependencies:
ruby-version: '2.7'
- node/install-packages:
cache-version: v1
pkg-manager: yarn
- 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
name: Precompile assets
- persist_to_workspace:
root: ~/projects/
paths:
- ./mastodon/public/assets
- ./mastodon/public/packs-test/
- public/assets
- 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:
<<: *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
executor:
name: default
ruby-version: '2.7'
steps:
- *attach_workspace
- *install_system_dependencies
- checkout
- install-system-dependencies
- install-ruby-dependencies:
ruby-version: '2.7'
- wait-db
- run:
name: Create database
command: ./bin/rails db:create
name: Create database
- run:
name: Run migrations
command: ./bin/rails db:migrate
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
name: Run migrations
workflows:
version: 2
build-and-test:
jobs:
- install
- install-ruby2.7:
- build
- test:
matrix:
parameters:
ruby-version:
- '2.7'
- '3.0'
name: test-ruby<< matrix.ruby-version >>
requires:
- install
- install-ruby2.6:
requires:
- install
- install-ruby2.7
- install-ruby3.0:
requires:
- install
- install-ruby2.7
- build:
requires:
- install-ruby2.7
- build
- test-migrations:
requires:
- install-ruby2.7
- test-ruby2.7:
requires:
- install-ruby2.7
- build
- test-ruby2.6:
- node/run:
cache-version: v1
name: test-webui
pkg-manager: yarn
requires:
- install-ruby2.6
- build
- test-ruby3.0:
requires:
- install-ruby3.0
- build
- test-webui:
requires:
- install
version: lts
yarn-run: test:jest

View File

@ -35,4 +35,7 @@ plugins:
enabled: true
exclude_patterns:
- 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_IMAGE_KEY='image'
# CAS_PHONE_KEY='phone'
# CAS_SECURITY_ASSUME_EMAIL_IS_VERIFIED=true
# Optional SAML authentication (cf. omniauth-saml)
# SAML_ENABLED=true

2
.github/CODEOWNERS vendored
View File

@ -1,4 +1,4 @@
# CODEOWNERS for tootsuite/mastodon
# CODEOWNERS for mastodon/mastodon
# 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.

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
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:

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
=======
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:
* [Gargron](https://github.com/Gargron)
@ -719,7 +719,7 @@ and provided thanks to the work of the following contributors:
* [西小倉宏信](mailto:nishiko@mindia.jp)
* [雨宮美羽](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

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 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
@ -44,4 +44,4 @@ It is not always possible to phrase every change in such a manner, but it is des
## 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
SHELL ["/bin/bash", "-c"]
# Install Node v12 (LTS)
ENV NODE_VER="12.21.0"
# Install Node v14 (LTS)
ENV NODE_VER="14.17.6"
RUN ARCH= && \
dpkgArch="$(dpkg --print-architecture)" && \
case "${dpkgArch##*-}" in \
@ -26,7 +26,7 @@ RUN ARCH= && \
mv node-v$NODE_VER-linux-$ARCH /opt/node
# Install Ruby
ENV RUBY_VER="2.7.2"
ENV RUBY_VER="2.7.4"
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential \
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 && \
bundle config set deployment 'true' && \
bundle config set without 'development test' && \
bundle config set silence_root_warning true && \
bundle install -j"$(nproc)" && \
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 'puma', '~> 5.3'
gem 'rails', '~> 6.1.3'
gem 'puma', '~> 5.5'
gem 'rails', '~> 6.1.4'
gem 'sprockets', '~> 3.7.2'
gem 'thor', '~> 1.1'
gem 'rack', '~> 2.2.3'
@ -17,15 +17,15 @@ gem 'makara', '~> 0.5'
gem 'pghero', '~> 2.8'
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-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0'
gem 'kt-paperclip', '~> 7.0'
gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.7'
gem 'bootsnap', '~> 1.6.0', require: false
gem 'addressable', '~> 2.8'
gem 'bootsnap', '~> 1.9.1', require: false
gem 'browser'
gem 'charlock_holmes', '~> 0.7.7'
gem 'iso-639'
@ -53,19 +53,18 @@ gem 'fastimage'
gem 'hiredis', '~> 0.6'
gem 'redis-namespace', '~> 1.8'
gem 'htmlentities', '~> 4.3'
gem 'http', '~> 4.4'
gem 'http', '~> 5.0'
gem 'http_accept_language', '~> 2.1'
gem 'httplog', '~> 1.5.0'
gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.3.1', require: 'mime/types/columnar'
gem 'nokogiri', '~> 1.11'
gem 'nokogiri', '~> 1.12'
gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.11'
gem 'oj', '~> 3.13'
gem 'ox', '~> 2.14'
gem 'parslet'
gem 'parallel', '~> 1.20'
gem 'posix-spawn'
gem 'pundit', '~> 2.1'
gem 'premailer-rails'
@ -73,15 +72,15 @@ gem 'rack-attack', '~> 6.5'
gem 'rack-cors', '~> 1.1', require: 'rack/cors'
gem 'rails-i18n', '~> 6.0'
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 'rqrcode', '~> 2.0'
gem 'rqrcode', '~> 2.1'
gem 'ruby-progressbar', '~> 1.11'
gem 'sanitize', '~> 5.2'
gem 'sanitize', '~> 6.0'
gem 'scenic', '~> 1.5'
gem 'sidekiq', '~> 6.2'
gem 'sidekiq-scheduler', '~> 3.0'
gem 'sidekiq-unique-jobs', '~> 7.0'
gem 'sidekiq-scheduler', '~> 3.1'
gem 'sidekiq-unique-jobs', '~> 7.1'
gem 'sidekiq-bulk', '~>0.2.0'
gem 'simple-navigation', '~> 4.3'
gem 'simple_form', '~> 5.1'
@ -115,13 +114,12 @@ end
group :test do
gem 'capybara', '~> 3.35'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.18'
gem 'faker', '~> 2.19'
gem 'microformats', '~> 4.2'
gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.1'
gem 'simplecov', '~> 0.21', require: false
gem 'webmock', '~> 3.13'
gem 'parallel_tests', '~> 3.7'
gem 'webmock', '~> 3.14'
gem 'rspec_junit_formatter', '~> 0.4'
end
@ -134,10 +132,10 @@ group :development do
gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.4'
gem 'memory_profiler'
gem 'rubocop', '~> 1.15', require: false
gem 'rubocop-rails', '~> 2.10', require: false
gem 'brakeman', '~> 5.0', require: false
gem 'bundler-audit', '~> 0.8', require: false
gem 'rubocop', '~> 1.22', require: false
gem 'rubocop-rails', '~> 2.12', require: false
gem 'brakeman', '~> 5.1', require: false
gem 'bundler-audit', '~> 0.9', require: false
gem 'capistrano', '~> 3.16'
gem 'capistrano-rails', '~> 1.6'
@ -155,5 +153,3 @@ gem 'concurrent-ruby', require: false
gem 'connection_pool', require: false
gem 'xorcist', '~> 1.1'
gem 'resolv', '~> 0.1.0'

View File

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

View File

@ -1,15 +1,15 @@
![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]
[![Build Status](https://img.shields.io/circleci/project/github/tootsuite/mastodon.svg)][circleci]
[![Code Climate](https://img.shields.io/codeclimate/maintainability/tootsuite/mastodon.svg)][code_climate]
[![GitHub release](https://img.shields.io/github/release/mastodon/mastodon.svg)][releases]
[![Build Status](https://img.shields.io/circleci/project/github/mastodon/mastodon.svg)][circleci]
[![Code Climate](https://img.shields.io/codeclimate/maintainability/mastodon/mastodon.svg)][code_climate]
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin]
[![Docker Pulls](https://img.shields.io/docker/pulls/tootsuite/mastodon.svg)][docker]
[releases]: https://github.com/tootsuite/mastodon/releases
[circleci]: https://circleci.com/gh/tootsuite/mastodon
[code_climate]: https://codeclimate.com/github/tootsuite/mastodon
[releases]: https://github.com/mastodon/mastodon/releases
[circleci]: https://circleci.com/gh/mastodon/mastodon
[code_climate]: https://codeclimate.com/github/mastodon/mastodon
[crowdin]: https://crowdin.com/project/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.
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

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'
# 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
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
read RUBY_VERSION < .ruby-version
gpg_command="gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB"
$($gpg_command)
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://rvm.io/mpapis.asc | gpg --import
curl -sSL https://rvm.io/pkuczynski.asc | gpg --import
curl -sSL https://raw.githubusercontent.com/rvm/rvm/stable/binscripts/rvm-installer | bash -s stable --ruby=$RUBY_VERSION
source /home/vagrant/.rvm/scripts/rvm

View File

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

View File

@ -19,11 +19,11 @@ class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseContro
private
def uri_prefix
signed_request_account.uri[/http(s?):\/\/[^\/]+\//]
signed_request_account.uri[Account::URL_PREFIX_RE]
end
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
def collection_presenter

View File

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

View File

@ -1,49 +1,17 @@
# frozen_string_literal: true
require 'sidekiq/api'
module Admin
class DashboardController < BaseController
def index
@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
@registrations_week = Redis.current.get("activity:accounts:local:#{current_week}") || 0
@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_reports_count = Report.unresolved.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
private
def current_week
@current_week ||= Time.now.utc.to_date.cweek
end
def redis_info
@redis_info ||= begin
if Redis.current.is_a?(Redis::Namespace)

View File

@ -6,9 +6,9 @@ module Admin
def create
authorize @user, :reset_password?
@user.send_reset_password_instructions
@user.reset_password!
log_action :reset_password, @user
redirect_to admin_accounts_path
redirect_to admin_account_path(@user.account_id)
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!
log_action :disable_2fa, @user
UserMailer.two_factor_disabled(@user).deliver_later!
redirect_to admin_accounts_path
redirect_to admin_account_path(@user.account_id)
end
private

View File

@ -40,7 +40,12 @@ class Api::BaseController < ApplicationController
render json: { error: 'This action is not allowed' }, status: 403
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
end

View File

@ -1,8 +1,8 @@
# frozen_string_literal: true
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 -> { doorkeeper_authorize! :follow, :'write:follows' }, only: [:follow, :unfollow]
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, :remove_from_followers]
before_action -> { doorkeeper_authorize! :follow, :'write:mutes' }, only: [:mute, :unmute]
before_action -> { doorkeeper_authorize! :follow, :'write:blocks' }, only: [:block, :unblock]
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
end
def remove_from_followers
RemoveFromFollowersService.new.call(current_user.account, @account)
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end
def unblock
UnblockService.new.call(current_user.account, @account)
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
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|
day = i.weeks.ago.to_date
week_id = day.cweek
week = Date.commercial(day.cwyear, week_id)
(0...12).map do |i|
start_of_week = i.weeks.ago
end_of_week = start_of_week + 6.days
weeks << {
week: week.to_time.to_i.to_s,
statuses: Redis.current.get("activity:statuses:local:#{week_id}") || '0',
logins: Redis.current.pfcount("activity:logins:#{week_id}").to_s,
registrations: Redis.current.get("activity:accounts:local:#{week_id}") || '0',
{
week: start_of_week.to_i.to_s,
statuses: statuses_tracker.sum(start_of_week, end_of_week).to_s,
logins: logins_tracker.sum(start_of_week, end_of_week).to_s,
registrations: registrations_tracker.sum(start_of_week, end_of_week).to_s,
}
end
weeks
end
def require_enabled_api!

View File

@ -26,7 +26,12 @@ class ApplicationController < ActionController::Base
rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
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 :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)
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
set_flash_message(:notice, :success, kind: provider_id.capitalize) if is_navigational_format?
else

View File

@ -25,9 +25,11 @@ class Auth::SessionsController < Devise::SessionsController
def create
super do |resource|
resource.update_sign_in!(request, new_sign_in: true)
remember_me(resource)
flash.delete(:notice)
# We only need to call this if this hasn't already been
# called from one of the two-factor or sign-in token
# authentication methods
on_authentication_success(resource, :password) unless @on_authentication_success_called
end
end
@ -40,11 +42,12 @@ class Auth::SessionsController < Devise::SessionsController
end
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(
allow: user.webauthn_credentials.pluck(:external_id)
allow: user.webauthn_credentials.pluck(:external_id),
user_verification: 'discouraged'
)
session[:webauthn_challenge] = options_for_get.challenge
@ -58,16 +61,20 @@ class Auth::SessionsController < Devise::SessionsController
protected
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])
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
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
params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt, credential: {})
end
@ -136,4 +143,34 @@ class Auth::SessionsController < Devise::SessionsController
session.delete(:attempt_user_id)
session.delete(:attempt_user_updated_at)
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

View File

@ -16,23 +16,26 @@ module SignInTokenAuthenticationConcern
end
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
restart_session
elsif user_params.key?(:sign_in_token_attempt) && session[:attempt_user_id]
authenticate_with_sign_in_token_attempt(user)
elsif user.present? && user.external_or_valid_password?(user_params[:password])
prompt_for_sign_in_token(user)
if session[:attempt_user_updated_at] != user.updated_at.to_s
restart_session
elsif user_params.key?(:sign_in_token_attempt)
authenticate_with_sign_in_token_attempt(user)
end
end
end
def authenticate_with_sign_in_token_attempt(user)
if valid_sign_in_token_attempt?(user)
clear_attempt_from_session
remember_me(user)
sign_in(user)
on_authentication_success(user, :sign_in_token)
else
on_authentication_failure(user, :sign_in_token, :invalid_sign_in_token)
flash.now[:alert] = I18n.t('users.invalid_sign_in_token')
prompt_for_sign_in_token(user)
end

View File

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

View File

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

View File

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

View File

@ -14,30 +14,7 @@ class HomeController < ApplicationController
def redirect_unauthenticated_to_permalinks!
return if user_signed_in?
matches = request.path.match(/\A\/web\/(statuses|accounts)\/([\d]+)\z/)
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)
redirect_to(PermalinkRedirector.new(request.path).redirect_path || default_redirect_path)
end
def default_redirect_path

View File

@ -27,7 +27,12 @@ class MediaController < ApplicationController
private
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
def verify_permitted_status!

View File

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

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
include RoutingHelper
before_action { response.headers['Vary'] = 'Accept' }
before_action :set_account
before_action :check_account_suspension
@ -39,10 +38,12 @@ module WellKnown
end
def bad_request
expires_in(3.minutes, public: true)
head 400
end
def not_found
expires_in(3.minutes, public: true)
head 404
end

View File

@ -80,17 +80,17 @@ module AccountsHelper
def account_description(account)
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),
].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),
].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),
].join(' '),
].join(', ')

View File

@ -14,6 +14,17 @@ module ApplicationHelper
ku
).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)
paths.any? { |path| current_page?(path) } ? 'active' : ''
end
@ -126,6 +137,10 @@ module ApplicationHelper
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
output = (@body_classes || '').split(' ')
output << "theme-#{current_theme.parameterize}"

View File

@ -41,6 +41,7 @@ module SettingsHelper
ka: 'ქართული',
kab: 'Taqbaylit',
kk: 'Қазақша',
kmr: 'Kurmancî',
kn: 'ಕನ್ನಡ',
ko: '한국어',
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_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_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS';
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) {
return {
type: ACCOUNT_FETCH_REQUEST,

View File

@ -7,7 +7,9 @@ import { useEmoji } from './emojis';
import resizeImage from '../utils/resize_image';
import { importFetchedAccounts } from './importer';
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';
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_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({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
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) => {
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,
text: text,
};
}
};
export function replyCompose(status, routerHistory) {
return (dispatch, getState) => {
@ -91,19 +98,19 @@ export function replyCompose(status, routerHistory) {
ensureComposeIsVisible(getState, routerHistory);
};
}
};
export function cancelReplyCompose() {
return {
type: COMPOSE_REPLY_CANCEL,
};
}
};
export function resetCompose() {
return {
type: COMPOSE_RESET,
};
}
};
export function mentionCompose(account, routerHistory) {
return (dispatch, getState) => {
@ -114,7 +121,7 @@ export function mentionCompose(account, routerHistory) {
ensureComposeIsVisible(getState, routerHistory);
};
}
};
export function directCompose(account, routerHistory) {
return (dispatch, getState) => {
@ -125,7 +132,7 @@ export function directCompose(account, routerHistory) {
ensureComposeIsVisible(getState, routerHistory);
};
}
};
export function submitCompose(routerHistory) {
return function (dispatch, getState) {
@ -151,7 +158,7 @@ export function submitCompose(routerHistory) {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
},
}).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();
}
@ -181,27 +188,27 @@ export function submitCompose(routerHistory) {
dispatch(submitComposeFail(error));
});
};
}
};
export function submitComposeRequest() {
return {
type: COMPOSE_SUBMIT_REQUEST,
};
}
};
export function submitComposeSuccess(status) {
return {
type: COMPOSE_SUBMIT_SUCCESS,
status: status,
};
}
};
export function submitComposeFail(error) {
return {
type: COMPOSE_SUBMIT_FAIL,
error: error,
};
}
};
export function uploadCompose(files) {
return function (dispatch, getState) {
@ -233,7 +240,7 @@ export function uploadCompose(files) {
total += file.size - f.size;
return api(getState).post('/api/v2/media', data, {
onUploadProgress: function ({ loaded }) {
onUploadProgress: function({ loaded }){
progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
},
@ -258,9 +265,9 @@ export function uploadCompose(files) {
}
});
}).catch(error => dispatch(uploadComposeFail(error)));
}
};
};
}
};
export const uploadThumbnail = (id, file) => (dispatch, getState) => {
dispatch(uploadThumbnailRequest());
@ -305,6 +312,32 @@ export const uploadThumbnailFail = error => ({
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) {
return (dispatch, getState) => {
dispatch(changeUploadComposeRequest());
@ -315,7 +348,7 @@ export function changeUploadCompose(id, params) {
dispatch(changeUploadComposeFail(id, error));
});
};
}
};
export function changeUploadComposeRequest() {
return {
@ -330,7 +363,7 @@ export function changeUploadComposeSuccess(media) {
media: media,
skipLoading: true,
};
}
};
export function changeUploadComposeFail(error) {
return {
@ -338,14 +371,14 @@ export function changeUploadComposeFail(error) {
error: error,
skipLoading: true,
};
}
};
export function uploadComposeRequest() {
return {
type: COMPOSE_UPLOAD_REQUEST,
skipLoading: true,
};
}
};
export function uploadComposeProgress(loaded, total) {
return {
@ -353,7 +386,7 @@ export function uploadComposeProgress(loaded, total) {
loaded: loaded,
total: total,
};
}
};
export function uploadComposeSuccess(media, file) {
return {
@ -362,7 +395,7 @@ export function uploadComposeSuccess(media, file) {
file: file,
skipLoading: true,
};
}
};
export function uploadComposeFail(error) {
return {
@ -370,14 +403,14 @@ export function uploadComposeFail(error) {
error: error,
skipLoading: true,
};
}
};
export function undoUploadCompose(media_id) {
return {
type: COMPOSE_UPLOAD_UNDO,
media_id: media_id,
};
}
};
export function clearComposeSuggestions() {
if (cancelFetchComposeSuggestionsAccounts) {
@ -386,7 +419,7 @@ export function clearComposeSuggestions() {
return {
type: COMPOSE_SUGGESTIONS_CLEAR,
};
}
};
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
if (cancelFetchComposeSuggestionsAccounts) {
@ -460,7 +493,7 @@ export function fetchComposeSuggestions(token) {
break;
}
};
}
};
export function readyComposeSuggestionsEmojis(token, emojis) {
return {
@ -468,7 +501,7 @@ export function readyComposeSuggestionsEmojis(token, emojis) {
token,
emojis,
};
}
};
export function readyComposeSuggestionsAccounts(token, accounts) {
return {
@ -476,7 +509,7 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
token,
accounts,
};
}
};
export const readyComposeSuggestionsTags = (token, tags) => ({
type: COMPOSE_SUGGESTIONS_READY,
@ -509,7 +542,7 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
path,
});
};
}
};
export function updateSuggestionTags(token) {
return {
@ -557,39 +590,39 @@ export function mountCompose() {
return {
type: COMPOSE_MOUNT,
};
}
};
export function unmountCompose() {
return {
type: COMPOSE_UNMOUNT,
};
}
};
export function changeComposeSensitivity() {
return {
type: COMPOSE_SENSITIVITY_CHANGE,
};
}
};
export function changeComposeSpoilerness() {
return {
type: COMPOSE_SPOILERNESS_CHANGE,
};
}
};
export function changeComposeSpoilerText(text) {
return {
type: COMPOSE_SPOILER_TEXT_CHANGE,
text,
};
}
};
export function changeComposeVisibility(value) {
return {
type: COMPOSE_VISIBILITY_CHANGE,
value,
};
}
};
export function insertEmojiCompose(position, emoji, needsSpace) {
return {
@ -598,33 +631,33 @@ export function insertEmojiCompose(position, emoji, needsSpace) {
emoji,
needsSpace,
};
}
};
export function changeComposing(value) {
return {
type: COMPOSE_COMPOSING_CHANGE,
value,
};
}
};
export function addPoll() {
return {
type: COMPOSE_POLL_ADD,
};
}
};
export function removePoll() {
return {
type: COMPOSE_POLL_REMOVE,
};
}
};
export function addPollOption(title) {
return {
type: COMPOSE_POLL_OPTION_ADD,
title,
};
}
};
export function changePollOption(index, title) {
return {
@ -632,14 +665,14 @@ export function changePollOption(index, title) {
index,
title,
};
}
};
export function removePollOption(index) {
return {
type: COMPOSE_POLL_OPTION_REMOVE,
index,
};
}
};
export function changePollSettings(expiresIn, isMultiple) {
return {
@ -647,4 +680,4 @@ export function changePollSettings(expiresIn, isMultiple) {
expiresIn,
isMultiple,
};
}
};

View File

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

View File

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

View File

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

View File

@ -118,7 +118,7 @@ class Account extends ImmutablePureComponent {
return (
<div className='account'>
<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>
{mute_expires_at}
<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 PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
@ -16,29 +18,13 @@ export default class AttachmentList extends ImmutablePureComponent {
render () {
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 (
<div className='attachment-list'>
<div className='attachment-list__icon'>
<Icon id='link' />
</div>
<div className={classNames('attachment-list', { compact })}>
{!compact && (
<div className='attachment-list__icon'>
<Icon id='link' />
</div>
)}
<ul className='attachment-list__list'>
{media.map(attachment => {
@ -46,7 +32,11 @@ export default class AttachmentList extends ImmutablePureComponent {
return (
<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>
);
})}

View File

@ -119,8 +119,8 @@ class ColumnHeader extends React.PureComponent {
moveButtons = (
<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.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.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='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
</div>
);
} else if (multiColumn && this.props.onPin) {
@ -141,8 +141,8 @@ class ColumnHeader extends React.PureComponent {
];
if (multiColumn) {
collapsedContent.push(moveButtons);
collapsedContent.push(pinButton);
collapsedContent.push(moveButtons);
}
if (children || (multiColumn && this.props.onPin)) {

View File

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

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,
// 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.
// See: https://github.com/tootsuite/mastodon/issues/2900
// See: https://github.com/mastodon/mastodon/issues/2900
this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
}

View File

@ -1,10 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import 'wicg-inert';
import { createBrowserHistory } from 'history';
import { multiply } from 'color-blend';
export default class ModalRoot extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
children: PropTypes.node,
onClose: PropTypes.func.isRequired,
@ -48,6 +53,7 @@ export default class ModalRoot extends React.PureComponent {
componentDidMount () {
window.addEventListener('keyup', this.handleKeyUp, false);
window.addEventListener('keydown', this.handleKeyDown, false);
this.history = this.context.router ? this.context.router.history : createBrowserHistory();
}
componentWillReceiveProps (nextProps) {
@ -69,6 +75,14 @@ export default class ModalRoot extends React.PureComponent {
this.activeElement.focus({ preventScroll: true });
this.activeElement = null;
}).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);
}
_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 = () => {
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';
const messages = defineMessages({
closed: { id: 'poll.closed', defaultMessage: 'Closed' },
voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', description: 'Tooltip of the "voted" checkmark in polls' },
closed: {
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) => {
@ -148,9 +158,16 @@ class Poll extends ImmutablePureComponent {
data-index={optionIndex}
/>
)}
{showResults && <span className='poll__number'>
{Math.round(percent)}%
</span>}
{showResults && (
<span
className='poll__number'
title={intl.formatMessage(messages.votes, {
votes: option.get('votes_count'),
})}
>
{Math.round(percent)}%
</span>
)}
<span
className='poll__option__text translate'

View File

@ -1,5 +1,5 @@
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 IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
import LoadMore from './load_more';
@ -34,7 +34,6 @@ class ScrollableList extends PureComponent {
onScrollToTop: PropTypes.func,
onScroll: PropTypes.func,
trackScroll: PropTypes.bool,
shouldUpdateScroll: PropTypes.func,
isLoading: PropTypes.bool,
showLoading: PropTypes.bool,
hasMore: PropTypes.bool,
@ -290,7 +289,7 @@ class ScrollableList extends PureComponent {
};
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 childrenCount = React.Children.count(children);
@ -356,7 +355,7 @@ class ScrollableList extends PureComponent {
if (trackScroll) {
return (
<ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
<ScrollContainer scrollKey={scrollKey}>
{scrollableArea}
</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 });
}
handleClick = () => {
if (this.props.onClick) {
this.props.onClick();
handleClick = e => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
}
if (!this.context.router) {
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');
if (e) {
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 = () => {
@ -242,11 +228,30 @@ class Status extends ImmutablePureComponent {
}
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 = () => {
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 => {
@ -309,8 +314,8 @@ class Status extends ImmutablePureComponent {
return (
<HotKeys handlers={handlers}>
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex='0'>
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
{status.get('content')}
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
<span>{status.get('content')}</span>
</div>
</HotKeys>
);
@ -465,14 +470,15 @@ class Status extends ImmutablePureComponent {
{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='status__expand' onClick={this.handleExpandClick} role='presentation' />
<div className='status__expand' onClick={this.handleClick} role='presentation' />
<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>
<RelativeTimestamp timestamp={status.get('created_at')} />
</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'>
{statusAvatar}
</div>

View File

@ -187,8 +187,8 @@ class StatusActionBar extends ImmutablePureComponent {
};
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 = () => {
this.props.onEmbed(this.props.status);

View File

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

View File

@ -18,7 +18,6 @@ export default class StatusList extends ImmutablePureComponent {
onScrollToTop: PropTypes.func,
onScroll: PropTypes.func,
trackScroll: PropTypes.bool,
shouldUpdateScroll: PropTypes.func,
isLoading: PropTypes.bool,
isPartial: PropTypes.bool,
hasMore: PropTypes.bool,
@ -77,7 +76,7 @@ export default class StatusList extends ImmutablePureComponent {
}
render () {
const { statusIds, featuredStatusIds, shouldUpdateScroll, onLoadMore, timelineId, ...other } = this.props;
const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other } = this.props;
const { isLoading, isPartial } = other;
if (isPartial) {
@ -120,7 +119,7 @@ export default class StatusList extends ImmutablePureComponent {
}
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}
</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 { IntlProvider, addLocaleData } from 'react-intl';
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 ErrorBoundary from '../components/error_boundary';
@ -24,14 +22,38 @@ const hydrateAction = hydrateStore(initialState);
store.dispatch(hydrateAction);
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 {
static propTypes = {
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() {
this.disconnect = store.dispatch(connectUserStream());
if (this.identity.signedIn) {
this.disconnect = store.dispatch(connectUserStream());
}
}
componentWillUnmount () {
@ -41,8 +63,8 @@ export default class Mastodon extends React.PureComponent {
}
}
shouldUpdateScroll (_, { location }) {
return location.state !== previewMediaState && location.state !== previewVideoState;
shouldUpdateScroll (prevRouterProps, { location }) {
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
}
render () {

View File

@ -7,7 +7,7 @@ import { getLocale } from 'mastodon/locales';
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
import MediaGallery from 'mastodon/components/media_gallery';
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 MediaModal from 'mastodon/features/ui/components/media_modal';
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 && (
<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
value={account.get('statuses_count')}
renderer={counterRenderer('statuses')}
/>
</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
value={account.get('following_count')}
renderer={counterRenderer('following')}
/>
</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
value={account.get('followers_count')}
renderer={counterRenderer('followers')}

View File

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

View File

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

View File

@ -21,7 +21,7 @@ export default class MovedNote extends ImmutablePureComponent {
handleAccountClick = e => {
if (e.button === 0) {
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();

View File

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

View File

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

View File

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

View File

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

View File

@ -21,7 +21,7 @@ export default class NavigationBar extends ImmutablePureComponent {
<div className='navigation-bar'>
<Permalink
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 >
<Avatar
@ -33,7 +33,7 @@ export default class NavigationBar extends ImmutablePureComponent {
<div className='navigation-bar__profile'>
<Permalink
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 >
</Permalink >

View File

@ -32,7 +32,7 @@ class ReplyIndicator extends ImmutablePureComponent {
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
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 StatusContainer from '../../../containers/status_container';
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 { searchEnabled } from '../../../initial_state';
import LoadMore from 'mastodon/components/load_more';

View File

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

View File

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

View File

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

View File

@ -81,7 +81,7 @@ class Conversation extends ImmutablePureComponent {
markRead();
}
this.context.router.history.push(`/statuses/${lastStatus.get('id')}`);
this.context.router.history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
}
handleMarkAsRead = () => {
@ -133,7 +133,7 @@ class Conversation extends ImmutablePureComponent {
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 = {
reply: this.handleReply,

View File

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

View File

@ -19,7 +19,6 @@ class DirectTimeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
@ -71,7 +70,7 @@ class DirectTimeline extends React.PureComponent {
}
render () {
const { intl, hasUnread, columnId, multiColumn, shouldUpdateScroll } = this.props;
const { intl, hasUnread, columnId, multiColumn } = this.props;
const pinned = !!columnId;
return (
@ -93,7 +92,6 @@ class DirectTimeline extends React.PureComponent {
timelineId='direct'
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." />}
shouldUpdateScroll={shouldUpdateScroll}
/>
</Column>
);

View File

@ -208,20 +208,15 @@ class AccountCard extends ImmutablePureComponent {
/>
</div >
<div className='columns'>
<div className='column'>
<div className='directory__card__bar'>
<Permalink
className='directory__card__bar__name'
href={account.get('url')}
to={`/accounts/${account.get('id')}`}
>
<Avatar
account={account}
size={48}
/>
<DisplayName account={account} />
</Permalink >
<div className='directory__card__bar'>
<Permalink
className='directory__card__bar__name'
href={account.get('url')}
to={`/@${account.get('acct')}`}
>
<Avatar account={account} size={48} />
<DisplayName account={account} />
</Permalink>
<div className='directory__card__bar__relationship account__relationship'>
{buttons}
@ -276,8 +271,6 @@ class AccountCard extends ImmutablePureComponent {
</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 classNames from 'classnames';
import LoadMore from 'mastodon/components/load_more';
import { ScrollContainer } from 'react-router-scroll-4';
import ScrollContainer from 'mastodon/containers/scroll_container';
const messages = defineMessages({
title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
@ -40,7 +40,6 @@ class Directory extends React.PureComponent {
isLoading: PropTypes.bool,
accountIds: ImmutablePropTypes.list.isRequired,
dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
@ -125,7 +124,7 @@ class Directory extends React.PureComponent {
}
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 pinned = !!columnId;
@ -163,7 +162,7 @@ class Directory extends React.PureComponent {
multiColumn={multiColumn}
/>
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea}
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
</Column>
);
}

View File

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

View File

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

View File

@ -66,7 +66,7 @@ class Account extends ImmutablePureComponent {
return (
<div className='account follow-recommendations-account'>
<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>
<DisplayName account={account} />

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