mirror of
https://framagit.org/tykayn/mastodon.git
synced 2023-08-25 08:33:12 +02:00
Merge branch 'master' of https://github.com/tootsuite/mastodon
This commit is contained in:
commit
e01b405c17
@ -3,7 +3,7 @@ version: 2
|
|||||||
aliases:
|
aliases:
|
||||||
- &defaults
|
- &defaults
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/ruby:2.6.0-stretch-node
|
- image: circleci/ruby:2.6-stretch-node
|
||||||
environment: &ruby_environment
|
environment: &ruby_environment
|
||||||
BUNDLE_APP_CONFIG: ./.bundle/
|
BUNDLE_APP_CONFIG: ./.bundle/
|
||||||
DB_HOST: localhost
|
DB_HOST: localhost
|
||||||
@ -105,14 +105,14 @@ jobs:
|
|||||||
install-ruby2.5:
|
install-ruby2.5:
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/ruby:2.5.3-stretch-node
|
- image: circleci/ruby:2.5-stretch-node
|
||||||
environment: *ruby_environment
|
environment: *ruby_environment
|
||||||
<<: *install_ruby_dependencies
|
<<: *install_ruby_dependencies
|
||||||
|
|
||||||
install-ruby2.4:
|
install-ruby2.4:
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/ruby:2.4.5-stretch-node
|
- image: circleci/ruby:2.4-stretch-node
|
||||||
environment: *ruby_environment
|
environment: *ruby_environment
|
||||||
<<: *install_ruby_dependencies
|
<<: *install_ruby_dependencies
|
||||||
|
|
||||||
@ -131,40 +131,40 @@ jobs:
|
|||||||
test-ruby2.6:
|
test-ruby2.6:
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/ruby:2.6.0-stretch-node
|
- image: circleci/ruby:2.6-stretch-node
|
||||||
environment: *ruby_environment
|
environment: *ruby_environment
|
||||||
- image: circleci/postgres:10.6-alpine
|
- image: circleci/postgres:10.6-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: root
|
POSTGRES_USER: root
|
||||||
- image: circleci/redis:5.0.3-alpine3.8
|
- image: circleci/redis:5-alpine
|
||||||
<<: *test_steps
|
<<: *test_steps
|
||||||
|
|
||||||
test-ruby2.5:
|
test-ruby2.5:
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/ruby:2.5.3-stretch-node
|
- image: circleci/ruby:2.5-stretch-node
|
||||||
environment: *ruby_environment
|
environment: *ruby_environment
|
||||||
- image: circleci/postgres:10.6-alpine
|
- image: circleci/postgres:10.6-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: root
|
POSTGRES_USER: root
|
||||||
- image: circleci/redis:4.0.12-alpine
|
- image: circleci/redis:5-alpine
|
||||||
<<: *test_steps
|
<<: *test_steps
|
||||||
|
|
||||||
test-ruby2.4:
|
test-ruby2.4:
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/ruby:2.4.5-stretch-node
|
- image: circleci/ruby:2.4-stretch-node
|
||||||
environment: *ruby_environment
|
environment: *ruby_environment
|
||||||
- image: circleci/postgres:10.6-alpine
|
- image: circleci/postgres:10.6-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: root
|
POSTGRES_USER: root
|
||||||
- image: circleci/redis:4.0.12-alpine
|
- image: circleci/redis:5-alpine
|
||||||
<<: *test_steps
|
<<: *test_steps
|
||||||
|
|
||||||
test-webui:
|
test-webui:
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/node:8.15.0-stretch
|
- image: circleci/node:12.9-stretch
|
||||||
steps:
|
steps:
|
||||||
- *attach_workspace
|
- *attach_workspace
|
||||||
- run: ./bin/retry yarn test:jest
|
- run: ./bin/retry yarn test:jest
|
||||||
@ -173,9 +173,11 @@ jobs:
|
|||||||
<<: *defaults
|
<<: *defaults
|
||||||
steps:
|
steps:
|
||||||
- *attach_workspace
|
- *attach_workspace
|
||||||
|
- *install_system_dependencies
|
||||||
- run: bundle exec i18n-tasks check-normalized
|
- run: bundle exec i18n-tasks check-normalized
|
||||||
- run: bundle exec i18n-tasks unused -l en
|
- run: bundle exec i18n-tasks unused -l en
|
||||||
- run: bundle exec i18n-tasks check-consistent-interpolations
|
- run: bundle exec i18n-tasks check-consistent-interpolations
|
||||||
|
- run: bundle exec rake repo:check_locales_files
|
||||||
|
|
||||||
workflows:
|
workflows:
|
||||||
version: 2
|
version: 2
|
||||||
|
@ -69,6 +69,7 @@ SMTP_PORT=587
|
|||||||
SMTP_LOGIN=
|
SMTP_LOGIN=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM_ADDRESS=notifications@example.com
|
SMTP_FROM_ADDRESS=notifications@example.com
|
||||||
|
#SMTP_REPLY_TO=
|
||||||
#SMTP_DOMAIN= # defaults to LOCAL_DOMAIN
|
#SMTP_DOMAIN= # defaults to LOCAL_DOMAIN
|
||||||
#SMTP_DELIVERY_METHOD=smtp # delivery method can also be sendmail
|
#SMTP_DELIVERY_METHOD=smtp # delivery method can also be sendmail
|
||||||
#SMTP_AUTH_METHOD=plain
|
#SMTP_AUTH_METHOD=plain
|
||||||
|
63
CHANGELOG.md
63
CHANGELOG.md
@ -3,6 +3,69 @@ Changelog
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
## [2.9.3] - 2019-08-10
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add GIF and WebP support for custom emojis ([Gargron](https://github.com/tootsuite/mastodon/pull/11519))
|
||||||
|
- Add logout link to dropdown menu in web UI ([koyuawsmbrtn](https://github.com/tootsuite/mastodon/pull/11353))
|
||||||
|
- Add indication that text search is unavailable in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11112), [ThibG](https://github.com/tootsuite/mastodon/pull/11202))
|
||||||
|
- Add `suffix` to `Mastodon::Version` to help forks ([clarfon](https://github.com/tootsuite/mastodon/pull/11407))
|
||||||
|
- Add on-hover animation to animated custom emoji in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11348), [ThibG](https://github.com/tootsuite/mastodon/pull/11404), [ThibG](https://github.com/tootsuite/mastodon/pull/11522))
|
||||||
|
- Add custom emoji support in profile metadata labels ([ThibG](https://github.com/tootsuite/mastodon/pull/11350))
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Change default interface of web and streaming from 0.0.0.0 to 127.0.0.1 ([Gargron](https://github.com/tootsuite/mastodon/pull/11302), [zunda](https://github.com/tootsuite/mastodon/pull/11378), [Gargron](https://github.com/tootsuite/mastodon/pull/11351), [zunda](https://github.com/tootsuite/mastodon/pull/11326))
|
||||||
|
- Change the retry limit of web push notifications ([highemerly](https://github.com/tootsuite/mastodon/pull/11292))
|
||||||
|
- Change ActivityPub deliveries to not retry HTTP 501 errors ([Gargron](https://github.com/tootsuite/mastodon/pull/11233))
|
||||||
|
- Change language detection to include hashtags as words ([Gargron](https://github.com/tootsuite/mastodon/pull/11341))
|
||||||
|
- Change terms and privacy policy pages to always be accessible ([Gargron](https://github.com/tootsuite/mastodon/pull/11334))
|
||||||
|
- Change robots tag to include `noarchive` when user opts out of indexing ([Kjwon15](https://github.com/tootsuite/mastodon/pull/11421))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix account domain block not clearing out notifications ([Gargron](https://github.com/tootsuite/mastodon/pull/11393))
|
||||||
|
- Fix incorrect locale sometimes being detected for browser ([Gargron](https://github.com/tootsuite/mastodon/pull/8657))
|
||||||
|
- Fix crash when saving invalid domain name ([Gargron](https://github.com/tootsuite/mastodon/pull/11528))
|
||||||
|
- Fix pinned statuses REST API returning pagination headers ([Gargron](https://github.com/tootsuite/mastodon/pull/11526))
|
||||||
|
- Fix "cancel follow request" button having unreadable text in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11521))
|
||||||
|
- Fix image uploads being blank when canvas read access is blocked ([ThibG](https://github.com/tootsuite/mastodon/pull/11499))
|
||||||
|
- Fix avatars not being animated on hover when not logged in ([ThibG](https://github.com/tootsuite/mastodon/pull/11349))
|
||||||
|
- Fix overzealous sanitization of HTML lists ([ThibG](https://github.com/tootsuite/mastodon/pull/11354))
|
||||||
|
- Fix block crashing when a follow request exists ([ThibG](https://github.com/tootsuite/mastodon/pull/11288))
|
||||||
|
- Fix backup service crashing when an attachment is missing ([ThibG](https://github.com/tootsuite/mastodon/pull/11241))
|
||||||
|
- Fix account moderation action always sending e-mail notification ([Gargron](https://github.com/tootsuite/mastodon/pull/11242))
|
||||||
|
- Fix swiping columns on mobile sometimes failing in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11200))
|
||||||
|
- Fix wrong actor URI being serialized into poll updates ([ThibG](https://github.com/tootsuite/mastodon/pull/11194))
|
||||||
|
- Fix statsd UDP sockets not being cleaned up in Sidekiq ([Gargron](https://github.com/tootsuite/mastodon/pull/11230))
|
||||||
|
- Fix expiration date of filters being set to "never" when editing them ([ThibG](https://github.com/tootsuite/mastodon/pull/11204))
|
||||||
|
- Fix support for MP4 files that are actually M4V files ([Gargron](https://github.com/tootsuite/mastodon/pull/11210))
|
||||||
|
- Fix `alerts` not being typecast correctly in push subscription in REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/11343))
|
||||||
|
- Fix some notices staying on unrelated pages ([ThibG](https://github.com/tootsuite/mastodon/pull/11364))
|
||||||
|
- Fix unboosting sometimes preventing a boost from reappearing on feed ([ThibG](https://github.com/tootsuite/mastodon/pull/11405), [Gargron](https://github.com/tootsuite/mastodon/pull/11450))
|
||||||
|
- Fix only one middle dot being recognized in hashtags ([Gargron](https://github.com/tootsuite/mastodon/pull/11345), [ThibG](https://github.com/tootsuite/mastodon/pull/11363))
|
||||||
|
- Fix unnecessary SQL query performed on unauthenticated requests ([Gargron](https://github.com/tootsuite/mastodon/pull/11179))
|
||||||
|
- Fix incorrect timestamp displayed on featured tags ([Kjwon15](https://github.com/tootsuite/mastodon/pull/11477))
|
||||||
|
- Fix privacy dropdown active state when dropdown is placed on top of it ([ThibG](https://github.com/tootsuite/mastodon/pull/11495))
|
||||||
|
- Fix filters not being applied to poll options ([ThibG](https://github.com/tootsuite/mastodon/pull/11174))
|
||||||
|
- Fix keyboard navigation on various dropdowns ([ThibG](https://github.com/tootsuite/mastodon/pull/11511), [ThibG](https://github.com/tootsuite/mastodon/pull/11492), [ThibG](https://github.com/tootsuite/mastodon/pull/11491))
|
||||||
|
- Fix keyboard navigation in modals ([ThibG](https://github.com/tootsuite/mastodon/pull/11493))
|
||||||
|
- Fix image conversation being non-deterministic due to timestamps ([Gargron](https://github.com/tootsuite/mastodon/pull/11408))
|
||||||
|
- Fix web UI performance ([ThibG](https://github.com/tootsuite/mastodon/pull/11211), [ThibG](https://github.com/tootsuite/mastodon/pull/11234))
|
||||||
|
- Fix scrolling to compose form when not necessary in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11246), [ThibG](https://github.com/tootsuite/mastodon/pull/11182))
|
||||||
|
- Fix save button being enabled when list title is empty in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11475))
|
||||||
|
- Fix poll expiration not being pre-filled on delete & redraft in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11203))
|
||||||
|
- Fix content warning sometimes being set when not requested in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11206))
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Fix invites not being disabled upon account suspension ([ThibG](https://github.com/tootsuite/mastodon/pull/11412))
|
||||||
|
- Fix blocked domains still being able to fill database with account records ([Gargron](https://github.com/tootsuite/mastodon/pull/11219))
|
||||||
|
|
||||||
## [2.9.2] - 2019-06-22
|
## [2.9.2] - 2019-06-22
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
20
Dockerfile
20
Dockerfile
@ -4,22 +4,20 @@ FROM ubuntu:18.04 as build-dep
|
|||||||
SHELL ["bash", "-c"]
|
SHELL ["bash", "-c"]
|
||||||
|
|
||||||
# Install Node
|
# Install Node
|
||||||
ENV NODE_VER="8.15.0"
|
ENV NODE_VER="12.9.1"
|
||||||
RUN echo "Etc/UTC" > /etc/localtime && \
|
RUN echo "Etc/UTC" > /etc/localtime && \
|
||||||
apt update && \
|
apt update && \
|
||||||
apt -y install wget make gcc g++ python && \
|
apt -y install wget python && \
|
||||||
cd ~ && \
|
cd ~ && \
|
||||||
wget https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER.tar.gz && \
|
wget https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER-linux-x64.tar.gz && \
|
||||||
tar xf node-v$NODE_VER.tar.gz && \
|
tar xf node-v$NODE_VER-linux-x64.tar.gz && \
|
||||||
cd node-v$NODE_VER && \
|
rm node-v$NODE_VER-linux-x64.tar.gz && \
|
||||||
./configure --prefix=/opt/node && \
|
mv node-v$NODE_VER-linux-x64 /opt/node
|
||||||
make -j$(nproc) > /dev/null && \
|
|
||||||
make install
|
|
||||||
|
|
||||||
# Install jemalloc
|
# Install jemalloc
|
||||||
ENV JE_VER="5.1.0"
|
ENV JE_VER="5.2.1"
|
||||||
RUN apt update && \
|
RUN apt update && \
|
||||||
apt -y install autoconf && \
|
apt -y install make autoconf gcc g++ && \
|
||||||
cd ~ && \
|
cd ~ && \
|
||||||
wget https://github.com/jemalloc/jemalloc/archive/$JE_VER.tar.gz && \
|
wget https://github.com/jemalloc/jemalloc/archive/$JE_VER.tar.gz && \
|
||||||
tar xf $JE_VER.tar.gz && \
|
tar xf $JE_VER.tar.gz && \
|
||||||
@ -30,7 +28,7 @@ RUN apt update && \
|
|||||||
make install_bin install_include install_lib
|
make install_bin install_include install_lib
|
||||||
|
|
||||||
# Install ruby
|
# Install ruby
|
||||||
ENV RUBY_VER="2.6.1"
|
ENV RUBY_VER="2.6.4"
|
||||||
ENV CPPFLAGS="-I/opt/jemalloc/include"
|
ENV CPPFLAGS="-I/opt/jemalloc/include"
|
||||||
ENV LDFLAGS="-L/opt/jemalloc/lib/"
|
ENV LDFLAGS="-L/opt/jemalloc/lib/"
|
||||||
RUN apt update && \
|
RUN apt update && \
|
||||||
|
33
Gemfile
33
Gemfile
@ -12,10 +12,10 @@ gem 'thor', '~> 0.20'
|
|||||||
gem 'hamlit-rails', '~> 0.2'
|
gem 'hamlit-rails', '~> 0.2'
|
||||||
gem 'pg', '~> 1.1'
|
gem 'pg', '~> 1.1'
|
||||||
gem 'makara', '~> 0.4'
|
gem 'makara', '~> 0.4'
|
||||||
gem 'pghero', '~> 2.2'
|
gem 'pghero', '~> 2.3'
|
||||||
gem 'dotenv-rails', '~> 2.7'
|
gem 'dotenv-rails', '~> 2.7'
|
||||||
|
|
||||||
gem 'aws-sdk-s3', '~> 1.46', require: false
|
gem 'aws-sdk-s3', '~> 1.48', require: false
|
||||||
gem 'fog-core', '<= 2.1.0'
|
gem 'fog-core', '<= 2.1.0'
|
||||||
gem 'fog-openstack', '~> 0.3', require: false
|
gem 'fog-openstack', '~> 0.3', require: false
|
||||||
gem 'paperclip', '~> 6.0'
|
gem 'paperclip', '~> 6.0'
|
||||||
@ -24,14 +24,14 @@ gem 'streamio-ffmpeg', '~> 3.0'
|
|||||||
gem 'blurhash', '~> 0.1'
|
gem 'blurhash', '~> 0.1'
|
||||||
|
|
||||||
gem 'active_model_serializers', '~> 0.10'
|
gem 'active_model_serializers', '~> 0.10'
|
||||||
gem 'addressable', '~> 2.6'
|
gem 'addressable', '~> 2.7'
|
||||||
gem 'bootsnap', '~> 1.4', require: false
|
gem 'bootsnap', '~> 1.4', require: false
|
||||||
gem 'browser'
|
gem 'browser'
|
||||||
gem 'charlock_holmes', '~> 0.7.6'
|
gem 'charlock_holmes', '~> 0.7.6'
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'chewy', '~> 5.0'
|
gem 'chewy', '~> 5.0'
|
||||||
gem 'cld3', '~> 3.2.4'
|
gem 'cld3', '~> 3.2.4'
|
||||||
gem 'devise', '~> 4.6'
|
gem 'devise', '~> 4.7'
|
||||||
gem 'devise-two-factor', '~> 3.1'
|
gem 'devise-two-factor', '~> 3.1'
|
||||||
|
|
||||||
group :pam_authentication, optional: true do
|
group :pam_authentication, optional: true do
|
||||||
@ -43,12 +43,14 @@ gem 'omniauth-cas', '~> 1.1'
|
|||||||
gem 'omniauth-saml', '~> 1.10'
|
gem 'omniauth-saml', '~> 1.10'
|
||||||
gem 'omniauth', '~> 1.9'
|
gem 'omniauth', '~> 1.9'
|
||||||
|
|
||||||
gem 'doorkeeper', '~> 5.1'
|
gem 'discard', '~> 1.1'
|
||||||
|
gem 'doorkeeper', '~> 5.2'
|
||||||
gem 'fast_blank', '~> 1.0'
|
gem 'fast_blank', '~> 1.0'
|
||||||
gem 'fastimage'
|
gem 'fastimage'
|
||||||
gem 'goldfinger', '~> 2.1'
|
gem 'goldfinger', '~> 2.1'
|
||||||
gem 'hiredis', '~> 0.6'
|
gem 'hiredis', '~> 0.6'
|
||||||
gem 'redis-namespace', '~> 1.5'
|
gem 'redis-namespace', '~> 1.5'
|
||||||
|
gem 'health_check', '~> 3.0'
|
||||||
gem 'htmlentities', '~> 4.3'
|
gem 'htmlentities', '~> 4.3'
|
||||||
gem 'http', '~> 3.3'
|
gem 'http', '~> 3.3'
|
||||||
gem 'http_accept_language', '~> 2.1'
|
gem 'http_accept_language', '~> 2.1'
|
||||||
@ -57,16 +59,16 @@ gem 'httplog', '~> 1.3'
|
|||||||
gem 'idn-ruby', require: 'idn'
|
gem 'idn-ruby', require: 'idn'
|
||||||
gem 'kaminari', '~> 1.1'
|
gem 'kaminari', '~> 1.1'
|
||||||
gem 'link_header', '~> 0.0'
|
gem 'link_header', '~> 0.0'
|
||||||
gem 'mime-types', '~> 3.2', require: 'mime/types/columnar'
|
gem 'mime-types', '~> 3.3', require: 'mime/types/columnar'
|
||||||
gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532'
|
gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532'
|
||||||
gem 'nokogiri', '~> 1.10'
|
gem 'nokogiri', '~> 1.10'
|
||||||
gem 'nsa', '~> 0.2'
|
gem 'nsa', '~> 0.2'
|
||||||
gem 'oj', '~> 3.8'
|
gem 'oj', '~> 3.9'
|
||||||
gem 'ostatus2', '~> 2.0'
|
gem 'ostatus2', '~> 2.0'
|
||||||
gem 'ox', '~> 2.11'
|
gem 'ox', '~> 2.11'
|
||||||
gem 'parslet'
|
gem 'parslet'
|
||||||
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
|
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
|
||||||
gem 'pundit', '~> 2.0'
|
gem 'pundit', '~> 2.1'
|
||||||
gem 'premailer-rails'
|
gem 'premailer-rails'
|
||||||
gem 'rack-attack', '~> 6.1'
|
gem 'rack-attack', '~> 6.1'
|
||||||
gem 'rack-cors', '~> 1.0', require: 'rack/cors'
|
gem 'rack-cors', '~> 1.0', require: 'rack/cors'
|
||||||
@ -75,12 +77,13 @@ gem 'rails-settings-cached', '~> 0.6'
|
|||||||
gem 'redis', '~> 4.1', require: ['redis', 'redis/connection/hiredis']
|
gem 'redis', '~> 4.1', require: ['redis', 'redis/connection/hiredis']
|
||||||
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
||||||
gem 'rqrcode', '~> 0.10'
|
gem 'rqrcode', '~> 0.10'
|
||||||
gem 'sanitize', '~> 5.0'
|
gem 'ruby-progressbar', '~> 1.10'
|
||||||
|
gem 'sanitize', '~> 5.1'
|
||||||
gem 'sidekiq', '~> 5.2'
|
gem 'sidekiq', '~> 5.2'
|
||||||
gem 'sidekiq-scheduler', '~> 3.0'
|
gem 'sidekiq-scheduler', '~> 3.0'
|
||||||
gem 'sidekiq-unique-jobs', '~> 6.0'
|
gem 'sidekiq-unique-jobs', '~> 6.0'
|
||||||
gem 'sidekiq-bulk', '~>0.2.0'
|
gem 'sidekiq-bulk', '~>0.2.0'
|
||||||
gem 'simple-navigation', '~> 4.0'
|
gem 'simple-navigation', '~> 4.1'
|
||||||
gem 'simple_form', '~> 4.1'
|
gem 'simple_form', '~> 4.1'
|
||||||
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
|
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
|
||||||
gem 'stoplight', '~> 2.1.3'
|
gem 'stoplight', '~> 2.1.3'
|
||||||
@ -92,7 +95,7 @@ gem 'tzinfo-data', '~> 1.2019'
|
|||||||
gem 'webpacker', '~> 4.0'
|
gem 'webpacker', '~> 4.0'
|
||||||
gem 'webpush'
|
gem 'webpush'
|
||||||
|
|
||||||
gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: '345b7a5733308af827e8491d284dbafa9128d7a2'
|
gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: 'e742697a0906e74e8bb777ef98137bc3955d981d'
|
||||||
gem 'json-ld-preloaded', '~> 3.0'
|
gem 'json-ld-preloaded', '~> 3.0'
|
||||||
gem 'rdf-normalize', '~> 0.3'
|
gem 'rdf-normalize', '~> 0.3'
|
||||||
|
|
||||||
@ -110,14 +113,14 @@ group :production, :test do
|
|||||||
end
|
end
|
||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
gem 'capybara', '~> 3.28'
|
gem 'capybara', '~> 3.29'
|
||||||
gem 'climate_control', '~> 0.2'
|
gem 'climate_control', '~> 0.2'
|
||||||
gem 'faker', '~> 2.1'
|
gem 'faker', '~> 2.3'
|
||||||
gem 'microformats', '~> 4.1'
|
gem 'microformats', '~> 4.1'
|
||||||
gem 'rails-controller-testing', '~> 1.0'
|
gem 'rails-controller-testing', '~> 1.0'
|
||||||
gem 'rspec-sidekiq', '~> 3.0'
|
gem 'rspec-sidekiq', '~> 3.0'
|
||||||
gem 'simplecov', '~> 0.17', require: false
|
gem 'simplecov', '~> 0.17', require: false
|
||||||
gem 'webmock', '~> 3.6'
|
gem 'webmock', '~> 3.7'
|
||||||
gem 'parallel_tests', '~> 2.29'
|
gem 'parallel_tests', '~> 2.29'
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -131,7 +134,7 @@ group :development do
|
|||||||
gem 'letter_opener_web', '~> 1.3'
|
gem 'letter_opener_web', '~> 1.3'
|
||||||
gem 'memory_profiler'
|
gem 'memory_profiler'
|
||||||
gem 'rubocop', '~> 0.74', require: false
|
gem 'rubocop', '~> 0.74', require: false
|
||||||
gem 'rubocop-rails', '~> 2.2', require: false
|
gem 'rubocop-rails', '~> 2.3', require: false
|
||||||
gem 'brakeman', '~> 4.6', require: false
|
gem 'brakeman', '~> 4.6', require: false
|
||||||
gem 'bundler-audit', '~> 0.6', require: false
|
gem 'bundler-audit', '~> 0.6', require: false
|
||||||
|
|
||||||
|
117
Gemfile.lock
117
Gemfile.lock
@ -7,8 +7,8 @@ GIT
|
|||||||
|
|
||||||
GIT
|
GIT
|
||||||
remote: https://github.com/ruby-rdf/json-ld.git
|
remote: https://github.com/ruby-rdf/json-ld.git
|
||||||
revision: 345b7a5733308af827e8491d284dbafa9128d7a2
|
revision: e742697a0906e74e8bb777ef98137bc3955d981d
|
||||||
ref: 345b7a5733308af827e8491d284dbafa9128d7a2
|
ref: e742697a0906e74e8bb777ef98137bc3955d981d
|
||||||
specs:
|
specs:
|
||||||
json-ld (3.0.2)
|
json-ld (3.0.2)
|
||||||
htmlentities (~> 4.3)
|
htmlentities (~> 4.3)
|
||||||
@ -83,9 +83,9 @@ GEM
|
|||||||
i18n (>= 0.7, < 2)
|
i18n (>= 0.7, < 2)
|
||||||
minitest (~> 5.1)
|
minitest (~> 5.1)
|
||||||
tzinfo (~> 1.1)
|
tzinfo (~> 1.1)
|
||||||
addressable (2.6.0)
|
addressable (2.7.0)
|
||||||
public_suffix (>= 2.0.2, < 4.0)
|
public_suffix (>= 2.0.2, < 5.0)
|
||||||
airbrussh (1.3.0)
|
airbrussh (1.3.3)
|
||||||
sshkit (>= 1.6.1, != 1.7.0)
|
sshkit (>= 1.6.1, != 1.7.0)
|
||||||
annotate (2.7.5)
|
annotate (2.7.5)
|
||||||
activerecord (>= 3.2, < 7.0)
|
activerecord (>= 3.2, < 7.0)
|
||||||
@ -97,8 +97,8 @@ GEM
|
|||||||
av (0.9.0)
|
av (0.9.0)
|
||||||
cocaine (~> 0.5.3)
|
cocaine (~> 0.5.3)
|
||||||
aws-eventstream (1.0.3)
|
aws-eventstream (1.0.3)
|
||||||
aws-partitions (1.193.0)
|
aws-partitions (1.207.0)
|
||||||
aws-sdk-core (3.61.1)
|
aws-sdk-core (3.65.1)
|
||||||
aws-eventstream (~> 1.0, >= 1.0.2)
|
aws-eventstream (~> 1.0, >= 1.0.2)
|
||||||
aws-partitions (~> 1.0)
|
aws-partitions (~> 1.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
@ -106,7 +106,7 @@ GEM
|
|||||||
aws-sdk-kms (1.24.0)
|
aws-sdk-kms (1.24.0)
|
||||||
aws-sdk-core (~> 3, >= 3.61.1)
|
aws-sdk-core (~> 3, >= 3.61.1)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sdk-s3 (1.46.0)
|
aws-sdk-s3 (1.48.0)
|
||||||
aws-sdk-core (~> 3, >= 3.61.1)
|
aws-sdk-core (~> 3, >= 3.61.1)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
@ -122,19 +122,19 @@ GEM
|
|||||||
debug_inspector (>= 0.0.1)
|
debug_inspector (>= 0.0.1)
|
||||||
blurhash (0.1.3)
|
blurhash (0.1.3)
|
||||||
ffi (~> 1.10.0)
|
ffi (~> 1.10.0)
|
||||||
bootsnap (1.4.4)
|
bootsnap (1.4.5)
|
||||||
msgpack (~> 1.0)
|
msgpack (~> 1.0)
|
||||||
brakeman (4.6.1)
|
brakeman (4.6.1)
|
||||||
browser (2.6.1)
|
browser (2.6.1)
|
||||||
builder (3.2.3)
|
builder (3.2.3)
|
||||||
bullet (6.0.1)
|
bullet (6.0.2)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
uniform_notifier (~> 1.11)
|
uniform_notifier (~> 1.11)
|
||||||
bundler-audit (0.6.1)
|
bundler-audit (0.6.1)
|
||||||
bundler (>= 1.2.0, < 3)
|
bundler (>= 1.2.0, < 3)
|
||||||
thor (~> 0.18)
|
thor (~> 0.18)
|
||||||
byebug (11.0.0)
|
byebug (11.0.0)
|
||||||
capistrano (3.11.0)
|
capistrano (3.11.1)
|
||||||
airbrussh (>= 1.0.0)
|
airbrussh (>= 1.0.0)
|
||||||
i18n
|
i18n
|
||||||
rake (>= 10.0.0)
|
rake (>= 10.0.0)
|
||||||
@ -150,7 +150,7 @@ GEM
|
|||||||
sshkit (~> 1.3)
|
sshkit (~> 1.3)
|
||||||
capistrano-yarn (2.0.2)
|
capistrano-yarn (2.0.2)
|
||||||
capistrano (~> 3.0)
|
capistrano (~> 3.0)
|
||||||
capybara (3.28.0)
|
capybara (3.29.0)
|
||||||
addressable
|
addressable
|
||||||
mini_mime (>= 0.1.3)
|
mini_mime (>= 0.1.3)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
@ -188,10 +188,10 @@ GEM
|
|||||||
rack (>= 1)
|
rack (>= 1)
|
||||||
rake (> 10, < 13)
|
rake (> 10, < 13)
|
||||||
thor (~> 0.19)
|
thor (~> 0.19)
|
||||||
devise (4.6.2)
|
devise (4.7.1)
|
||||||
bcrypt (~> 3.0)
|
bcrypt (~> 3.0)
|
||||||
orm_adapter (~> 0.1)
|
orm_adapter (~> 0.1)
|
||||||
railties (>= 4.1.0, < 6.0)
|
railties (>= 4.1.0)
|
||||||
responders
|
responders
|
||||||
warden (~> 1.2.3)
|
warden (~> 1.2.3)
|
||||||
devise-two-factor (3.1.0)
|
devise-two-factor (3.1.0)
|
||||||
@ -204,10 +204,12 @@ GEM
|
|||||||
devise (>= 4.0.0)
|
devise (>= 4.0.0)
|
||||||
rpam2 (~> 4.0)
|
rpam2 (~> 4.0)
|
||||||
diff-lcs (1.3)
|
diff-lcs (1.3)
|
||||||
|
discard (1.1.0)
|
||||||
|
activerecord (>= 4.2, < 7)
|
||||||
docile (1.3.2)
|
docile (1.3.2)
|
||||||
domain_name (0.5.20180417)
|
domain_name (0.5.20180417)
|
||||||
unf (>= 0.0.5, < 1.0.0)
|
unf (>= 0.0.5, < 1.0.0)
|
||||||
doorkeeper (5.1.0)
|
doorkeeper (5.2.0)
|
||||||
railties (>= 5)
|
railties (>= 5)
|
||||||
dotenv (2.7.5)
|
dotenv (2.7.5)
|
||||||
dotenv-rails (2.7.5)
|
dotenv-rails (2.7.5)
|
||||||
@ -229,12 +231,12 @@ GEM
|
|||||||
tzinfo
|
tzinfo
|
||||||
excon (0.62.0)
|
excon (0.62.0)
|
||||||
fabrication (2.20.2)
|
fabrication (2.20.2)
|
||||||
faker (2.1.2)
|
faker (2.3.0)
|
||||||
i18n (>= 0.8)
|
i18n (~> 1.6.0)
|
||||||
faraday (0.15.0)
|
faraday (0.15.0)
|
||||||
multipart-post (>= 1.2, < 3)
|
multipart-post (>= 1.2, < 3)
|
||||||
fast_blank (1.0.0)
|
fast_blank (1.0.0)
|
||||||
fastimage (2.1.5)
|
fastimage (2.1.7)
|
||||||
ffi (1.10.0)
|
ffi (1.10.0)
|
||||||
fog-core (2.1.0)
|
fog-core (2.1.0)
|
||||||
builder
|
builder
|
||||||
@ -276,6 +278,8 @@ GEM
|
|||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
hashdiff (1.0.0)
|
hashdiff (1.0.0)
|
||||||
hashie (3.6.0)
|
hashie (3.6.0)
|
||||||
|
health_check (3.0.0)
|
||||||
|
railties (>= 5.0)
|
||||||
heapy (0.1.4)
|
heapy (0.1.4)
|
||||||
highline (2.0.1)
|
highline (2.0.1)
|
||||||
hiredis (0.6.3)
|
hiredis (0.6.3)
|
||||||
@ -312,7 +316,7 @@ GEM
|
|||||||
jmespath (1.4.0)
|
jmespath (1.4.0)
|
||||||
json (2.2.0)
|
json (2.2.0)
|
||||||
json-canonicalization (0.1.0)
|
json-canonicalization (0.1.0)
|
||||||
json-ld-preloaded (3.0.3)
|
json-ld-preloaded (3.0.4)
|
||||||
json-ld (~> 3.0)
|
json-ld (~> 3.0)
|
||||||
multi_json (~> 1.12)
|
multi_json (~> 1.12)
|
||||||
rdf (~> 3.0)
|
rdf (~> 3.0)
|
||||||
@ -360,32 +364,32 @@ GEM
|
|||||||
microformats (4.1.0)
|
microformats (4.1.0)
|
||||||
json (~> 2.1)
|
json (~> 2.1)
|
||||||
nokogiri (~> 1.8, >= 1.8.3)
|
nokogiri (~> 1.8, >= 1.8.3)
|
||||||
mime-types (3.2.2)
|
mime-types (3.3)
|
||||||
mime-types-data (~> 3.2015)
|
mime-types-data (~> 3.2015)
|
||||||
mime-types-data (3.2018.0812)
|
mime-types-data (3.2019.0904)
|
||||||
mimemagic (0.3.3)
|
mimemagic (0.3.3)
|
||||||
mini_mime (1.0.2)
|
mini_mime (1.0.2)
|
||||||
mini_portile2 (2.4.0)
|
mini_portile2 (2.4.0)
|
||||||
minitest (5.11.3)
|
minitest (5.11.3)
|
||||||
msgpack (1.2.10)
|
msgpack (1.3.1)
|
||||||
multi_json (1.13.1)
|
multi_json (1.13.1)
|
||||||
multipart-post (2.0.0)
|
multipart-post (2.0.0)
|
||||||
necromancer (0.5.0)
|
necromancer (0.5.0)
|
||||||
net-ldap (0.16.1)
|
net-ldap (0.16.1)
|
||||||
net-scp (1.2.1)
|
net-scp (2.0.0)
|
||||||
net-ssh (>= 2.6.5)
|
net-ssh (>= 2.6.5, < 6.0.0)
|
||||||
net-ssh (5.0.2)
|
net-ssh (5.2.0)
|
||||||
nio4r (2.4.0)
|
nio4r (2.5.1)
|
||||||
nokogiri (1.10.4)
|
nokogiri (1.10.4)
|
||||||
mini_portile2 (~> 2.4.0)
|
mini_portile2 (~> 2.4.0)
|
||||||
nokogumbo (2.0.0)
|
nokogumbo (2.0.1)
|
||||||
nokogiri (~> 1.8, >= 1.8.4)
|
nokogiri (~> 1.8, >= 1.8.4)
|
||||||
nsa (0.2.7)
|
nsa (0.2.7)
|
||||||
activesupport (>= 4.2, < 6)
|
activesupport (>= 4.2, < 6)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
sidekiq (>= 3.5)
|
sidekiq (>= 3.5)
|
||||||
statsd-ruby (~> 1.4, >= 1.4.0)
|
statsd-ruby (~> 1.4, >= 1.4.0)
|
||||||
oj (3.8.1)
|
oj (3.9.1)
|
||||||
omniauth (1.9.0)
|
omniauth (1.9.0)
|
||||||
hashie (>= 3.4.6, < 3.7.0)
|
hashie (>= 3.4.6, < 3.7.0)
|
||||||
rack (>= 1.6.2, < 3)
|
rack (>= 1.6.2, < 3)
|
||||||
@ -414,16 +418,16 @@ GEM
|
|||||||
parallel (1.17.0)
|
parallel (1.17.0)
|
||||||
parallel_tests (2.29.2)
|
parallel_tests (2.29.2)
|
||||||
parallel
|
parallel
|
||||||
parser (2.6.3.0)
|
parser (2.6.4.0)
|
||||||
ast (~> 2.4.0)
|
ast (~> 2.4.0)
|
||||||
parslet (1.8.2)
|
parslet (1.8.2)
|
||||||
pastel (0.7.2)
|
pastel (0.7.2)
|
||||||
equatable (~> 0.5.0)
|
equatable (~> 0.5.0)
|
||||||
tty-color (~> 0.4.0)
|
tty-color (~> 0.4.0)
|
||||||
pg (1.1.4)
|
pg (1.1.4)
|
||||||
pghero (2.2.1)
|
pghero (2.3.0)
|
||||||
activerecord
|
activerecord (>= 5)
|
||||||
pkg-config (1.3.7)
|
pkg-config (1.3.8)
|
||||||
premailer (1.11.1)
|
premailer (1.11.1)
|
||||||
addressable
|
addressable
|
||||||
css_parser (>= 1.6.0)
|
css_parser (>= 1.6.0)
|
||||||
@ -440,10 +444,10 @@ GEM
|
|||||||
pry (~> 0.10)
|
pry (~> 0.10)
|
||||||
pry-rails (0.3.9)
|
pry-rails (0.3.9)
|
||||||
pry (>= 0.10.4)
|
pry (>= 0.10.4)
|
||||||
public_suffix (3.1.1)
|
public_suffix (4.0.1)
|
||||||
puma (4.1.0)
|
puma (4.1.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.0.1)
|
pundit (2.1.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.1.6)
|
raabro (1.1.6)
|
||||||
rack (2.0.7)
|
rack (2.0.7)
|
||||||
@ -552,7 +556,7 @@ GEM
|
|||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 1.4.0, < 1.7)
|
unicode-display_width (>= 1.4.0, < 1.7)
|
||||||
rubocop-rails (2.2.1)
|
rubocop-rails (2.3.2)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 0.72.0)
|
rubocop (>= 0.72.0)
|
||||||
ruby-progressbar (1.10.1)
|
ruby-progressbar (1.10.1)
|
||||||
@ -561,7 +565,7 @@ GEM
|
|||||||
rufus-scheduler (3.5.2)
|
rufus-scheduler (3.5.2)
|
||||||
fugit (~> 1.1, >= 1.1.5)
|
fugit (~> 1.1, >= 1.1.5)
|
||||||
safe_yaml (1.0.5)
|
safe_yaml (1.0.5)
|
||||||
sanitize (5.0.0)
|
sanitize (5.1.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.8.0)
|
nokogiri (>= 1.8.0)
|
||||||
nokogumbo (~> 2.0)
|
nokogumbo (~> 2.0)
|
||||||
@ -581,7 +585,7 @@ GEM
|
|||||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||||
sidekiq (>= 4.0, < 7.0)
|
sidekiq (>= 4.0, < 7.0)
|
||||||
thor (~> 0)
|
thor (~> 0)
|
||||||
simple-navigation (4.0.5)
|
simple-navigation (4.1.0)
|
||||||
activesupport (>= 2.3.2)
|
activesupport (>= 2.3.2)
|
||||||
simple_form (4.1.0)
|
simple_form (4.1.0)
|
||||||
actionpack (>= 5.0)
|
actionpack (>= 5.0)
|
||||||
@ -598,7 +602,7 @@ GEM
|
|||||||
actionpack (>= 4.0)
|
actionpack (>= 4.0)
|
||||||
activesupport (>= 4.0)
|
activesupport (>= 4.0)
|
||||||
sprockets (>= 3.0.0)
|
sprockets (>= 3.0.0)
|
||||||
sshkit (1.17.0)
|
sshkit (1.20.0)
|
||||||
net-scp (>= 1.1.2)
|
net-scp (>= 1.1.2)
|
||||||
net-ssh (>= 2.8.0)
|
net-ssh (>= 2.8.0)
|
||||||
stackprof (0.2.12)
|
stackprof (0.2.12)
|
||||||
@ -633,7 +637,7 @@ GEM
|
|||||||
unf (~> 0.1.0)
|
unf (~> 0.1.0)
|
||||||
tzinfo (1.2.5)
|
tzinfo (1.2.5)
|
||||||
thread_safe (~> 0.1)
|
thread_safe (~> 0.1)
|
||||||
tzinfo-data (1.2019.2)
|
tzinfo-data (1.2019.3)
|
||||||
tzinfo (>= 1.0.0)
|
tzinfo (>= 1.0.0)
|
||||||
unf (0.1.4)
|
unf (0.1.4)
|
||||||
unf_ext
|
unf_ext
|
||||||
@ -642,7 +646,7 @@ GEM
|
|||||||
uniform_notifier (1.12.1)
|
uniform_notifier (1.12.1)
|
||||||
warden (1.2.8)
|
warden (1.2.8)
|
||||||
rack (>= 2.0.6)
|
rack (>= 2.0.6)
|
||||||
webmock (3.6.2)
|
webmock (3.7.3)
|
||||||
addressable (>= 2.3.6)
|
addressable (>= 2.3.6)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
@ -666,9 +670,9 @@ PLATFORMS
|
|||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
active_model_serializers (~> 0.10)
|
active_model_serializers (~> 0.10)
|
||||||
active_record_query_trace (~> 1.6)
|
active_record_query_trace (~> 1.6)
|
||||||
addressable (~> 2.6)
|
addressable (~> 2.7)
|
||||||
annotate (~> 2.7)
|
annotate (~> 2.7)
|
||||||
aws-sdk-s3 (~> 1.46)
|
aws-sdk-s3 (~> 1.48)
|
||||||
better_errors (~> 2.5)
|
better_errors (~> 2.5)
|
||||||
binding_of_caller (~> 0.7)
|
binding_of_caller (~> 0.7)
|
||||||
blurhash (~> 0.1)
|
blurhash (~> 0.1)
|
||||||
@ -681,7 +685,7 @@ DEPENDENCIES
|
|||||||
capistrano-rails (~> 1.4)
|
capistrano-rails (~> 1.4)
|
||||||
capistrano-rbenv (~> 2.1)
|
capistrano-rbenv (~> 2.1)
|
||||||
capistrano-yarn (~> 2.0)
|
capistrano-yarn (~> 2.0)
|
||||||
capybara (~> 3.28)
|
capybara (~> 3.29)
|
||||||
charlock_holmes (~> 0.7.6)
|
charlock_holmes (~> 0.7.6)
|
||||||
chewy (~> 5.0)
|
chewy (~> 5.0)
|
||||||
cld3 (~> 3.2.4)
|
cld3 (~> 3.2.4)
|
||||||
@ -689,13 +693,14 @@ DEPENDENCIES
|
|||||||
concurrent-ruby
|
concurrent-ruby
|
||||||
connection_pool
|
connection_pool
|
||||||
derailed_benchmarks
|
derailed_benchmarks
|
||||||
devise (~> 4.6)
|
devise (~> 4.7)
|
||||||
devise-two-factor (~> 3.1)
|
devise-two-factor (~> 3.1)
|
||||||
devise_pam_authenticatable2 (~> 9.2)
|
devise_pam_authenticatable2 (~> 9.2)
|
||||||
doorkeeper (~> 5.1)
|
discard (~> 1.1)
|
||||||
|
doorkeeper (~> 5.2)
|
||||||
dotenv-rails (~> 2.7)
|
dotenv-rails (~> 2.7)
|
||||||
fabrication (~> 2.20)
|
fabrication (~> 2.20)
|
||||||
faker (~> 2.1)
|
faker (~> 2.3)
|
||||||
fast_blank (~> 1.0)
|
fast_blank (~> 1.0)
|
||||||
fastimage
|
fastimage
|
||||||
fog-core (<= 2.1.0)
|
fog-core (<= 2.1.0)
|
||||||
@ -703,6 +708,7 @@ DEPENDENCIES
|
|||||||
fuubar (~> 2.4)
|
fuubar (~> 2.4)
|
||||||
goldfinger (~> 2.1)
|
goldfinger (~> 2.1)
|
||||||
hamlit-rails (~> 0.2)
|
hamlit-rails (~> 0.2)
|
||||||
|
health_check (~> 3.0)
|
||||||
hiredis (~> 0.6)
|
hiredis (~> 0.6)
|
||||||
htmlentities (~> 4.3)
|
htmlentities (~> 4.3)
|
||||||
http (~> 3.3)
|
http (~> 3.3)
|
||||||
@ -723,12 +729,12 @@ DEPENDENCIES
|
|||||||
mario-redis-lock (~> 1.2)
|
mario-redis-lock (~> 1.2)
|
||||||
memory_profiler
|
memory_profiler
|
||||||
microformats (~> 4.1)
|
microformats (~> 4.1)
|
||||||
mime-types (~> 3.2)
|
mime-types (~> 3.3)
|
||||||
net-ldap (~> 0.10)
|
net-ldap (~> 0.10)
|
||||||
nilsimsa!
|
nilsimsa!
|
||||||
nokogiri (~> 1.10)
|
nokogiri (~> 1.10)
|
||||||
nsa (~> 0.2)
|
nsa (~> 0.2)
|
||||||
oj (~> 3.8)
|
oj (~> 3.9)
|
||||||
omniauth (~> 1.9)
|
omniauth (~> 1.9)
|
||||||
omniauth-cas (~> 1.1)
|
omniauth-cas (~> 1.1)
|
||||||
omniauth-saml (~> 1.10)
|
omniauth-saml (~> 1.10)
|
||||||
@ -739,7 +745,7 @@ DEPENDENCIES
|
|||||||
parallel_tests (~> 2.29)
|
parallel_tests (~> 2.29)
|
||||||
parslet
|
parslet
|
||||||
pg (~> 1.1)
|
pg (~> 1.1)
|
||||||
pghero (~> 2.2)
|
pghero (~> 2.3)
|
||||||
pkg-config (~> 1.3)
|
pkg-config (~> 1.3)
|
||||||
posix-spawn!
|
posix-spawn!
|
||||||
premailer-rails
|
premailer-rails
|
||||||
@ -747,7 +753,7 @@ DEPENDENCIES
|
|||||||
pry-byebug (~> 3.7)
|
pry-byebug (~> 3.7)
|
||||||
pry-rails (~> 0.3)
|
pry-rails (~> 0.3)
|
||||||
puma (~> 4.1)
|
puma (~> 4.1)
|
||||||
pundit (~> 2.0)
|
pundit (~> 2.1)
|
||||||
rack-attack (~> 6.1)
|
rack-attack (~> 6.1)
|
||||||
rack-cors (~> 1.0)
|
rack-cors (~> 1.0)
|
||||||
rails (~> 5.2.3)
|
rails (~> 5.2.3)
|
||||||
@ -762,13 +768,14 @@ DEPENDENCIES
|
|||||||
rspec-rails (~> 3.8)
|
rspec-rails (~> 3.8)
|
||||||
rspec-sidekiq (~> 3.0)
|
rspec-sidekiq (~> 3.0)
|
||||||
rubocop (~> 0.74)
|
rubocop (~> 0.74)
|
||||||
rubocop-rails (~> 2.2)
|
rubocop-rails (~> 2.3)
|
||||||
sanitize (~> 5.0)
|
ruby-progressbar (~> 1.10)
|
||||||
|
sanitize (~> 5.1)
|
||||||
sidekiq (~> 5.2)
|
sidekiq (~> 5.2)
|
||||||
sidekiq-bulk (~> 0.2.0)
|
sidekiq-bulk (~> 0.2.0)
|
||||||
sidekiq-scheduler (~> 3.0)
|
sidekiq-scheduler (~> 3.0)
|
||||||
sidekiq-unique-jobs (~> 6.0)
|
sidekiq-unique-jobs (~> 6.0)
|
||||||
simple-navigation (~> 4.0)
|
simple-navigation (~> 4.1)
|
||||||
simple_form (~> 4.1)
|
simple_form (~> 4.1)
|
||||||
simplecov (~> 0.17)
|
simplecov (~> 0.17)
|
||||||
sprockets-rails (~> 3.2)
|
sprockets-rails (~> 3.2)
|
||||||
@ -781,7 +788,7 @@ DEPENDENCIES
|
|||||||
tty-prompt (~> 0.19)
|
tty-prompt (~> 0.19)
|
||||||
twitter-text (~> 1.14)
|
twitter-text (~> 1.14)
|
||||||
tzinfo-data (~> 1.2019)
|
tzinfo-data (~> 1.2019)
|
||||||
webmock (~> 3.6)
|
webmock (~> 3.7)
|
||||||
webpacker (~> 4.0)
|
webpacker (~> 4.0)
|
||||||
webpush
|
webpush
|
||||||
|
|
||||||
|
37
app/chewy/tags_index.rb
Normal file
37
app/chewy/tags_index.rb
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TagsIndex < Chewy::Index
|
||||||
|
settings index: { refresh_interval: '15m' }, analysis: {
|
||||||
|
analyzer: {
|
||||||
|
content: {
|
||||||
|
tokenizer: 'keyword',
|
||||||
|
filter: %w(lowercase asciifolding cjk_width),
|
||||||
|
},
|
||||||
|
|
||||||
|
edge_ngram: {
|
||||||
|
tokenizer: 'edge_ngram',
|
||||||
|
filter: %w(lowercase asciifolding cjk_width),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
tokenizer: {
|
||||||
|
edge_ngram: {
|
||||||
|
type: 'edge_ngram',
|
||||||
|
min_gram: 2,
|
||||||
|
max_gram: 15,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
define_type ::Tag.listable, delete_if: ->(tag) { tag.destroyed? || !tag.listable? } do
|
||||||
|
root date_detection: false do
|
||||||
|
field :name, type: 'text', analyzer: 'content' do
|
||||||
|
field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
|
||||||
|
end
|
||||||
|
|
||||||
|
field :reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? }
|
||||||
|
field :usage, type: 'long', value: ->(tag) { tag.history.reduce(0) { |total, day| total + day[:accounts].to_i } }
|
||||||
|
field :last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -6,7 +6,7 @@ class AboutController < ApplicationController
|
|||||||
before_action :require_open_federation!, only: [:show, :more]
|
before_action :require_open_federation!, only: [:show, :more]
|
||||||
before_action :set_body_classes, only: :show
|
before_action :set_body_classes, only: :show
|
||||||
before_action :set_instance_presenter
|
before_action :set_instance_presenter
|
||||||
before_action :set_expires_in
|
before_action :set_expires_in, only: [:show, :more, :terms]
|
||||||
|
|
||||||
skip_before_action :require_functional!, only: [:more, :terms]
|
skip_before_action :require_functional!, only: [:more, :terms]
|
||||||
|
|
||||||
@ -14,16 +14,35 @@ class AboutController < ApplicationController
|
|||||||
|
|
||||||
def more
|
def more
|
||||||
flash.now[:notice] = I18n.t('about.instance_actor_flash') if params[:instance_actor]
|
flash.now[:notice] = I18n.t('about.instance_actor_flash') if params[:instance_actor]
|
||||||
|
|
||||||
|
toc_generator = TOCGenerator.new(@instance_presenter.site_extended_description)
|
||||||
|
|
||||||
|
@contents = toc_generator.html
|
||||||
|
@table_of_contents = toc_generator.toc
|
||||||
|
@blocks = DomainBlock.with_user_facing_limitations.by_severity if display_blocks?
|
||||||
end
|
end
|
||||||
|
|
||||||
def terms; end
|
def terms; end
|
||||||
|
|
||||||
|
helper_method :display_blocks?
|
||||||
|
helper_method :display_blocks_rationale?
|
||||||
|
helper_method :public_fetch_mode?
|
||||||
|
helper_method :new_user
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def require_open_federation!
|
def require_open_federation!
|
||||||
not_found if whitelist_mode?
|
not_found if whitelist_mode?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def display_blocks?
|
||||||
|
Setting.show_domain_blocks == 'all' || (Setting.show_domain_blocks == 'users' && user_signed_in?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def display_blocks_rationale?
|
||||||
|
Setting.show_domain_blocks_rationale == 'all' || (Setting.show_domain_blocks_rationale == 'users' && user_signed_in?)
|
||||||
|
end
|
||||||
|
|
||||||
def new_user
|
def new_user
|
||||||
User.new.tap do |user|
|
User.new.tap do |user|
|
||||||
user.build_account
|
user.build_account
|
||||||
@ -31,8 +50,6 @@ class AboutController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
helper_method :new_user
|
|
||||||
|
|
||||||
def set_instance_presenter
|
def set_instance_presenter
|
||||||
@instance_presenter = InstancePresenter.new
|
@instance_presenter = InstancePresenter.new
|
||||||
end
|
end
|
||||||
|
@ -18,6 +18,7 @@ class AccountsController < ApplicationController
|
|||||||
|
|
||||||
@pinned_statuses = []
|
@pinned_statuses = []
|
||||||
@endorsed_accounts = @account.endorsed_accounts.to_a.sample(4)
|
@endorsed_accounts = @account.endorsed_accounts.to_a.sample(4)
|
||||||
|
@featured_hashtags = @account.featured_tags.order(statuses_count: :desc)
|
||||||
|
|
||||||
if current_account && @account.blocking?(current_account)
|
if current_account && @account.blocking?(current_account)
|
||||||
@statuses = []
|
@statuses = []
|
||||||
@ -27,6 +28,7 @@ class AccountsController < ApplicationController
|
|||||||
@pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses?
|
@pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses?
|
||||||
@statuses = filtered_status_page(params)
|
@statuses = filtered_status_page(params)
|
||||||
@statuses = cache_collection(@statuses, Status)
|
@statuses = cache_collection(@statuses, Status)
|
||||||
|
@rss_url = rss_url
|
||||||
|
|
||||||
unless @statuses.empty?
|
unless @statuses.empty?
|
||||||
@older_url = older_url if @statuses.last.id > filtered_statuses.last.id
|
@older_url = older_url if @statuses.last.id > filtered_statuses.last.id
|
||||||
@ -37,8 +39,9 @@ class AccountsController < ApplicationController
|
|||||||
format.rss do
|
format.rss do
|
||||||
expires_in 0, public: true
|
expires_in 0, public: true
|
||||||
|
|
||||||
@statuses = cache_collection(default_statuses.without_reblogs.without_replies.limit(PAGE_SIZE), Status)
|
@statuses = filtered_statuses.without_reblogs.without_replies.limit(PAGE_SIZE)
|
||||||
render xml: RSS::AccountSerializer.render(@account, @statuses)
|
@statuses = cache_collection(@statuses, Status)
|
||||||
|
render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
|
||||||
end
|
end
|
||||||
|
|
||||||
format.json do
|
format.json do
|
||||||
@ -96,6 +99,14 @@ class AccountsController < ApplicationController
|
|||||||
params[:username]
|
params[:username]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def rss_url
|
||||||
|
if tag_requested?
|
||||||
|
short_account_tag_url(@account, params[:tag], format: 'rss')
|
||||||
|
else
|
||||||
|
short_account_url(@account, format: 'rss')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def older_url
|
def older_url
|
||||||
pagination_url(max_id: @statuses.last.id)
|
pagination_url(max_id: @statuses.last.id)
|
||||||
end
|
end
|
||||||
@ -125,7 +136,7 @@ class AccountsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def tag_requested?
|
def tag_requested?
|
||||||
request.path.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
|
request.path.split('.').first.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
|
||||||
end
|
end
|
||||||
|
|
||||||
def filtered_status_page(params)
|
def filtered_status_page(params)
|
||||||
|
@ -5,7 +5,7 @@ module Admin
|
|||||||
before_action :set_account
|
before_action :set_account
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true)
|
@account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true, include_statuses: true)
|
||||||
@warning_presets = AccountWarningPreset.all
|
@warning_presets = AccountWarningPreset.all
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def resource_params
|
def resource_params
|
||||||
params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification)
|
params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -41,7 +41,7 @@ module Admin
|
|||||||
|
|
||||||
def reject
|
def reject
|
||||||
authorize @account.user, :reject?
|
authorize @account.user, :reject?
|
||||||
SuspendAccountService.new.call(@account, including_user: true, destroy: true, skip_distribution: true)
|
SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false)
|
||||||
redirect_to admin_pending_accounts_path
|
redirect_to admin_pending_accounts_path
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -2,19 +2,20 @@
|
|||||||
|
|
||||||
module Admin
|
module Admin
|
||||||
class CustomEmojisController < BaseController
|
class CustomEmojisController < BaseController
|
||||||
before_action :set_custom_emoji, except: [:index, :new, :create]
|
|
||||||
before_action :set_filter_params
|
|
||||||
|
|
||||||
include ObfuscateFilename
|
include ObfuscateFilename
|
||||||
|
|
||||||
obfuscate_filename [:custom_emoji, :image]
|
obfuscate_filename [:custom_emoji, :image]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
authorize :custom_emoji, :index?
|
authorize :custom_emoji, :index?
|
||||||
|
|
||||||
@custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page])
|
@custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page])
|
||||||
|
@form = Form::CustomEmojiBatch.new
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
authorize :custom_emoji, :create?
|
authorize :custom_emoji, :create?
|
||||||
|
|
||||||
@custom_emoji = CustomEmoji.new
|
@custom_emoji = CustomEmoji.new
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -31,69 +32,17 @@ module Admin
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def batch
|
||||||
authorize @custom_emoji, :update?
|
@form = Form::CustomEmojiBatch.new(form_custom_emoji_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||||
|
@form.save
|
||||||
if @custom_emoji.update(resource_params)
|
rescue ActionController::ParameterMissing
|
||||||
log_action :update, @custom_emoji
|
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||||
flash[:notice] = I18n.t('admin.custom_emojis.updated_msg')
|
ensure
|
||||||
else
|
redirect_to admin_custom_emojis_path(filter_params)
|
||||||
flash[:alert] = I18n.t('admin.custom_emojis.update_failed_msg')
|
|
||||||
end
|
|
||||||
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
|
|
||||||
end
|
|
||||||
|
|
||||||
def destroy
|
|
||||||
authorize @custom_emoji, :destroy?
|
|
||||||
@custom_emoji.destroy!
|
|
||||||
log_action :destroy, @custom_emoji
|
|
||||||
flash[:notice] = I18n.t('admin.custom_emojis.destroyed_msg')
|
|
||||||
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
|
|
||||||
end
|
|
||||||
|
|
||||||
def copy
|
|
||||||
authorize @custom_emoji, :copy?
|
|
||||||
|
|
||||||
emoji = CustomEmoji.find_or_initialize_by(domain: nil,
|
|
||||||
shortcode: @custom_emoji.shortcode)
|
|
||||||
emoji.image = @custom_emoji.image
|
|
||||||
|
|
||||||
if emoji.save
|
|
||||||
log_action :create, emoji
|
|
||||||
flash[:notice] = I18n.t('admin.custom_emojis.copied_msg')
|
|
||||||
else
|
|
||||||
flash[:alert] = I18n.t('admin.custom_emojis.copy_failed_msg')
|
|
||||||
end
|
|
||||||
|
|
||||||
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
|
|
||||||
end
|
|
||||||
|
|
||||||
def enable
|
|
||||||
authorize @custom_emoji, :enable?
|
|
||||||
@custom_emoji.update!(disabled: false)
|
|
||||||
log_action :enable, @custom_emoji
|
|
||||||
flash[:notice] = I18n.t('admin.custom_emojis.enabled_msg')
|
|
||||||
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
|
|
||||||
end
|
|
||||||
|
|
||||||
def disable
|
|
||||||
authorize @custom_emoji, :disable?
|
|
||||||
@custom_emoji.update!(disabled: true)
|
|
||||||
log_action :disable, @custom_emoji
|
|
||||||
flash[:notice] = I18n.t('admin.custom_emojis.disabled_msg')
|
|
||||||
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_custom_emoji
|
|
||||||
@custom_emoji = CustomEmoji.find(params[:id])
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_filter_params
|
|
||||||
@filter_params = filter_params.to_hash.symbolize_keys
|
|
||||||
end
|
|
||||||
|
|
||||||
def resource_params
|
def resource_params
|
||||||
params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker)
|
params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker)
|
||||||
end
|
end
|
||||||
@ -103,12 +52,29 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def filter_params
|
def filter_params
|
||||||
params.permit(
|
params.slice(:local, :remote, :by_domain, :shortcode, :page).permit(:local, :remote, :by_domain, :shortcode, :page)
|
||||||
:local,
|
end
|
||||||
:remote,
|
|
||||||
:by_domain,
|
def action_from_button
|
||||||
:shortcode
|
if params[:update]
|
||||||
)
|
'update'
|
||||||
|
elsif params[:list]
|
||||||
|
'list'
|
||||||
|
elsif params[:unlist]
|
||||||
|
'unlist'
|
||||||
|
elsif params[:enable]
|
||||||
|
'enable'
|
||||||
|
elsif params[:disable]
|
||||||
|
'disable'
|
||||||
|
elsif params[:copy]
|
||||||
|
'copy'
|
||||||
|
elsif params[:delete]
|
||||||
|
'delete'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def form_custom_emoji_batch_params
|
||||||
|
params.require(:form_custom_emoji_batch).permit(:action, :category_id, :category_name, custom_emoji_ids: [])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -5,10 +5,10 @@ module Admin
|
|||||||
before_action :set_report_note, only: [:destroy]
|
before_action :set_report_note, only: [:destroy]
|
||||||
|
|
||||||
def create
|
def create
|
||||||
authorize ReportNote, :create?
|
authorize :report_note, :create?
|
||||||
|
|
||||||
@report_note = current_account.report_notes.new(resource_params)
|
@report_note = current_account.report_notes.new(resource_params)
|
||||||
@report = @report_note.report
|
@report = @report_note.report
|
||||||
|
|
||||||
if @report_note.save
|
if @report_note.save
|
||||||
if params[:create_and_resolve]
|
if params[:create_and_resolve]
|
||||||
@ -26,9 +26,8 @@ module Admin
|
|||||||
|
|
||||||
redirect_to admin_report_path(@report), notice: I18n.t('admin.report_notes.created_msg')
|
redirect_to admin_report_path(@report), notice: I18n.t('admin.report_notes.created_msg')
|
||||||
else
|
else
|
||||||
@report_notes = @report.notes.latest
|
@report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at)
|
||||||
@report_history = @report.history
|
@form = Form::StatusBatch.new
|
||||||
@form = Form::StatusBatch.new
|
|
||||||
|
|
||||||
render template: 'admin/reports/show'
|
render template: 'admin/reports/show'
|
||||||
end
|
end
|
||||||
|
@ -2,13 +2,34 @@
|
|||||||
|
|
||||||
module Admin
|
module Admin
|
||||||
class TagsController < BaseController
|
class TagsController < BaseController
|
||||||
before_action :set_tags, only: :index
|
before_action :set_tag, except: [:index, :batch, :approve_all, :reject_all]
|
||||||
before_action :set_tag, except: :index
|
before_action :set_usage_by_domain, except: [:index, :batch, :approve_all, :reject_all]
|
||||||
before_action :set_usage_by_domain, except: :index
|
before_action :set_counters, except: [:index, :batch, :approve_all, :reject_all]
|
||||||
before_action :set_counters, except: :index
|
|
||||||
|
|
||||||
def index
|
def index
|
||||||
authorize :tag, :index?
|
authorize :tag, :index?
|
||||||
|
|
||||||
|
@tags = filtered_tags.page(params[:page])
|
||||||
|
@form = Form::TagBatch.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def batch
|
||||||
|
@form = Form::TagBatch.new(form_tag_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||||
|
@form.save
|
||||||
|
rescue ActionController::ParameterMissing
|
||||||
|
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||||
|
ensure
|
||||||
|
redirect_to admin_tags_path(filter_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def approve_all
|
||||||
|
Form::TagBatch.new(current_account: current_account, tag_ids: Tag.pending_review.pluck(:id), action: 'approve').save
|
||||||
|
redirect_to admin_tags_path(filter_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reject_all
|
||||||
|
Form::TagBatch.new(current_account: current_account, tag_ids: Tag.pending_review.pluck(:id), action: 'reject').save
|
||||||
|
redirect_to admin_tags_path(filter_params)
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@ -27,17 +48,14 @@ module Admin
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_tags
|
|
||||||
@tags = filtered_tags.page(params[:page])
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_tag
|
def set_tag
|
||||||
@tag = Tag.find(params[:id])
|
@tag = Tag.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_usage_by_domain
|
def set_usage_by_domain
|
||||||
@usage_by_domain = @tag.statuses
|
@usage_by_domain = @tag.statuses
|
||||||
.where(visibility: :public)
|
.with_public_visibility
|
||||||
|
.excluding_silenced_accounts
|
||||||
.where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day)))
|
.where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day)))
|
||||||
.joins(:account)
|
.joins(:account)
|
||||||
.group('accounts.domain')
|
.group('accounts.domain')
|
||||||
@ -51,16 +69,11 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def filtered_tags
|
def filtered_tags
|
||||||
scope = Tag
|
TagFilter.new(filter_params).results
|
||||||
scope = scope.discoverable if filter_params[:context] == 'directory'
|
|
||||||
scope = scope.unreviewed if filter_params[:review] == 'unreviewed'
|
|
||||||
scope = scope.reviewed.order(reviewed_at: :desc) if filter_params[:review] == 'reviewed'
|
|
||||||
scope = scope.pending_review.order(requested_review_at: :desc) if filter_params[:review] == 'pending_review'
|
|
||||||
scope.order(score: :desc)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_params
|
def filter_params
|
||||||
params.slice(:context, :review).permit(:context, :review)
|
params.slice(:directory, :reviewed, :unreviewed, :pending_review, :page, :popular, :active, :name).permit(:directory, :reviewed, :unreviewed, :pending_review, :page, :popular, :active, :name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def tag_params
|
def tag_params
|
||||||
@ -74,5 +87,17 @@ module Admin
|
|||||||
date.to_time(:utc).beginning_of_day.to_i
|
date.to_time(:utc).beginning_of_day.to_i
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def form_tag_batch_params
|
||||||
|
params.require(:form_tag_batch).permit(:action, tag_ids: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
def action_from_button
|
||||||
|
if params[:approve]
|
||||||
|
'approve'
|
||||||
|
elsif params[:reject]
|
||||||
|
'reject'
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -8,6 +8,7 @@ module Admin
|
|||||||
authorize @user, :disable_2fa?
|
authorize @user, :disable_2fa?
|
||||||
@user.disable_two_factor!
|
@user.disable_two_factor!
|
||||||
log_action :disable_2fa, @user
|
log_action :disable_2fa, @user
|
||||||
|
UserMailer.two_factor_disabled(@user).deliver_later!
|
||||||
redirect_to admin_accounts_path
|
redirect_to admin_accounts_path
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -36,6 +36,14 @@ class Api::BaseController < ApplicationController
|
|||||||
render json: { error: 'This action is not allowed' }, status: 403
|
render json: { error: 'This action is not allowed' }, status: 403
|
||||||
end
|
end
|
||||||
|
|
||||||
|
rescue_from Mastodon::RaceConditionError do
|
||||||
|
render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
|
||||||
|
end
|
||||||
|
|
||||||
|
rescue_from ActionController::ParameterMissing do |e|
|
||||||
|
render json: { error: e.to_s }, status: 400
|
||||||
|
end
|
||||||
|
|
||||||
def doorkeeper_unauthorized_render_options(error: nil)
|
def doorkeeper_unauthorized_render_options(error: nil)
|
||||||
{ json: { error: (error.try(:description) || 'Not authorized') } }
|
{ json: { error: (error.try(:description) || 'Not authorized') } }
|
||||||
end
|
end
|
||||||
|
@ -29,14 +29,13 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
|
|||||||
|
|
||||||
def account_statuses
|
def account_statuses
|
||||||
statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses
|
statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses
|
||||||
statuses = statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id))
|
|
||||||
|
|
||||||
statuses.merge!(only_media_scope) if truthy_param?(:only_media)
|
statuses.merge!(only_media_scope) if truthy_param?(:only_media)
|
||||||
statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
|
statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
|
||||||
statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
|
statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
|
||||||
statuses.merge!(hashtag_scope) if params[:tagged].present?
|
statuses.merge!(hashtag_scope) if params[:tagged].present?
|
||||||
|
|
||||||
statuses
|
statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_account_statuses
|
def permitted_account_statuses
|
||||||
|
@ -58,7 +58,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController
|
|||||||
|
|
||||||
def reject
|
def reject
|
||||||
authorize @account.user, :reject?
|
authorize @account.user, :reject?
|
||||||
SuspendAccountService.new.call(@account, including_user: true, destroy: true, skip_distribution: true)
|
SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false)
|
||||||
render json: @account, serializer: REST::Admin::AccountSerializer
|
render json: @account, serializer: REST::Admin::AccountSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -7,6 +7,6 @@ class Api::V1::CustomEmojisController < Api::BaseController
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
expires_in 3.minutes, public: true
|
expires_in 3.minutes, public: true
|
||||||
render_with_cache(each_serializer: REST::CustomEmojiSerializer) { CustomEmoji.local.where(disabled: false).includes(:category) }
|
render_with_cache(each_serializer: REST::CustomEmojiSerializer) { CustomEmoji.listed.includes(:category) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
30
app/controllers/api/v1/directories_controller.rb
Normal file
30
app/controllers/api/v1/directories_controller.rb
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::DirectoriesController < Api::BaseController
|
||||||
|
before_action :require_enabled!
|
||||||
|
before_action :set_accounts
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: @accounts, each_serializer: REST::AccountSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def require_enabled!
|
||||||
|
return not_found unless Setting.profile_directory
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_accounts
|
||||||
|
@accounts = accounts_scope.offset(params[:offset]).limit(limit_param(DEFAULT_ACCOUNTS_LIMIT))
|
||||||
|
end
|
||||||
|
|
||||||
|
def accounts_scope
|
||||||
|
Account.discoverable.tap do |scope|
|
||||||
|
scope.merge!(Account.local) if truthy_param?(:local)
|
||||||
|
scope.merge!(Account.by_recent_status) if params[:order].blank? || params[:order] == 'active'
|
||||||
|
scope.merge!(Account.order(id: :desc)) if params[:order] == 'new'
|
||||||
|
scope.merge!(Account.not_excluded_by_account(current_account)) if current_account
|
||||||
|
scope.merge!(Account.not_domain_blocked_by_account(current_account)) if current_account && !truthy_param?(:local)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,20 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController
|
||||||
|
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
|
||||||
|
|
||||||
|
before_action :require_user!
|
||||||
|
before_action :set_most_used_tags, only: :index
|
||||||
|
|
||||||
|
respond_to :json
|
||||||
|
|
||||||
|
def index
|
||||||
|
render json: @most_used_tags, each_serializer: REST::TagSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_most_used_tags
|
||||||
|
@most_used_tags = Tag.most_used(current_account).where.not(id: current_account.featured_tags).limit(10)
|
||||||
|
end
|
||||||
|
end
|
40
app/controllers/api/v1/featured_tags_controller.rb
Normal file
40
app/controllers/api/v1/featured_tags_controller.rb
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::FeaturedTagsController < Api::BaseController
|
||||||
|
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
|
||||||
|
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
|
||||||
|
|
||||||
|
before_action :require_user!
|
||||||
|
before_action :set_featured_tags, only: :index
|
||||||
|
before_action :set_featured_tag, except: [:index, :create]
|
||||||
|
|
||||||
|
def index
|
||||||
|
render json: @featured_tags, each_serializer: REST::FeaturedTagSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@featured_tag = current_account.featured_tags.new(featured_tag_params)
|
||||||
|
@featured_tag.reset_data
|
||||||
|
@featured_tag.save!
|
||||||
|
render json: @featured_tag, serializer: REST::FeaturedTagSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@featured_tag.destroy!
|
||||||
|
render_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_featured_tag
|
||||||
|
@featured_tag = current_account.featured_tags.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_featured_tags
|
||||||
|
@featured_tags = current_account.featured_tags.order(statuses_count: :desc)
|
||||||
|
end
|
||||||
|
|
||||||
|
def featured_tag_params
|
||||||
|
params.permit(:name)
|
||||||
|
end
|
||||||
|
end
|
@ -14,12 +14,12 @@ class Api::V1::FollowRequestsController < Api::BaseController
|
|||||||
def authorize
|
def authorize
|
||||||
AuthorizeFollowService.new.call(account, current_account)
|
AuthorizeFollowService.new.call(account, current_account)
|
||||||
NotifyService.new.call(current_account, Follow.find_by(account: account, target_account: current_account))
|
NotifyService.new.call(current_account, Follow.find_by(account: account, target_account: current_account))
|
||||||
render_empty
|
render json: account, serializer: REST::RelationshipSerializer, relationships: relationships
|
||||||
end
|
end
|
||||||
|
|
||||||
def reject
|
def reject
|
||||||
RejectFollowService.new.call(account, current_account)
|
RejectFollowService.new.call(account, current_account)
|
||||||
render_empty
|
render json: account, serializer: REST::RelationshipSerializer, relationships: relationships
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@ -28,6 +28,10 @@ class Api::V1::FollowRequestsController < Api::BaseController
|
|||||||
Account.find(params[:id])
|
Account.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def relationships(**options)
|
||||||
|
AccountRelationshipsPresenter.new([params[:id]], current_user.account_id, options)
|
||||||
|
end
|
||||||
|
|
||||||
def load_accounts
|
def load_accounts
|
||||||
default_accounts.merge(paginated_follow_requests).to_a
|
default_accounts.merge(paginated_follow_requests).to_a
|
||||||
end
|
end
|
||||||
|
44
app/controllers/api/v1/markers_controller.rb
Normal file
44
app/controllers/api/v1/markers_controller.rb
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::MarkersController < Api::BaseController
|
||||||
|
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:index]
|
||||||
|
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, except: [:index]
|
||||||
|
|
||||||
|
before_action :require_user!
|
||||||
|
|
||||||
|
def index
|
||||||
|
@markers = current_user.markers.where(timeline: Array(params[:timeline])).each_with_object({}) { |marker, h| h[marker.timeline] = marker }
|
||||||
|
render json: serialize_map(@markers)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
Marker.transaction do
|
||||||
|
@markers = {}
|
||||||
|
|
||||||
|
resource_params.each_pair do |timeline, timeline_params|
|
||||||
|
@markers[timeline] = current_user.markers.find_or_initialize_by(timeline: timeline)
|
||||||
|
@markers[timeline].update!(timeline_params)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: serialize_map(@markers)
|
||||||
|
rescue ActiveRecord::StaleObjectError
|
||||||
|
render json: { error: 'Conflict during update, please try again' }, status: 409
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def serialize_map(map)
|
||||||
|
serialized = {}
|
||||||
|
|
||||||
|
map.each_pair do |key, value|
|
||||||
|
serialized[key] = ActiveModelSerializers::SerializableResource.new(value, serializer: REST::MarkerSerializer).as_json
|
||||||
|
end
|
||||||
|
|
||||||
|
Oj.dump(serialized)
|
||||||
|
end
|
||||||
|
|
||||||
|
def resource_params
|
||||||
|
params.slice(*Marker::TIMELINES).permit(*Marker::TIMELINES.map { |timeline| { timeline.to_sym => [:last_read_id] } })
|
||||||
|
end
|
||||||
|
end
|
@ -21,7 +21,7 @@ class Api::V1::ReportsController < Api::BaseController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def reported_status_ids
|
def reported_status_ids
|
||||||
reported_account.statuses.find(status_ids).pluck(:id)
|
reported_account.statuses.with_discarded.find(status_ids).pluck(:id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def status_ids
|
def status_ids
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class Api::V1::SearchController < Api::BaseController
|
|
||||||
include Authorization
|
|
||||||
|
|
||||||
RESULTS_LIMIT = 20
|
|
||||||
|
|
||||||
before_action -> { doorkeeper_authorize! :read, :'read:search' }
|
|
||||||
before_action :require_user!
|
|
||||||
|
|
||||||
respond_to :json
|
|
||||||
|
|
||||||
def index
|
|
||||||
@search = Search.new(search_results)
|
|
||||||
render json: @search, serializer: REST::SearchSerializer
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def search_results
|
|
||||||
SearchService.new.call(
|
|
||||||
params[:q],
|
|
||||||
current_account,
|
|
||||||
limit_param(RESULTS_LIMIT),
|
|
||||||
search_params.merge(resolve: truthy_param?(:resolve))
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def search_params
|
|
||||||
params.permit(:type, :offset, :min_id, :max_id, :account_id)
|
|
||||||
end
|
|
||||||
end
|
|
@ -18,6 +18,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
|
|||||||
@reblogs_map = { @status.id => false }
|
@reblogs_map = { @status.id => false }
|
||||||
|
|
||||||
authorize status_for_destroy, :unreblog?
|
authorize status_for_destroy, :unreblog?
|
||||||
|
status_for_destroy.discard
|
||||||
RemovalWorker.perform_async(status_for_destroy.id)
|
RemovalWorker.perform_async(status_for_destroy.id)
|
||||||
|
|
||||||
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map)
|
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map)
|
||||||
@ -30,7 +31,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def status_for_destroy
|
def status_for_destroy
|
||||||
current_user.account.statuses.where(reblog_of_id: params[:status_id]).first!
|
@status_for_destroy ||= current_user.account.statuses.where(reblog_of_id: params[:status_id]).first!
|
||||||
end
|
end
|
||||||
|
|
||||||
def reblog_params
|
def reblog_params
|
||||||
|
@ -53,7 +53,8 @@ class Api::V1::StatusesController < Api::BaseController
|
|||||||
@status = Status.where(account_id: current_user.account).find(params[:id])
|
@status = Status.where(account_id: current_user.account).find(params[:id])
|
||||||
authorize @status, :destroy?
|
authorize @status, :destroy?
|
||||||
|
|
||||||
RemovalWorker.perform_async(@status.id)
|
@status.discard
|
||||||
|
RemovalWorker.perform_async(@status.id, redraft: true)
|
||||||
|
|
||||||
render json: @status, serializer: REST::StatusSerializer, source_requested: true
|
render json: @status, serializer: REST::StatusSerializer, source_requested: true
|
||||||
end
|
end
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::Timelines::PublicController < Api::BaseController
|
class Api::V1::Timelines::PublicController < Api::BaseController
|
||||||
|
before_action :require_user!, only: [:show], if: :require_auth?
|
||||||
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
||||||
|
|
||||||
respond_to :json
|
respond_to :json
|
||||||
@ -12,6 +13,10 @@ class Api::V1::Timelines::PublicController < Api::BaseController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def require_auth?
|
||||||
|
!Setting.timeline_preview
|
||||||
|
end
|
||||||
|
|
||||||
def load_statuses
|
def load_statuses
|
||||||
cached_public_statuses
|
cached_public_statuses
|
||||||
end
|
end
|
||||||
|
@ -1,8 +1,32 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V2::SearchController < Api::V1::SearchController
|
class Api::V2::SearchController < Api::BaseController
|
||||||
|
include Authorization
|
||||||
|
|
||||||
|
RESULTS_LIMIT = 20
|
||||||
|
|
||||||
|
before_action -> { doorkeeper_authorize! :read, :'read:search' }
|
||||||
|
before_action :require_user!
|
||||||
|
|
||||||
|
respond_to :json
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@search = Search.new(search_results)
|
@search = Search.new(search_results)
|
||||||
render json: @search, serializer: REST::V2::SearchSerializer
|
render json: @search, serializer: REST::SearchSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def search_results
|
||||||
|
SearchService.new.call(
|
||||||
|
params[:q],
|
||||||
|
current_account,
|
||||||
|
limit_param(RESULTS_LIMIT),
|
||||||
|
search_params.merge(resolve: truthy_param?(:resolve))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_params
|
||||||
|
params.permit(:type, :offset, :min_id, :max_id, :account_id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -21,10 +21,13 @@ class ApplicationController < ActionController::Base
|
|||||||
helper_method :whitelist_mode?
|
helper_method :whitelist_mode?
|
||||||
|
|
||||||
rescue_from ActionController::RoutingError, with: :not_found
|
rescue_from ActionController::RoutingError, with: :not_found
|
||||||
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
||||||
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
|
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
|
||||||
rescue_from ActionController::UnknownFormat, with: :not_acceptable
|
rescue_from ActionController::UnknownFormat, with: :not_acceptable
|
||||||
|
rescue_from ActionController::ParameterMissing, with: :bad_request
|
||||||
|
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
||||||
rescue_from Mastodon::NotPermittedError, with: :forbidden
|
rescue_from Mastodon::NotPermittedError, with: :forbidden
|
||||||
|
rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error
|
||||||
|
rescue_from Mastodon::RaceConditionError, with: :service_unavailable
|
||||||
|
|
||||||
before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
|
before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
|
||||||
before_action :require_functional!, if: :user_signed_in?
|
before_action :require_functional!, if: :user_signed_in?
|
||||||
@ -38,7 +41,7 @@ class ApplicationController < ActionController::Base
|
|||||||
private
|
private
|
||||||
|
|
||||||
def https_enabled?
|
def https_enabled?
|
||||||
Rails.env.production?
|
Rails.env.production? && !request.path.start_with?('/health')
|
||||||
end
|
end
|
||||||
|
|
||||||
def authorized_fetch_mode?
|
def authorized_fetch_mode?
|
||||||
@ -95,6 +98,18 @@ class ApplicationController < ActionController::Base
|
|||||||
respond_with_error(406)
|
respond_with_error(406)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def bad_request
|
||||||
|
respond_with_error(400)
|
||||||
|
end
|
||||||
|
|
||||||
|
def internal_server_error
|
||||||
|
respond_with_error(500)
|
||||||
|
end
|
||||||
|
|
||||||
|
def service_unavailable
|
||||||
|
respond_with_error(503)
|
||||||
|
end
|
||||||
|
|
||||||
def single_user_mode?
|
def single_user_mode?
|
||||||
@single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists?
|
@single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists?
|
||||||
end
|
end
|
||||||
|
22
app/controllers/auth/challenges_controller.rb
Normal file
22
app/controllers/auth/challenges_controller.rb
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Auth::ChallengesController < ApplicationController
|
||||||
|
include ChallengableConcern
|
||||||
|
|
||||||
|
layout 'auth'
|
||||||
|
|
||||||
|
before_action :authenticate_user!
|
||||||
|
|
||||||
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
|
def create
|
||||||
|
if challenge_passed?
|
||||||
|
session[:challenge_passed_at] = Time.now.utc
|
||||||
|
redirect_to challenge_params[:return_to]
|
||||||
|
else
|
||||||
|
@challenge = Form::Challenge.new(return_to: challenge_params[:return_to])
|
||||||
|
flash.now[:alert] = I18n.t('challenge.invalid_password')
|
||||||
|
render_challenge
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -4,15 +4,38 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
|
|||||||
layout 'auth'
|
layout 'auth'
|
||||||
|
|
||||||
before_action :set_body_classes
|
before_action :set_body_classes
|
||||||
|
before_action :require_unconfirmed!
|
||||||
|
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
|
def new
|
||||||
|
super
|
||||||
|
|
||||||
|
resource.email = current_user.unconfirmed_email || current_user.email if user_signed_in?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def require_unconfirmed!
|
||||||
|
redirect_to edit_user_registration_path if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank?
|
||||||
|
end
|
||||||
|
|
||||||
def set_body_classes
|
def set_body_classes
|
||||||
@body_classes = 'lighter'
|
@body_classes = 'lighter'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def after_resending_confirmation_instructions_path_for(_resource_name)
|
||||||
|
if user_signed_in?
|
||||||
|
if current_user.confirmed? && current_user.approved?
|
||||||
|
edit_user_registration_path
|
||||||
|
else
|
||||||
|
auth_setup_path
|
||||||
|
end
|
||||||
|
else
|
||||||
|
new_user_session_path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def after_confirmation_path_for(_resource_name, user)
|
def after_confirmation_path_for(_resource_name, user)
|
||||||
if user.created_by_application && truthy_param?(:redirect_to_app)
|
if user.created_by_application && truthy_param?(:redirect_to_app)
|
||||||
user.created_by_application.redirect_uri
|
user.created_by_application.redirect_uri
|
||||||
|
@ -8,8 +8,6 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
skip_before_action :require_no_authentication, only: [:create]
|
skip_before_action :require_no_authentication, only: [:create]
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
|
|
||||||
|
|
||||||
before_action :set_instance_presenter, only: [:new]
|
before_action :set_instance_presenter, only: [:new]
|
||||||
before_action :set_body_classes
|
before_action :set_body_classes
|
||||||
|
|
||||||
@ -22,33 +20,35 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
super do |resource|
|
self.resource = begin
|
||||||
remember_me(resource)
|
if user_params[:email].blank? && session[:otp_user_id].present?
|
||||||
flash.delete(:notice)
|
User.find(session[:otp_user_id])
|
||||||
|
else
|
||||||
|
warden.authenticate!(auth_options)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if resource.otp_required_for_login?
|
||||||
|
if user_params[:otp_attempt].present? && session[:otp_user_id].present?
|
||||||
|
authenticate_with_two_factor_via_otp(resource)
|
||||||
|
else
|
||||||
|
prompt_for_two_factor(resource)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
authenticate_and_respond(resource)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
tmp_stored_location = stored_location_for(:user)
|
tmp_stored_location = stored_location_for(:user)
|
||||||
super
|
super
|
||||||
|
session.delete(:challenge_passed_at)
|
||||||
flash.delete(:notice)
|
flash.delete(:notice)
|
||||||
store_location_for(:user, tmp_stored_location) if continue_after?
|
store_location_for(:user, tmp_stored_location) if continue_after?
|
||||||
end
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def find_user
|
|
||||||
if session[:otp_user_id]
|
|
||||||
User.find(session[:otp_user_id])
|
|
||||||
elsif user_params[:email]
|
|
||||||
if use_seamless_external_login? && Devise.check_at_sign && user_params[:email].index('@').nil?
|
|
||||||
User.joins(:account).find_by(accounts: { username: user_params[:email] })
|
|
||||||
else
|
|
||||||
User.find_for_authentication(email: user_params[:email])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(:email, :password, :otp_attempt)
|
params.require(:user).permit(:email, :password, :otp_attempt)
|
||||||
end
|
end
|
||||||
@ -71,32 +71,17 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
def two_factor_enabled?
|
|
||||||
find_user.try(:otp_required_for_login?)
|
|
||||||
end
|
|
||||||
|
|
||||||
def valid_otp_attempt?(user)
|
def valid_otp_attempt?(user)
|
||||||
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
|
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
|
||||||
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
|
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
|
||||||
rescue OpenSSL::Cipher::CipherError => _error
|
rescue OpenSSL::Cipher::CipherError
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def authenticate_with_two_factor
|
|
||||||
user = self.resource = find_user
|
|
||||||
|
|
||||||
if user_params[:otp_attempt].present? && session[:otp_user_id]
|
|
||||||
authenticate_with_two_factor_via_otp(user)
|
|
||||||
elsif user&.valid_password?(user_params[:password])
|
|
||||||
prompt_for_two_factor(user)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def authenticate_with_two_factor_via_otp(user)
|
def authenticate_with_two_factor_via_otp(user)
|
||||||
if valid_otp_attempt?(user)
|
if valid_otp_attempt?(user)
|
||||||
session.delete(:otp_user_id)
|
session.delete(:otp_user_id)
|
||||||
remember_me(user)
|
authenticate_and_respond(user)
|
||||||
sign_in(user)
|
|
||||||
else
|
else
|
||||||
flash.now[:alert] = I18n.t('users.invalid_otp_token')
|
flash.now[:alert] = I18n.t('users.invalid_otp_token')
|
||||||
prompt_for_two_factor(user)
|
prompt_for_two_factor(user)
|
||||||
@ -108,6 +93,13 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
render :two_factor
|
render :two_factor
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def authenticate_and_respond(user)
|
||||||
|
sign_in(user)
|
||||||
|
remember_me(user)
|
||||||
|
|
||||||
|
respond_with user, location: after_sign_in_path_for(user)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_instance_presenter
|
def set_instance_presenter
|
||||||
@ -120,9 +112,11 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
|
|
||||||
def home_paths(resource)
|
def home_paths(resource)
|
||||||
paths = [about_path]
|
paths = [about_path]
|
||||||
|
|
||||||
if single_user_mode? && resource.is_a?(User)
|
if single_user_mode? && resource.is_a?(User)
|
||||||
paths << short_account_path(username: resource.account)
|
paths << short_account_path(username: resource.account)
|
||||||
end
|
end
|
||||||
|
|
||||||
paths
|
paths
|
||||||
end
|
end
|
||||||
|
|
||||||
|
65
app/controllers/concerns/challengable_concern.rb
Normal file
65
app/controllers/concerns/challengable_concern.rb
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# This concern is inspired by "sudo mode" on GitHub. It
|
||||||
|
# is a way to re-authenticate a user before allowing them
|
||||||
|
# to see or perform an action.
|
||||||
|
#
|
||||||
|
# Add `before_action :require_challenge!` to actions you
|
||||||
|
# want to protect.
|
||||||
|
#
|
||||||
|
# The user will be shown a page to enter the challenge (which
|
||||||
|
# is either the password, or just the username when no
|
||||||
|
# password exists). Upon passing, there is a grace period
|
||||||
|
# during which no challenge will be asked from the user.
|
||||||
|
#
|
||||||
|
# Accessing challenge-protected resources during the grace
|
||||||
|
# period will refresh the grace period.
|
||||||
|
module ChallengableConcern
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
CHALLENGE_TIMEOUT = 1.hour.freeze
|
||||||
|
|
||||||
|
def require_challenge!
|
||||||
|
return if skip_challenge?
|
||||||
|
|
||||||
|
if challenge_passed_recently?
|
||||||
|
session[:challenge_passed_at] = Time.now.utc
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@challenge = Form::Challenge.new(return_to: request.url)
|
||||||
|
|
||||||
|
if params.key?(:form_challenge)
|
||||||
|
if challenge_passed?
|
||||||
|
session[:challenge_passed_at] = Time.now.utc
|
||||||
|
return
|
||||||
|
else
|
||||||
|
flash.now[:alert] = I18n.t('challenge.invalid_password')
|
||||||
|
render_challenge
|
||||||
|
end
|
||||||
|
else
|
||||||
|
render_challenge
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_challenge
|
||||||
|
@body_classes = 'lighter'
|
||||||
|
render template: 'auth/challenges/new', layout: 'auth'
|
||||||
|
end
|
||||||
|
|
||||||
|
def challenge_passed?
|
||||||
|
current_user.valid_password?(challenge_params[:current_password])
|
||||||
|
end
|
||||||
|
|
||||||
|
def skip_challenge?
|
||||||
|
current_user.encrypted_password.blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
def challenge_passed_recently?
|
||||||
|
session[:challenge_passed_at].present? && session[:challenge_passed_at] >= CHALLENGE_TIMEOUT.ago
|
||||||
|
end
|
||||||
|
|
||||||
|
def challenge_params
|
||||||
|
params.require(:form_challenge).permit(:current_password, :return_to)
|
||||||
|
end
|
||||||
|
end
|
@ -5,7 +5,10 @@ module ExportControllerConcern
|
|||||||
|
|
||||||
included do
|
included do
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
|
before_action :require_not_suspended!
|
||||||
before_action :load_export
|
before_action :load_export
|
||||||
|
|
||||||
|
skip_before_action :require_functional!
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@ -27,4 +30,8 @@ module ExportControllerConcern
|
|||||||
def export_filename
|
def export_filename
|
||||||
"#{controller_name}.csv"
|
"#{controller_name}.csv"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def require_not_suspended!
|
||||||
|
forbidden if current_account.suspended?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -23,6 +23,19 @@ module SignatureVerification
|
|||||||
@signature_verification_failure_code || 401
|
@signature_verification_failure_code || 401
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def signature_key_id
|
||||||
|
raw_signature = request.headers['Signature']
|
||||||
|
signature_params = {}
|
||||||
|
|
||||||
|
raw_signature.split(',').each do |part|
|
||||||
|
parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
|
||||||
|
next if parsed_parts.nil? || parsed_parts.size != 3
|
||||||
|
signature_params[parsed_parts[1]] = parsed_parts[2]
|
||||||
|
end
|
||||||
|
|
||||||
|
signature_params['keyId']
|
||||||
|
end
|
||||||
|
|
||||||
def signed_request_account
|
def signed_request_account
|
||||||
return @signed_request_account if defined?(@signed_request_account)
|
return @signed_request_account if defined?(@signed_request_account)
|
||||||
|
|
||||||
@ -154,7 +167,7 @@ module SignatureVerification
|
|||||||
.with_fallback { nil }
|
.with_fallback { nil }
|
||||||
.with_threshold(1)
|
.with_threshold(1)
|
||||||
.with_cool_off_time(5.minutes.seconds)
|
.with_cool_off_time(5.minutes.seconds)
|
||||||
.with_error_handler { |error, handle| error.is_a?(HTTP::Error) ? handle.call(error) : raise(error) }
|
.with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) }
|
||||||
.run
|
.run
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -7,7 +7,6 @@ class DirectoriesController < ApplicationController
|
|||||||
before_action :require_enabled!
|
before_action :require_enabled!
|
||||||
before_action :set_instance_presenter
|
before_action :set_instance_presenter
|
||||||
before_action :set_tag, only: :show
|
before_action :set_tag, only: :show
|
||||||
before_action :set_tags
|
|
||||||
before_action :set_accounts
|
before_action :set_accounts
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@ -28,13 +27,10 @@ class DirectoriesController < ApplicationController
|
|||||||
@tag = Tag.discoverable.find_normalized!(params[:id])
|
@tag = Tag.discoverable.find_normalized!(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_tags
|
|
||||||
@tags = Tag.discoverable.limit(30).reject { |tag| tag.cached_sample_accounts.empty? }
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_accounts
|
def set_accounts
|
||||||
@accounts = Account.discoverable.by_recent_status.page(params[:page]).per(40).tap do |query|
|
@accounts = Account.local.discoverable.by_recent_status.page(params[:page]).per(20).tap do |query|
|
||||||
query.merge!(Account.tagged_with(@tag.id)) if @tag
|
query.merge!(Account.tagged_with(@tag.id)) if @tag
|
||||||
|
query.merge!(Account.not_excluded_by_account(current_account)) if current_account
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -43,7 +43,7 @@ class InvitesController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def resource_params
|
def resource_params
|
||||||
params.require(:invite).permit(:max_uses, :expires_in, :autofollow)
|
params.require(:invite).permit(:max_uses, :expires_in, :autofollow, :comment)
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_body_classes
|
def set_body_classes
|
||||||
|
@ -7,6 +7,10 @@ class MediaProxyController < ApplicationController
|
|||||||
|
|
||||||
before_action :authenticate_user!, if: :whitelist_mode?
|
before_action :authenticate_user!, if: :whitelist_mode?
|
||||||
|
|
||||||
|
rescue_from ActiveRecord::RecordInvalid, with: :not_found
|
||||||
|
rescue_from Mastodon::UnexpectedResponseError, with: :not_found
|
||||||
|
rescue_from HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, with: :internal_server_error
|
||||||
|
|
||||||
def show
|
def show
|
||||||
RedisLock.acquire(lock_options) do |lock|
|
RedisLock.acquire(lock_options) do |lock|
|
||||||
if lock.acquired?
|
if lock.acquired?
|
||||||
|
@ -29,7 +29,7 @@ class RemoteFollowController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def session_params
|
def session_params
|
||||||
{ acct: session[:remote_follow] }
|
{ acct: session[:remote_follow] || current_account&.username }
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_body_classes
|
def set_body_classes
|
||||||
|
@ -32,7 +32,7 @@ class RemoteInteractionController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def session_params
|
def session_params
|
||||||
{ acct: session[:remote_follow] }
|
{ acct: session[:remote_follow] || current_account&.username }
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_status
|
def set_status
|
||||||
|
43
app/controllers/settings/aliases_controller.rb
Normal file
43
app/controllers/settings/aliases_controller.rb
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Settings::AliasesController < Settings::BaseController
|
||||||
|
layout 'admin'
|
||||||
|
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :set_aliases, except: :destroy
|
||||||
|
before_action :set_alias, only: :destroy
|
||||||
|
|
||||||
|
def index
|
||||||
|
@alias = current_account.aliases.build
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@alias = current_account.aliases.build(resource_params)
|
||||||
|
|
||||||
|
if @alias.save
|
||||||
|
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
|
||||||
|
redirect_to settings_aliases_path, notice: I18n.t('aliases.created_msg')
|
||||||
|
else
|
||||||
|
render :index
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@alias.destroy!
|
||||||
|
redirect_to settings_aliases_path, notice: I18n.t('aliases.deleted_msg')
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def resource_params
|
||||||
|
params.require(:account_alias).permit(:acct)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_alias
|
||||||
|
@alias = current_account.aliases.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_aliases
|
||||||
|
@aliases = current_account.aliases.order(id: :desc).reject(&:new_record?)
|
||||||
|
end
|
||||||
|
end
|
@ -14,12 +14,11 @@ class Settings::DeletesController < Settings::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
if current_user.valid_password?(delete_params[:password])
|
if challenge_passed?
|
||||||
Admin::SuspensionWorker.perform_async(current_user.account_id, true)
|
destroy_account!
|
||||||
sign_out
|
|
||||||
redirect_to new_user_session_path, notice: I18n.t('deletes.success_msg')
|
redirect_to new_user_session_path, notice: I18n.t('deletes.success_msg')
|
||||||
else
|
else
|
||||||
redirect_to settings_delete_path, alert: I18n.t('deletes.bad_password_msg')
|
redirect_to settings_delete_path, alert: I18n.t('deletes.challenge_not_passed')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -29,11 +28,25 @@ class Settings::DeletesController < Settings::BaseController
|
|||||||
redirect_to root_path unless Setting.open_deletion
|
redirect_to root_path unless Setting.open_deletion
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete_params
|
def resource_params
|
||||||
params.require(:form_delete_confirmation).permit(:password)
|
params.require(:form_delete_confirmation).permit(:password, :username)
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_not_suspended!
|
def require_not_suspended!
|
||||||
forbidden if current_account.suspended?
|
forbidden if current_account.suspended?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def challenge_passed?
|
||||||
|
if current_user.encrypted_password.blank?
|
||||||
|
current_account.username == resource_params[:username]
|
||||||
|
else
|
||||||
|
current_user.valid_password?(resource_params[:password])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy_account!
|
||||||
|
current_account.suspend!
|
||||||
|
Admin::SuspensionWorker.perform_async(current_user.account_id, true)
|
||||||
|
sign_out
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -6,6 +6,9 @@ class Settings::ExportsController < Settings::BaseController
|
|||||||
layout 'admin'
|
layout 'admin'
|
||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
|
before_action :require_not_suspended!
|
||||||
|
|
||||||
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@export = Export.new(current_account)
|
@export = Export.new(current_account)
|
||||||
@ -34,4 +37,8 @@ class Settings::ExportsController < Settings::BaseController
|
|||||||
def lock_options
|
def lock_options
|
||||||
{ redis: Redis.current, key: "backup:#{current_user.id}" }
|
{ redis: Redis.current, key: "backup:#{current_user.id}" }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def require_not_suspended!
|
||||||
|
forbidden if current_account.suspended?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -4,31 +4,59 @@ class Settings::MigrationsController < Settings::BaseController
|
|||||||
layout 'admin'
|
layout 'admin'
|
||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
|
before_action :require_not_suspended!
|
||||||
|
before_action :set_migrations
|
||||||
|
before_action :set_cooldown
|
||||||
|
|
||||||
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@migration = Form::Migration.new(account: current_account.moved_to_account)
|
@migration = current_account.migrations.build
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def create
|
||||||
@migration = Form::Migration.new(resource_params)
|
@migration = current_account.migrations.build(resource_params)
|
||||||
|
|
||||||
if @migration.valid? && migration_account_changed?
|
if @migration.save_with_challenge(current_user)
|
||||||
current_account.update!(moved_to_account: @migration.account)
|
current_account.update!(moved_to_account: @migration.target_account)
|
||||||
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
|
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
|
||||||
redirect_to settings_migration_path, notice: I18n.t('migrations.updated_msg')
|
ActivityPub::MoveDistributionWorker.perform_async(@migration.id)
|
||||||
|
redirect_to settings_migration_path, notice: I18n.t('migrations.moved_msg', acct: current_account.moved_to_account.acct)
|
||||||
else
|
else
|
||||||
render :show
|
render :show
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def cancel
|
||||||
|
if current_account.moved_to_account_id.present?
|
||||||
|
current_account.update!(moved_to_account: nil)
|
||||||
|
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to settings_migration_path, notice: I18n.t('migrations.cancelled_msg')
|
||||||
|
end
|
||||||
|
|
||||||
|
helper_method :on_cooldown?
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def resource_params
|
def resource_params
|
||||||
params.require(:migration).permit(:acct)
|
params.require(:account_migration).permit(:acct, :current_password, :current_username)
|
||||||
end
|
end
|
||||||
|
|
||||||
def migration_account_changed?
|
def set_migrations
|
||||||
current_account.moved_to_account_id != @migration.account&.id &&
|
@migrations = current_account.migrations.includes(:target_account).order(id: :desc).reject(&:new_record?)
|
||||||
current_account.id != @migration.account&.id
|
end
|
||||||
|
|
||||||
|
def set_cooldown
|
||||||
|
@cooldown = current_account.migrations.within_cooldown.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_cooldown?
|
||||||
|
@cooldown.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def require_not_suspended!
|
||||||
|
forbidden if current_account.suspended?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -3,9 +3,12 @@
|
|||||||
module Settings
|
module Settings
|
||||||
module TwoFactorAuthentication
|
module TwoFactorAuthentication
|
||||||
class ConfirmationsController < BaseController
|
class ConfirmationsController < BaseController
|
||||||
|
include ChallengableConcern
|
||||||
|
|
||||||
layout 'admin'
|
layout 'admin'
|
||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
|
before_action :require_challenge!
|
||||||
before_action :ensure_otp_secret
|
before_action :ensure_otp_secret
|
||||||
|
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
@ -15,13 +18,15 @@ module Settings
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
if current_user.validate_and_consume_otp!(confirmation_params[:code])
|
if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt])
|
||||||
flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success')
|
flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success')
|
||||||
|
|
||||||
current_user.otp_required_for_login = true
|
current_user.otp_required_for_login = true
|
||||||
@recovery_codes = current_user.generate_otp_backup_codes!
|
@recovery_codes = current_user.generate_otp_backup_codes!
|
||||||
current_user.save!
|
current_user.save!
|
||||||
|
|
||||||
|
UserMailer.two_factor_enabled(current_user).deliver_later!
|
||||||
|
|
||||||
render 'settings/two_factor_authentication/recovery_codes/index'
|
render 'settings/two_factor_authentication/recovery_codes/index'
|
||||||
else
|
else
|
||||||
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
|
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
|
||||||
@ -33,7 +38,7 @@ module Settings
|
|||||||
private
|
private
|
||||||
|
|
||||||
def confirmation_params
|
def confirmation_params
|
||||||
params.require(:form_two_factor_confirmation).permit(:code)
|
params.require(:form_two_factor_confirmation).permit(:otp_attempt)
|
||||||
end
|
end
|
||||||
|
|
||||||
def prepare_two_factor_form
|
def prepare_two_factor_form
|
||||||
|
@ -3,16 +3,22 @@
|
|||||||
module Settings
|
module Settings
|
||||||
module TwoFactorAuthentication
|
module TwoFactorAuthentication
|
||||||
class RecoveryCodesController < BaseController
|
class RecoveryCodesController < BaseController
|
||||||
|
include ChallengableConcern
|
||||||
|
|
||||||
layout 'admin'
|
layout 'admin'
|
||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
|
before_action :require_challenge!, on: :create
|
||||||
|
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@recovery_codes = current_user.generate_otp_backup_codes!
|
@recovery_codes = current_user.generate_otp_backup_codes!
|
||||||
current_user.save!
|
current_user.save!
|
||||||
|
|
||||||
|
UserMailer.two_factor_recovery_codes_changed(current_user).deliver_later!
|
||||||
flash.now[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated')
|
flash.now[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated')
|
||||||
|
|
||||||
render :index
|
render :index
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
module Settings
|
module Settings
|
||||||
class TwoFactorAuthenticationsController < BaseController
|
class TwoFactorAuthenticationsController < BaseController
|
||||||
|
include ChallengableConcern
|
||||||
|
|
||||||
layout 'admin'
|
layout 'admin'
|
||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
before_action :verify_otp_required, only: [:create]
|
before_action :verify_otp_required, only: [:create]
|
||||||
|
before_action :require_challenge!, only: [:create]
|
||||||
|
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
@ -23,6 +26,7 @@ module Settings
|
|||||||
if acceptable_code?
|
if acceptable_code?
|
||||||
current_user.otp_required_for_login = false
|
current_user.otp_required_for_login = false
|
||||||
current_user.save!
|
current_user.save!
|
||||||
|
UserMailer.two_factor_disabled(current_user).deliver_later!
|
||||||
redirect_to settings_two_factor_authentication_path
|
redirect_to settings_two_factor_authentication_path
|
||||||
else
|
else
|
||||||
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
|
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
|
||||||
@ -34,7 +38,7 @@ module Settings
|
|||||||
private
|
private
|
||||||
|
|
||||||
def confirmation_params
|
def confirmation_params
|
||||||
params.require(:form_two_factor_confirmation).permit(:code)
|
params.require(:form_two_factor_confirmation).permit(:otp_attempt)
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_otp_required
|
def verify_otp_required
|
||||||
@ -42,8 +46,8 @@ module Settings
|
|||||||
end
|
end
|
||||||
|
|
||||||
def acceptable_code?
|
def acceptable_code?
|
||||||
current_user.validate_and_consume_otp!(confirmation_params[:code]) ||
|
current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) ||
|
||||||
current_user.invalidate_otp_backup_code!(confirmation_params[:code])
|
current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -5,18 +5,22 @@ module WellKnown
|
|||||||
include RoutingHelper
|
include RoutingHelper
|
||||||
|
|
||||||
before_action { response.headers['Vary'] = 'Accept' }
|
before_action { response.headers['Vary'] = 'Accept' }
|
||||||
|
before_action :set_account
|
||||||
|
before_action :check_account_suspension
|
||||||
|
|
||||||
|
rescue_from ActiveRecord::RecordNotFound, ActionController::ParameterMissing, with: :not_found
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@account = Account.find_local!(username_from_resource)
|
|
||||||
|
|
||||||
expires_in 3.days, public: true
|
expires_in 3.days, public: true
|
||||||
render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json'
|
render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json'
|
||||||
rescue ActiveRecord::RecordNotFound
|
|
||||||
head 404
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def set_account
|
||||||
|
@account = Account.find_local!(username_from_resource)
|
||||||
|
end
|
||||||
|
|
||||||
def username_from_resource
|
def username_from_resource
|
||||||
resource_user = resource_param
|
resource_user = resource_param
|
||||||
username, domain = resource_user.split('@')
|
username, domain = resource_user.split('@')
|
||||||
@ -28,5 +32,17 @@ module WellKnown
|
|||||||
def resource_param
|
def resource_param
|
||||||
params.require(:resource)
|
params.require(:resource)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def check_account_suspension
|
||||||
|
expires_in(3.minutes, public: true) && gone if @account.suspended?
|
||||||
|
end
|
||||||
|
|
||||||
|
def not_found
|
||||||
|
head 404
|
||||||
|
end
|
||||||
|
|
||||||
|
def gone
|
||||||
|
head 410
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -5,7 +5,7 @@ module Admin::FilterHelper
|
|||||||
REPORT_FILTERS = %i(resolved account_id target_account_id).freeze
|
REPORT_FILTERS = %i(resolved account_id target_account_id).freeze
|
||||||
INVITE_FILTER = %i(available expired).freeze
|
INVITE_FILTER = %i(available expired).freeze
|
||||||
CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze
|
CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze
|
||||||
TAGS_FILTERS = %i(context review).freeze
|
TAGS_FILTERS = %i(directory reviewed unreviewed pending_review popular active name).freeze
|
||||||
INSTANCES_FILTERS = %i(limited by_domain).freeze
|
INSTANCES_FILTERS = %i(limited by_domain).freeze
|
||||||
FOLLOWERS_FILTERS = %i(relationship status by_domain activity order).freeze
|
FOLLOWERS_FILTERS = %i(relationship status by_domain activity order).freeze
|
||||||
|
|
||||||
|
@ -77,8 +77,12 @@ module ApplicationHelper
|
|||||||
content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
|
content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
|
||||||
end
|
end
|
||||||
|
|
||||||
def custom_emoji_tag(custom_emoji)
|
def custom_emoji_tag(custom_emoji, animate = true)
|
||||||
image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:")
|
if animate
|
||||||
|
image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:")
|
||||||
|
else
|
||||||
|
image_tag(custom_emoji.image.url(:static), class: 'emojione custom-emoji', alt: ":#{custom_emoji.shortcode}", 'data-original' => full_asset_url(custom_emoji.image.url), 'data-static' => full_asset_url(custom_emoji.image.url(:static)))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def opengraph(property, content)
|
def opengraph(property, content)
|
||||||
|
@ -8,4 +8,16 @@ module InstanceHelper
|
|||||||
def site_hostname
|
def site_hostname
|
||||||
@site_hostname ||= Addressable::URI.parse("//#{Rails.configuration.x.local_domain}").display_uri.host
|
@site_hostname ||= Addressable::URI.parse("//#{Rails.configuration.x.local_domain}").display_uri.host
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def description_for_sign_up
|
||||||
|
prefix = begin
|
||||||
|
if @invite.present?
|
||||||
|
I18n.t('auth.description.prefix_invited_by_user', name: @invite.user.account.username)
|
||||||
|
else
|
||||||
|
I18n.t('auth.description.prefix_sign_up')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
safe_join([prefix, I18n.t('auth.description.suffix')], ' ')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -43,7 +43,8 @@ module SettingsHelper
|
|||||||
oc: 'Occitan',
|
oc: 'Occitan',
|
||||||
pl: 'Polski',
|
pl: 'Polski',
|
||||||
pt: 'Português',
|
pt: 'Português',
|
||||||
'pt-BR': 'Português do Brasil',
|
'pt-PT': 'Português (Portugal)',
|
||||||
|
'pt-BR': 'Português (Brasil)',
|
||||||
ro: 'Română',
|
ro: 'Română',
|
||||||
ru: 'Русский',
|
ru: 'Русский',
|
||||||
sk: 'Slovenčina',
|
sk: 'Slovenčina',
|
||||||
@ -86,4 +87,12 @@ module SettingsHelper
|
|||||||
'desktop'
|
'desktop'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def compact_account_link_to(account)
|
||||||
|
return if account.nil?
|
||||||
|
|
||||||
|
link_to ActivityPub::TagManager.instance.url_for(account), class: 'name-tag', title: account.acct do
|
||||||
|
safe_join([image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'), content_tag(:span, account.acct, class: 'username')], ' ')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -34,6 +34,26 @@ module StatusesHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def minimal_account_action_button(account)
|
||||||
|
if user_signed_in?
|
||||||
|
return if account.id == current_user.account_id
|
||||||
|
|
||||||
|
if current_account.following?(account) || current_account.requested?(account)
|
||||||
|
link_to account_unfollow_path(account), class: 'icon-button active', data: { method: :post }, title: t('accounts.unfollow') do
|
||||||
|
fa_icon('user-times fw')
|
||||||
|
end
|
||||||
|
elsif !(account.memorial? || account.moved?)
|
||||||
|
link_to account_follow_path(account), class: "icon-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post }, title: t('accounts.follow') do
|
||||||
|
fa_icon('user-plus fw')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elsif !(account.memorial? || account.moved?)
|
||||||
|
link_to account_remote_follow_path(account), class: 'icon-button modal-button', target: '_new', title: t('accounts.follow') do
|
||||||
|
fa_icon('user-plus fw')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def svg_logo
|
def svg_logo
|
||||||
content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976')
|
content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976')
|
||||||
end
|
end
|
||||||
|
@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl';
|
|||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
||||||
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
|
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
|
||||||
|
rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' },
|
||||||
|
rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ALERT_SHOW = 'ALERT_SHOW';
|
export const ALERT_SHOW = 'ALERT_SHOW';
|
||||||
@ -23,23 +25,29 @@ export function clearAlert() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) {
|
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) {
|
||||||
return {
|
return {
|
||||||
type: ALERT_SHOW,
|
type: ALERT_SHOW,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
|
message_values,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function showAlertForError(error) {
|
export function showAlertForError(error) {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
const { data, status, statusText } = error.response;
|
const { data, status, statusText, headers } = error.response;
|
||||||
|
|
||||||
if (status === 404 || status === 410) {
|
if (status === 404 || status === 410) {
|
||||||
// Skip these errors as they are reflected in the UI
|
// Skip these errors as they are reflected in the UI
|
||||||
return { type: ALERT_NOOP };
|
return { type: ALERT_NOOP };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === 429 && headers['x-ratelimit-reset']) {
|
||||||
|
const reset_date = new Date(headers['x-ratelimit-reset']);
|
||||||
|
return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date });
|
||||||
|
}
|
||||||
|
|
||||||
let message = statusText;
|
let message = statusText;
|
||||||
let title = `${status}`;
|
let title = `${status}`;
|
||||||
|
|
||||||
|
@ -356,6 +356,8 @@ const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
|
|||||||
cancelFetchComposeSuggestionsTags();
|
cancelFetchComposeSuggestionsTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispatch(updateSuggestionTags(token));
|
||||||
|
|
||||||
api(getState).get('/api/v2/search', {
|
api(getState).get('/api/v2/search', {
|
||||||
cancelToken: new CancelToken(cancel => {
|
cancelToken: new CancelToken(cancel => {
|
||||||
cancelFetchComposeSuggestionsTags = cancel;
|
cancelFetchComposeSuggestionsTags = cancel;
|
||||||
|
@ -15,6 +15,10 @@ export const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE';
|
|||||||
|
|
||||||
export const CONVERSATIONS_READ = 'CONVERSATIONS_READ';
|
export const CONVERSATIONS_READ = 'CONVERSATIONS_READ';
|
||||||
|
|
||||||
|
export const CONVERSATIONS_DELETE_REQUEST = 'CONVERSATIONS_DELETE_REQUEST';
|
||||||
|
export const CONVERSATIONS_DELETE_SUCCESS = 'CONVERSATIONS_DELETE_SUCCESS';
|
||||||
|
export const CONVERSATIONS_DELETE_FAIL = 'CONVERSATIONS_DELETE_FAIL';
|
||||||
|
|
||||||
export const mountConversations = () => ({
|
export const mountConversations = () => ({
|
||||||
type: CONVERSATIONS_MOUNT,
|
type: CONVERSATIONS_MOUNT,
|
||||||
});
|
});
|
||||||
@ -82,3 +86,27 @@ export const updateConversations = conversation => dispatch => {
|
|||||||
conversation,
|
conversation,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const deleteConversation = conversationId => (dispatch, getState) => {
|
||||||
|
dispatch(deleteConversationRequest(conversationId));
|
||||||
|
|
||||||
|
api(getState).delete(`/api/v1/conversations/${conversationId}`)
|
||||||
|
.then(() => dispatch(deleteConversationSuccess(conversationId)))
|
||||||
|
.catch(error => dispatch(deleteConversationFail(conversationId, error)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteConversationRequest = id => ({
|
||||||
|
type: CONVERSATIONS_DELETE_REQUEST,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteConversationSuccess = id => ({
|
||||||
|
type: CONVERSATIONS_DELETE_SUCCESS,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteConversationFail = (id, error) => ({
|
||||||
|
type: CONVERSATIONS_DELETE_FAIL,
|
||||||
|
id,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
61
app/javascript/mastodon/actions/directory.js
Normal file
61
app/javascript/mastodon/actions/directory.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import api from '../api';
|
||||||
|
import { importFetchedAccounts } from './importer';
|
||||||
|
import { fetchRelationships } from './accounts';
|
||||||
|
|
||||||
|
export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST';
|
||||||
|
export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS';
|
||||||
|
export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL';
|
||||||
|
|
||||||
|
export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST';
|
||||||
|
export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS';
|
||||||
|
export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL';
|
||||||
|
|
||||||
|
export const fetchDirectory = params => (dispatch, getState) => {
|
||||||
|
dispatch(fetchDirectoryRequest());
|
||||||
|
|
||||||
|
api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => {
|
||||||
|
dispatch(importFetchedAccounts(data));
|
||||||
|
dispatch(fetchDirectorySuccess(data));
|
||||||
|
dispatch(fetchRelationships(data.map(x => x.id)));
|
||||||
|
}).catch(error => dispatch(fetchDirectoryFail(error)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchDirectoryRequest = () => ({
|
||||||
|
type: DIRECTORY_FETCH_REQUEST,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchDirectorySuccess = accounts => ({
|
||||||
|
type: DIRECTORY_FETCH_SUCCESS,
|
||||||
|
accounts,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchDirectoryFail = error => ({
|
||||||
|
type: DIRECTORY_FETCH_FAIL,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const expandDirectory = params => (dispatch, getState) => {
|
||||||
|
dispatch(expandDirectoryRequest());
|
||||||
|
|
||||||
|
const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size;
|
||||||
|
|
||||||
|
api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => {
|
||||||
|
dispatch(importFetchedAccounts(data));
|
||||||
|
dispatch(expandDirectorySuccess(data));
|
||||||
|
dispatch(fetchRelationships(data.map(x => x.id)));
|
||||||
|
}).catch(error => dispatch(expandDirectoryFail(error)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expandDirectoryRequest = () => ({
|
||||||
|
type: DIRECTORY_EXPAND_REQUEST,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const expandDirectorySuccess = accounts => ({
|
||||||
|
type: DIRECTORY_EXPAND_SUCCESS,
|
||||||
|
accounts,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const expandDirectoryFail = error => ({
|
||||||
|
type: DIRECTORY_EXPAND_FAIL,
|
||||||
|
error,
|
||||||
|
});
|
30
app/javascript/mastodon/actions/markers.js
Normal file
30
app/javascript/mastodon/actions/markers.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export const submitMarkers = () => (dispatch, getState) => {
|
||||||
|
const accessToken = getState().getIn(['meta', 'access_token'], '');
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
const lastHomeId = getState().getIn(['timelines', 'home', 'items', 0]);
|
||||||
|
const lastNotificationId = getState().getIn(['notifications', 'items', 0, 'id']);
|
||||||
|
|
||||||
|
if (lastHomeId) {
|
||||||
|
params.home = {
|
||||||
|
last_read_id: lastHomeId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastNotificationId) {
|
||||||
|
params.notifications = {
|
||||||
|
last_read_id: lastNotificationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(params).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new XMLHttpRequest();
|
||||||
|
|
||||||
|
client.open('POST', '/api/v1/markers', false);
|
||||||
|
client.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
|
||||||
|
client.send(JSON.stringify(params));
|
||||||
|
};
|
@ -28,6 +28,9 @@ export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
|
|||||||
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
|
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
|
||||||
export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
|
export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
|
||||||
|
|
||||||
|
export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
|
||||||
|
export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
|
||||||
|
|
||||||
defineMessages({
|
defineMessages({
|
||||||
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
||||||
group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
|
group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
|
||||||
@ -151,7 +154,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
|
|||||||
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
|
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
|
||||||
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
|
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
|
||||||
|
|
||||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent && preferPendingItems));
|
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
|
||||||
fetchRelatedRelationships(dispatch, response.data);
|
fetchRelatedRelationships(dispatch, response.data);
|
||||||
done();
|
done();
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
@ -168,11 +171,12 @@ export function expandNotificationsRequest(isLoadingMore) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function expandNotificationsSuccess(notifications, next, isLoadingMore, usePendingItems) {
|
export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) {
|
||||||
return {
|
return {
|
||||||
type: NOTIFICATIONS_EXPAND_SUCCESS,
|
type: NOTIFICATIONS_EXPAND_SUCCESS,
|
||||||
notifications,
|
notifications,
|
||||||
next,
|
next,
|
||||||
|
isLoadingRecent: isLoadingRecent,
|
||||||
usePendingItems,
|
usePendingItems,
|
||||||
skipLoading: !isLoadingMore,
|
skipLoading: !isLoadingMore,
|
||||||
};
|
};
|
||||||
@ -214,3 +218,11 @@ export function setFilter (filterType) {
|
|||||||
dispatch(saveSettings());
|
dispatch(saveSettings());
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const mountNotifications = () => ({
|
||||||
|
type: NOTIFICATIONS_MOUNT,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const unmountNotifications = () => ({
|
||||||
|
type: NOTIFICATIONS_UNMOUNT,
|
||||||
|
});
|
||||||
|
@ -9,18 +9,18 @@ export default class AutosuggestHashtag extends React.PureComponent {
|
|||||||
tag: PropTypes.shape({
|
tag: PropTypes.shape({
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
url: PropTypes.string,
|
url: PropTypes.string,
|
||||||
history: PropTypes.array.isRequired,
|
history: PropTypes.array,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { tag } = this.props;
|
const { tag } = this.props;
|
||||||
const weeklyUses = shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));
|
const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='autosuggest-hashtag'>
|
<div className='autosuggest-hashtag'>
|
||||||
<div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div>
|
<div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div>
|
||||||
<div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>
|
{tag.history !== undefined && <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -35,35 +35,35 @@ export default class AvatarComposite extends React.PureComponent {
|
|||||||
|
|
||||||
if (size === 2) {
|
if (size === 2) {
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
right = '2px';
|
right = '1px';
|
||||||
} else {
|
} else {
|
||||||
left = '2px';
|
left = '1px';
|
||||||
}
|
}
|
||||||
} else if (size === 3) {
|
} else if (size === 3) {
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
right = '2px';
|
right = '1px';
|
||||||
} else if (index > 0) {
|
} else if (index > 0) {
|
||||||
left = '2px';
|
left = '1px';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === 1) {
|
if (index === 1) {
|
||||||
bottom = '2px';
|
bottom = '1px';
|
||||||
} else if (index > 1) {
|
} else if (index > 1) {
|
||||||
top = '2px';
|
top = '1px';
|
||||||
}
|
}
|
||||||
} else if (size === 4) {
|
} else if (size === 4) {
|
||||||
if (index === 0 || index === 2) {
|
if (index === 0 || index === 2) {
|
||||||
right = '2px';
|
right = '1px';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === 1 || index === 3) {
|
if (index === 1 || index === 3) {
|
||||||
left = '2px';
|
left = '1px';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index < 2) {
|
if (index < 2) {
|
||||||
bottom = '2px';
|
bottom = '1px';
|
||||||
} else {
|
} else {
|
||||||
top = '2px';
|
top = '1px';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,7 +88,13 @@ export default class AvatarComposite extends React.PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='account__avatar-composite' style={{ width: `${size}px`, height: `${size}px` }}>
|
<div className='account__avatar-composite' style={{ width: `${size}px`, height: `${size}px` }}>
|
||||||
{accounts.take(4).map((account, i) => this.renderItem(account, accounts.size, i))}
|
{accounts.take(4).map((account, i) => this.renderItem(account, Math.min(accounts.size, 4), i))}
|
||||||
|
|
||||||
|
{accounts.size > 4 && (
|
||||||
|
<span className='account__avatar-composite__label'>
|
||||||
|
+{accounts.size - 4}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,19 @@ export default class ColumnBackButton extends React.PureComponent {
|
|||||||
if (multiColumn) {
|
if (multiColumn) {
|
||||||
return component;
|
return component;
|
||||||
} else {
|
} else {
|
||||||
return createPortal(component, document.getElementById('tabs-bar__portal'));
|
// The portal container and the component may be rendered to the DOM in
|
||||||
|
// the same React render pass, so the container might not be available at
|
||||||
|
// the time `render()` is called.
|
||||||
|
const container = document.getElementById('tabs-bar__portal');
|
||||||
|
if (container === null) {
|
||||||
|
// The container wasn't available, force a re-render so that the
|
||||||
|
// component can eventually be inserted in the container and not scroll
|
||||||
|
// with the rest of the area.
|
||||||
|
this.forceUpdate();
|
||||||
|
return component;
|
||||||
|
} else {
|
||||||
|
return createPortal(component, container);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,7 +120,7 @@ class ColumnHeader extends React.PureComponent {
|
|||||||
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
|
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (multiColumn) {
|
} else if (multiColumn && this.props.onPin) {
|
||||||
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
|
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,7 +142,7 @@ class ColumnHeader extends React.PureComponent {
|
|||||||
collapsedContent.push(pinButton);
|
collapsedContent.push(pinButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (children || multiColumn) {
|
if (children || (multiColumn && this.props.onPin)) {
|
||||||
collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>;
|
collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,7 +178,19 @@ class ColumnHeader extends React.PureComponent {
|
|||||||
if (multiColumn || placeholder) {
|
if (multiColumn || placeholder) {
|
||||||
return component;
|
return component;
|
||||||
} else {
|
} else {
|
||||||
return createPortal(component, document.getElementById('tabs-bar__portal'));
|
// The portal container and the component may be rendered to the DOM in
|
||||||
|
// the same React render pass, so the container might not be available at
|
||||||
|
// the time `render()` is called.
|
||||||
|
const container = document.getElementById('tabs-bar__portal');
|
||||||
|
if (container === null) {
|
||||||
|
// The container wasn't available, force a re-render so that the
|
||||||
|
// component can eventually be inserted in the container and not scroll
|
||||||
|
// with the rest of the area.
|
||||||
|
this.forceUpdate();
|
||||||
|
return component;
|
||||||
|
} else {
|
||||||
|
return createPortal(component, container);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,11 +12,11 @@ const Hashtag = ({ hashtag }) => (
|
|||||||
#<span>{hashtag.get('name')}</span>
|
#<span>{hashtag.get('name')}</span>
|
||||||
</Permalink>
|
</Permalink>
|
||||||
|
|
||||||
<FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} />
|
<FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1, count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1)}</strong> }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='trends__item__current'>
|
<div className='trends__item__current'>
|
||||||
{shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
|
{shortNumberFormat(hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='trends__item__sparkline'>
|
<div className='trends__item__sparkline'>
|
||||||
|
@ -159,7 +159,7 @@ class Item extends React.PureComponent {
|
|||||||
if (attachment.get('type') === 'unknown') {
|
if (attachment.get('type') === 'unknown') {
|
||||||
return (
|
return (
|
||||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||||
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }} title={attachment.get('description')}>
|
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} target='_blank' style={{ cursor: 'pointer' }} title={attachment.get('description')}>
|
||||||
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
|
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -315,15 +315,22 @@ class MediaGallery extends React.PureComponent {
|
|||||||
style.height = height;
|
style.height = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
const size = media.take(4).size;
|
const size = media.take(4).size;
|
||||||
|
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
|
||||||
|
|
||||||
if (this.isStandaloneEligible()) {
|
if (this.isStandaloneEligible()) {
|
||||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
|
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
|
||||||
} else {
|
} else {
|
||||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />);
|
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible || uncached} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visible) {
|
if (uncached) {
|
||||||
|
spoilerButton = (
|
||||||
|
<button type='button' disabled className='spoiler-button__overlay'>
|
||||||
|
<span className='spoiler-button__overlay__label'><FormattedMessage id='status.uncached_media_warning' defaultMessage='Not available' /></span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
} else if (visible) {
|
||||||
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
|
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
|
||||||
} else {
|
} else {
|
||||||
spoilerButton = (
|
spoilerButton = (
|
||||||
@ -335,7 +342,7 @@ class MediaGallery extends React.PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='media-gallery' style={style} ref={this.handleRef}>
|
<div className='media-gallery' style={style} ref={this.handleRef}>
|
||||||
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
|
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
|
||||||
{spoilerButton}
|
{spoilerButton}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -32,8 +32,38 @@ class Poll extends ImmutablePureComponent {
|
|||||||
|
|
||||||
state = {
|
state = {
|
||||||
selected: {},
|
selected: {},
|
||||||
|
expired: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static getDerivedStateFromProps (props, state) {
|
||||||
|
const { poll, intl } = props;
|
||||||
|
const expired = poll.get('expired') || (new Date(poll.get('expires_at'))).getTime() < intl.now();
|
||||||
|
return (expired === state.expired) ? null : { expired };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
this._setupTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate () {
|
||||||
|
this._setupTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
clearTimeout(this._timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
_setupTimer () {
|
||||||
|
const { poll, intl } = this.props;
|
||||||
|
clearTimeout(this._timer);
|
||||||
|
if (!this.state.expired) {
|
||||||
|
const delay = (new Date(poll.get('expires_at'))).getTime() - intl.now();
|
||||||
|
this._timer = setTimeout(() => {
|
||||||
|
this.setState({ expired: true });
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleOptionChange = e => {
|
handleOptionChange = e => {
|
||||||
const { target: { value } } = e;
|
const { target: { value } } = e;
|
||||||
|
|
||||||
@ -68,12 +98,11 @@ class Poll extends ImmutablePureComponent {
|
|||||||
this.props.dispatch(fetchPoll(this.props.poll.get('id')));
|
this.props.dispatch(fetchPoll(this.props.poll.get('id')));
|
||||||
};
|
};
|
||||||
|
|
||||||
renderOption (option, optionIndex) {
|
renderOption (option, optionIndex, showResults) {
|
||||||
const { poll, disabled } = this.props;
|
const { poll, disabled } = this.props;
|
||||||
const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100;
|
const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100;
|
||||||
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count'));
|
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count'));
|
||||||
const active = !!this.state.selected[`${optionIndex}`];
|
const active = !!this.state.selected[`${optionIndex}`];
|
||||||
const showResults = poll.get('voted') || poll.get('expired');
|
|
||||||
|
|
||||||
let titleEmojified = option.get('title_emojified');
|
let titleEmojified = option.get('title_emojified');
|
||||||
if (!titleEmojified) {
|
if (!titleEmojified) {
|
||||||
@ -112,19 +141,20 @@ class Poll extends ImmutablePureComponent {
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { poll, intl } = this.props;
|
const { poll, intl } = this.props;
|
||||||
|
const { expired } = this.state;
|
||||||
|
|
||||||
if (!poll) {
|
if (!poll) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
|
const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
|
||||||
const showResults = poll.get('voted') || poll.get('expired');
|
const showResults = poll.get('voted') || expired;
|
||||||
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
|
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='poll'>
|
<div className='poll'>
|
||||||
<ul>
|
<ul>
|
||||||
{poll.get('options').map((option, i) => this.renderOption(option, i))}
|
{poll.get('options').map((option, i) => this.renderOption(option, i, showResults))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className='poll__footer'>
|
<div className='poll__footer'>
|
||||||
|
35
app/javascript/mastodon/components/radio_button.js
Normal file
35
app/javascript/mastodon/components/radio_button.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
export default class RadioButton extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
value: PropTypes.string.isRequired,
|
||||||
|
checked: PropTypes.bool,
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
label: PropTypes.node.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { name, value, checked, onChange, label } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className='radio-button'>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type='radio'
|
||||||
|
value={value}
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={classNames('radio-button__input', { checked })} />
|
||||||
|
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -172,8 +172,9 @@ export default class ScrollableList extends PureComponent {
|
|||||||
const someItemInserted = React.Children.count(prevProps.children) > 0 &&
|
const someItemInserted = React.Children.count(prevProps.children) > 0 &&
|
||||||
React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
|
React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
|
||||||
this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
|
this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
|
||||||
|
const pendingChanged = (prevProps.numPending > 0) !== (this.props.numPending > 0);
|
||||||
|
|
||||||
if (someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently)) {
|
if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently)) {
|
||||||
return this.getScrollHeight() - this.getScrollTop();
|
return this.getScrollHeight() - this.getScrollTop();
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
@ -198,7 +199,12 @@ export default class ScrollableList extends PureComponent {
|
|||||||
this.clearMouseIdleTimer();
|
this.clearMouseIdleTimer();
|
||||||
this.detachScrollListener();
|
this.detachScrollListener();
|
||||||
this.detachIntersectionObserver();
|
this.detachIntersectionObserver();
|
||||||
|
|
||||||
detachFullscreenListener(this.onFullScreenChange);
|
detachFullscreenListener(this.onFullScreenChange);
|
||||||
|
|
||||||
|
if (this.props.onScrollToTop) {
|
||||||
|
this.props.onScrollToTop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onFullScreenChange = () => {
|
onFullScreenChange = () => {
|
||||||
@ -261,6 +267,13 @@ export default class ScrollableList extends PureComponent {
|
|||||||
handleLoadPending = e => {
|
handleLoadPending = e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.props.onLoadPending();
|
this.props.onLoadPending();
|
||||||
|
// Prevent the weird scroll-jumping behavior, as we explicitly don't want to
|
||||||
|
// scroll to top, and we know the scroll height is going to change
|
||||||
|
this.scrollToTopOnMouseIdle = false;
|
||||||
|
this.lastScrollWasSynthetic = false;
|
||||||
|
this.clearMouseIdleTimer();
|
||||||
|
this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
|
||||||
|
this.mouseMovedRecently = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
@ -12,7 +12,7 @@ import AttachmentList from './attachment_list';
|
|||||||
import Card from '../features/status/components/card';
|
import Card from '../features/status/components/card';
|
||||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { MediaGallery, Video } from '../features/ui/util/async-components';
|
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
|
||||||
import { HotKeys } from 'react-hotkeys';
|
import { HotKeys } from 'react-hotkeys';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
@ -199,11 +199,15 @@ class Status extends ImmutablePureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderLoadingMediaGallery () {
|
renderLoadingMediaGallery () {
|
||||||
return <div className='media_gallery' style={{ height: '110px' }} />;
|
return <div className='media-gallery' style={{ height: '110px' }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderLoadingVideoPlayer () {
|
renderLoadingVideoPlayer () {
|
||||||
return <div className='media-spoiler-video' style={{ height: '110px' }} />;
|
return <div className='video-player' style={{ height: '110px' }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLoadingAudioPlayer () {
|
||||||
|
return <div className='audio-player' style={{ height: '110px' }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleOpenVideo = (media, startTime) => {
|
handleOpenVideo = (media, startTime) => {
|
||||||
@ -278,12 +282,27 @@ class Status extends ImmutablePureComponent {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlers = this.props.muted ? {} : {
|
||||||
|
reply: this.handleHotkeyReply,
|
||||||
|
favourite: this.handleHotkeyFavourite,
|
||||||
|
boost: this.handleHotkeyBoost,
|
||||||
|
mention: this.handleHotkeyMention,
|
||||||
|
open: this.handleHotkeyOpen,
|
||||||
|
openProfile: this.handleHotkeyOpenProfile,
|
||||||
|
moveUp: this.handleHotkeyMoveUp,
|
||||||
|
moveDown: this.handleHotkeyMoveDown,
|
||||||
|
toggleHidden: this.handleHotkeyToggleHidden,
|
||||||
|
toggleSensitive: this.handleHotkeyToggleSensitive,
|
||||||
|
};
|
||||||
|
|
||||||
if (hidden) {
|
if (hidden) {
|
||||||
return (
|
return (
|
||||||
<div ref={this.handleRef}>
|
<HotKeys handlers={handlers}>
|
||||||
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
|
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex='0'>
|
||||||
{status.get('content')}
|
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
|
||||||
</div>
|
{status.get('content')}
|
||||||
|
</div>
|
||||||
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -333,7 +352,23 @@ class Status extends ImmutablePureComponent {
|
|||||||
media={status.get('media_attachments')}
|
media={status.get('media_attachments')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
|
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||||
|
const attachment = status.getIn(['media_attachments', 0]);
|
||||||
|
|
||||||
|
media = (
|
||||||
|
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
|
||||||
|
{Component => (
|
||||||
|
<Component
|
||||||
|
src={attachment.get('url')}
|
||||||
|
alt={attachment.get('description')}
|
||||||
|
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||||
|
peaks={[0]}
|
||||||
|
height={70}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Bundle>
|
||||||
|
);
|
||||||
|
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
const attachment = status.getIn(['media_attachments', 0]);
|
const attachment = status.getIn(['media_attachments', 0]);
|
||||||
|
|
||||||
media = (
|
media = (
|
||||||
@ -394,19 +429,6 @@ class Status extends ImmutablePureComponent {
|
|||||||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlers = this.props.muted ? {} : {
|
|
||||||
reply: this.handleHotkeyReply,
|
|
||||||
favourite: this.handleHotkeyFavourite,
|
|
||||||
boost: this.handleHotkeyBoost,
|
|
||||||
mention: this.handleHotkeyMention,
|
|
||||||
open: this.handleHotkeyOpen,
|
|
||||||
openProfile: this.handleHotkeyOpenProfile,
|
|
||||||
moveUp: this.handleHotkeyMoveUp,
|
|
||||||
moveDown: this.handleHotkeyMoveDown,
|
|
||||||
toggleHidden: this.handleHotkeyToggleHidden,
|
|
||||||
toggleSensitive: this.handleHotkeyToggleSensitive,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers}>
|
<HotKeys handlers={handlers}>
|
||||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
|
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
|
||||||
|
@ -230,7 +230,7 @@ export default class StatusContent extends React.PureComponent {
|
|||||||
);
|
);
|
||||||
} else if (this.props.onClick) {
|
} else if (this.props.onClick) {
|
||||||
const output = [
|
const output = [
|
||||||
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'>
|
||||||
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
|
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
|
||||||
|
|
||||||
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||||
|
@ -12,6 +12,8 @@ import { hydrateStore } from '../actions/store';
|
|||||||
import { connectUserStream } from '../actions/streaming';
|
import { connectUserStream } from '../actions/streaming';
|
||||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||||
import { getLocale } from '../locales';
|
import { getLocale } from '../locales';
|
||||||
|
import { previewState as previewMediaState } from 'mastodon/features/ui/components/media_modal';
|
||||||
|
import { previewState as previewVideoState } from 'mastodon/features/ui/components/video_modal';
|
||||||
import initialState from '../initial_state';
|
import initialState from '../initial_state';
|
||||||
import ErrorBoundary from '../components/error_boundary';
|
import ErrorBoundary from '../components/error_boundary';
|
||||||
|
|
||||||
@ -35,6 +37,10 @@ class MastodonMount extends React.PureComponent {
|
|||||||
showIntroduction: PropTypes.bool,
|
showIntroduction: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
shouldUpdateScroll (_, { location }) {
|
||||||
|
return location.state !== previewMediaState && location.state !== previewVideoState;
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { showIntroduction } = this.props;
|
const { showIntroduction } = this.props;
|
||||||
|
|
||||||
@ -44,7 +50,7 @@ class MastodonMount extends React.PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter basename='/web'>
|
<BrowserRouter basename='/web'>
|
||||||
<ScrollContext>
|
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
|
||||||
<Route path='/' component={UI} />
|
<Route path='/' component={UI} />
|
||||||
</ScrollContext>
|
</ScrollContext>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
@ -7,6 +7,8 @@ import MediaGallery from '../components/media_gallery';
|
|||||||
import Video from '../features/video';
|
import Video from '../features/video';
|
||||||
import Card from '../features/status/components/card';
|
import Card from '../features/status/components/card';
|
||||||
import Poll from 'mastodon/components/poll';
|
import Poll from 'mastodon/components/poll';
|
||||||
|
import Hashtag from 'mastodon/components/hashtag';
|
||||||
|
import Audio from 'mastodon/features/audio';
|
||||||
import ModalRoot from '../components/modal_root';
|
import ModalRoot from '../components/modal_root';
|
||||||
import { getScrollbarWidth } from '../features/ui/components/modal_root';
|
import { getScrollbarWidth } from '../features/ui/components/modal_root';
|
||||||
import MediaModal from '../features/ui/components/media_modal';
|
import MediaModal from '../features/ui/components/media_modal';
|
||||||
@ -15,7 +17,7 @@ import { List as ImmutableList, fromJS } from 'immutable';
|
|||||||
const { localeData, messages } = getLocale();
|
const { localeData, messages } = getLocale();
|
||||||
addLocaleData(localeData);
|
addLocaleData(localeData);
|
||||||
|
|
||||||
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll };
|
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
|
||||||
|
|
||||||
export default class MediaContainer extends PureComponent {
|
export default class MediaContainer extends PureComponent {
|
||||||
|
|
||||||
@ -62,12 +64,13 @@ export default class MediaContainer extends PureComponent {
|
|||||||
{[].map.call(components, (component, i) => {
|
{[].map.call(components, (component, i) => {
|
||||||
const componentName = component.getAttribute('data-component');
|
const componentName = component.getAttribute('data-component');
|
||||||
const Component = MEDIA_COMPONENTS[componentName];
|
const Component = MEDIA_COMPONENTS[componentName];
|
||||||
const { media, card, poll, ...props } = JSON.parse(component.getAttribute('data-props'));
|
const { media, card, poll, hashtag, ...props } = JSON.parse(component.getAttribute('data-props'));
|
||||||
|
|
||||||
Object.assign(props, {
|
Object.assign(props, {
|
||||||
...(media ? { media: fromJS(media) } : {}),
|
...(media ? { media: fromJS(media) } : {}),
|
||||||
...(card ? { card: fromJS(card) } : {}),
|
...(card ? { card: fromJS(card) } : {}),
|
||||||
...(poll ? { poll: fromJS(poll) } : {}),
|
...(poll ? { poll: fromJS(poll) } : {}),
|
||||||
|
...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
|
||||||
|
|
||||||
...(componentName === 'Video' ? {
|
...(componentName === 'Video' ? {
|
||||||
onOpenVideo: this.handleOpenVideo,
|
onOpenVideo: this.handleOpenVideo,
|
||||||
@ -81,6 +84,7 @@ export default class MediaContainer extends PureComponent {
|
|||||||
component,
|
component,
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<ModalRoot onClose={this.handleCloseMedia}>
|
<ModalRoot onClose={this.handleCloseMedia}>
|
||||||
{this.state.media && (
|
{this.state.media && (
|
||||||
<MediaModal
|
<MediaModal
|
||||||
|
@ -56,6 +56,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||||||
onReply (status, router) {
|
onReply (status, router) {
|
||||||
dispatch((_, getState) => {
|
dispatch((_, getState) => {
|
||||||
let state = getState();
|
let state = getState();
|
||||||
|
|
||||||
if (state.getIn(['compose', 'text']).trim().length !== 0) {
|
if (state.getIn(['compose', 'text']).trim().length !== 0) {
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(openModal('CONFIRM', {
|
||||||
message: intl.formatMessage(messages.replyMessage),
|
message: intl.formatMessage(messages.replyMessage),
|
||||||
|
@ -96,6 +96,12 @@ export default class MediaItem extends ImmutablePureComponent {
|
|||||||
|
|
||||||
if (attachment.get('type') === 'unknown') {
|
if (attachment.get('type') === 'unknown') {
|
||||||
// Skip
|
// Skip
|
||||||
|
} else if (attachment.get('type') === 'audio') {
|
||||||
|
thumbnail = (
|
||||||
|
<span className='account-gallery__item__icons'>
|
||||||
|
<Icon id='music' />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
} else if (attachment.get('type') === 'image') {
|
} else if (attachment.get('type') === 'image') {
|
||||||
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
|
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
|
||||||
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
|
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
|
||||||
|
@ -100,7 +100,7 @@ class AccountGallery extends ImmutablePureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleOpenMedia = attachment => {
|
handleOpenMedia = attachment => {
|
||||||
if (attachment.get('type') === 'video') {
|
if (['video', 'audio'].includes(attachment.get('type'))) {
|
||||||
this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') }));
|
this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') }));
|
||||||
} else {
|
} else {
|
||||||
const media = attachment.getIn(['status', 'media_attachments']);
|
const media = attachment.getIn(['status', 'media_attachments']);
|
||||||
|
226
app/javascript/mastodon/features/audio/index.js
Normal file
226
app/javascript/mastodon/features/audio/index.js
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
import { formatTime } from 'mastodon/features/video';
|
||||||
|
import Icon from 'mastodon/components/icon';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { throttle } from 'lodash';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||||
|
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
||||||
|
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
|
||||||
|
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @injectIntl
|
||||||
|
class Audio extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
src: PropTypes.string.isRequired,
|
||||||
|
alt: PropTypes.string,
|
||||||
|
duration: PropTypes.number,
|
||||||
|
peaks: PropTypes.arrayOf(PropTypes.number),
|
||||||
|
height: PropTypes.number,
|
||||||
|
preload: PropTypes.bool,
|
||||||
|
editable: PropTypes.bool,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
currentTime: 0,
|
||||||
|
duration: null,
|
||||||
|
paused: true,
|
||||||
|
muted: false,
|
||||||
|
volume: 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
// hard coded in components.scss
|
||||||
|
// any way to get ::before values programatically?
|
||||||
|
|
||||||
|
volWidth = 50;
|
||||||
|
|
||||||
|
volOffset = 70;
|
||||||
|
|
||||||
|
volHandleOffset = v => {
|
||||||
|
const offset = v * this.volWidth + this.volOffset;
|
||||||
|
return (offset > 110) ? 110 : offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolumeRef = c => {
|
||||||
|
this.volume = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWaveformRef = c => {
|
||||||
|
this.waveform = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
if (this.waveform) {
|
||||||
|
this._updateWaveform();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate (prevProps) {
|
||||||
|
if (this.waveform && prevProps.src !== this.props.src) {
|
||||||
|
this._updateWaveform();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
if (this.wavesurfer) {
|
||||||
|
this.wavesurfer.destroy();
|
||||||
|
this.wavesurfer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateWaveform () {
|
||||||
|
const { src, height, duration, peaks, preload } = this.props;
|
||||||
|
|
||||||
|
const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color');
|
||||||
|
const waveColor = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color');
|
||||||
|
|
||||||
|
if (this.wavesurfer) {
|
||||||
|
this.wavesurfer.destroy();
|
||||||
|
this.loaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wavesurfer = WaveSurfer.create({
|
||||||
|
container: this.waveform,
|
||||||
|
height,
|
||||||
|
barWidth: 3,
|
||||||
|
cursorWidth: 0,
|
||||||
|
progressColor,
|
||||||
|
waveColor,
|
||||||
|
backend: 'MediaElement',
|
||||||
|
interact: preload,
|
||||||
|
});
|
||||||
|
|
||||||
|
wavesurfer.setVolume(this.state.volume);
|
||||||
|
|
||||||
|
if (preload) {
|
||||||
|
wavesurfer.load(src);
|
||||||
|
this.loaded = true;
|
||||||
|
} else {
|
||||||
|
wavesurfer.load(src, peaks, 'none', duration);
|
||||||
|
this.loaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) }));
|
||||||
|
wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) }));
|
||||||
|
wavesurfer.on('pause', () => this.setState({ paused: true }));
|
||||||
|
wavesurfer.on('play', () => this.setState({ paused: false }));
|
||||||
|
wavesurfer.on('volume', volume => this.setState({ volume }));
|
||||||
|
wavesurfer.on('mute', muted => this.setState({ muted }));
|
||||||
|
|
||||||
|
this.wavesurfer = wavesurfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePlay = () => {
|
||||||
|
if (this.state.paused) {
|
||||||
|
if (!this.props.preload && !this.loaded) {
|
||||||
|
this.wavesurfer.createBackend();
|
||||||
|
this.wavesurfer.createPeakCache();
|
||||||
|
this.wavesurfer.load(this.props.src);
|
||||||
|
this.wavesurfer.toggleInteraction();
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.wavesurfer.play();
|
||||||
|
this.setState({ paused: false });
|
||||||
|
} else {
|
||||||
|
this.wavesurfer.pause();
|
||||||
|
this.setState({ paused: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMute = () => {
|
||||||
|
this.wavesurfer.setMute(!this.state.muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleVolumeMouseDown = e => {
|
||||||
|
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
|
||||||
|
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
|
||||||
|
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
|
||||||
|
document.addEventListener('touchend', this.handleVolumeMouseUp, true);
|
||||||
|
|
||||||
|
this.handleMouseVolSlide(e);
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleVolumeMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
|
||||||
|
document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
|
||||||
|
document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
|
||||||
|
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseVolSlide = throttle(e => {
|
||||||
|
const rect = this.volume.getBoundingClientRect();
|
||||||
|
const x = (e.clientX - rect.left) / this.volWidth; // x position within the element.
|
||||||
|
|
||||||
|
if(!isNaN(x)) {
|
||||||
|
let slideamt = x;
|
||||||
|
|
||||||
|
if (x > 1) {
|
||||||
|
slideamt = 1;
|
||||||
|
} else if(x < 0) {
|
||||||
|
slideamt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.wavesurfer.setVolume(slideamt);
|
||||||
|
}
|
||||||
|
}, 60);
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { height, intl, alt, editable } = this.props;
|
||||||
|
const { paused, muted, volume, currentTime } = this.state;
|
||||||
|
|
||||||
|
const volumeWidth = muted ? 0 : volume * this.volWidth;
|
||||||
|
const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames('audio-player', { editable })}>
|
||||||
|
<div className='audio-player__progress-placeholder' style={{ display: 'none' }} />
|
||||||
|
<div className='audio-player__wave-placeholder' style={{ display: 'none' }} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className='audio-player__waveform'
|
||||||
|
aria-label={alt}
|
||||||
|
title={alt}
|
||||||
|
style={{ height }}
|
||||||
|
ref={this.setWaveformRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='video-player__controls active'>
|
||||||
|
<div className='video-player__buttons-bar'>
|
||||||
|
<div className='video-player__buttons left'>
|
||||||
|
<button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
|
||||||
|
<button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
|
||||||
|
|
||||||
|
<div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
|
||||||
|
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={classNames('video-player__volume__handle')}
|
||||||
|
tabIndex='0'
|
||||||
|
style={{ left: `${volumeHandleLoc}px` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
|
||||||
|
<span className='video-player__time-sep'>/</span>
|
||||||
|
<span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -18,9 +18,10 @@ const mapStateToProps = (state, { onlyMedia, columnId }) => {
|
|||||||
const uuid = columnId;
|
const uuid = columnId;
|
||||||
const columns = state.getIn(['settings', 'columns']);
|
const columns = state.getIn(['settings', 'columns']);
|
||||||
const index = columns.findIndex(c => c.get('uuid') === uuid);
|
const index = columns.findIndex(c => c.get('uuid') === uuid);
|
||||||
|
const timelineState = state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasUnread: state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`, 'unread']) > 0,
|
hasUnread: !!timelineState && (timelineState.get('unread') > 0 || timelineState.get('pendingItems').size > 0),
|
||||||
onlyMedia: (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']),
|
onlyMedia: (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -23,9 +23,14 @@ class ActionBar extends React.PureComponent {
|
|||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
account: ImmutablePropTypes.map.isRequired,
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
|
onLogout: PropTypes.func.isRequired,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleLogout = () => {
|
||||||
|
this.props.onLogout();
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl } = this.props;
|
const { intl } = this.props;
|
||||||
|
|
||||||
@ -44,7 +49,7 @@ class ActionBar extends React.PureComponent {
|
|||||||
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
|
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
|
||||||
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
|
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
menu.push({ text: intl.formatMessage(messages.logout), href: '/auth/sign_out', target: null, method: 'delete' });
|
menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='compose__action-bar'>
|
<div className='compose__action-bar'>
|
||||||
|
@ -12,6 +12,7 @@ export default class NavigationBar extends ImmutablePureComponent {
|
|||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
account: ImmutablePropTypes.map.isRequired,
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
|
onLogout: PropTypes.func.isRequired,
|
||||||
onClose: PropTypes.func,
|
onClose: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -33,7 +34,7 @@ export default class NavigationBar extends ImmutablePureComponent {
|
|||||||
|
|
||||||
<div className='navigation-bar__actions'>
|
<div className='navigation-bar__actions'>
|
||||||
<IconButton className='close' title='' icon='close' onClick={this.props.onClose} />
|
<IconButton className='close' title='' icon='close' onClick={this.props.onClose} />
|
||||||
<ActionBar account={this.props.account} />
|
<ActionBar account={this.props.account} onLogout={this.props.onLogout} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,11 +1,29 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import NavigationBar from '../components/navigation_bar';
|
import NavigationBar from '../components/navigation_bar';
|
||||||
|
import { logOut } from 'mastodon/utils/log_out';
|
||||||
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
import { me } from '../../../initial_state';
|
import { me } from '../../../initial_state';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
|
||||||
|
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
|
||||||
|
});
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
const mapStateToProps = state => {
|
||||||
return {
|
return {
|
||||||
account: state.getIn(['accounts', me]),
|
account: state.getIn(['accounts', me]),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(mapStateToProps)(NavigationBar);
|
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
|
onLogout () {
|
||||||
|
dispatch(openModal('CONFIRM', {
|
||||||
|
message: intl.formatMessage(messages.logoutMessage),
|
||||||
|
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||||
|
onConfirm: () => logOut(),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar));
|
||||||
|
@ -12,9 +12,11 @@ import Motion from '../ui/util/optional_motion';
|
|||||||
import spring from 'react-motion/lib/spring';
|
import spring from 'react-motion/lib/spring';
|
||||||
import SearchResultsContainer from './containers/search_results_container';
|
import SearchResultsContainer from './containers/search_results_container';
|
||||||
import { changeComposing } from '../../actions/compose';
|
import { changeComposing } from '../../actions/compose';
|
||||||
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
|
import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
|
||||||
import { mascot } from '../../initial_state';
|
import { mascot } from '../../initial_state';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
|
import { logOut } from 'mastodon/utils/log_out';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||||
@ -25,6 +27,8 @@ const messages = defineMessages({
|
|||||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||||
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' },
|
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' },
|
||||||
|
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
|
||||||
|
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = (state, ownProps) => ({
|
const mapStateToProps = (state, ownProps) => ({
|
||||||
@ -61,6 +65,21 @@ class Compose extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleLogoutClick = e => {
|
||||||
|
const { dispatch, intl } = this.props;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
dispatch(openModal('CONFIRM', {
|
||||||
|
message: intl.formatMessage(messages.logoutMessage),
|
||||||
|
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||||
|
onConfirm: () => logOut(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
onFocus = () => {
|
onFocus = () => {
|
||||||
this.props.dispatch(changeComposing(true));
|
this.props.dispatch(changeComposing(true));
|
||||||
}
|
}
|
||||||
@ -92,7 +111,7 @@ class Compose extends React.PureComponent {
|
|||||||
<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='/timelines/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='/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' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><Icon id='sign-out' 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>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,9 +2,28 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import StatusContainer from '../../../containers/status_container';
|
import StatusContent from 'mastodon/components/status_content';
|
||||||
|
import AttachmentList from 'mastodon/components/attachment_list';
|
||||||
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||||
|
import AvatarComposite from 'mastodon/components/avatar_composite';
|
||||||
|
import Permalink from 'mastodon/components/permalink';
|
||||||
|
import IconButton from 'mastodon/components/icon_button';
|
||||||
|
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
|
||||||
|
import { HotKeys } from 'react-hotkeys';
|
||||||
|
|
||||||
export default class Conversation extends ImmutablePureComponent {
|
const messages = defineMessages({
|
||||||
|
more: { id: 'status.more', defaultMessage: 'More' },
|
||||||
|
open: { id: 'conversation.open', defaultMessage: 'View conversation' },
|
||||||
|
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
||||||
|
markAsRead: { id: 'conversation.mark_as_read', defaultMessage: 'Mark as read' },
|
||||||
|
delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
|
||||||
|
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
|
||||||
|
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @injectIntl
|
||||||
|
class Conversation extends ImmutablePureComponent {
|
||||||
|
|
||||||
static contextTypes = {
|
static contextTypes = {
|
||||||
router: PropTypes.object,
|
router: PropTypes.object,
|
||||||
@ -13,11 +32,12 @@ export default class Conversation extends ImmutablePureComponent {
|
|||||||
static propTypes = {
|
static propTypes = {
|
||||||
conversationId: PropTypes.string.isRequired,
|
conversationId: PropTypes.string.isRequired,
|
||||||
accounts: ImmutablePropTypes.list.isRequired,
|
accounts: ImmutablePropTypes.list.isRequired,
|
||||||
lastStatusId: PropTypes.string,
|
lastStatus: ImmutablePropTypes.map,
|
||||||
unread:PropTypes.bool.isRequired,
|
unread:PropTypes.bool.isRequired,
|
||||||
onMoveUp: PropTypes.func,
|
onMoveUp: PropTypes.func,
|
||||||
onMoveDown: PropTypes.func,
|
onMoveDown: PropTypes.func,
|
||||||
markRead: PropTypes.func.isRequired,
|
markRead: PropTypes.func.isRequired,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
handleClick = () => {
|
handleClick = () => {
|
||||||
@ -25,13 +45,25 @@ export default class Conversation extends ImmutablePureComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { lastStatusId, unread, markRead } = this.props;
|
const { lastStatus, unread, markRead } = this.props;
|
||||||
|
|
||||||
if (unread) {
|
if (unread) {
|
||||||
markRead();
|
markRead();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.context.router.history.push(`/statuses/${lastStatusId}`);
|
this.context.router.history.push(`/statuses/${lastStatus.get('id')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMarkAsRead = () => {
|
||||||
|
this.props.markRead();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReply = () => {
|
||||||
|
this.props.reply(this.props.lastStatus, this.context.router.history);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDelete = () => {
|
||||||
|
this.props.delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHotkeyMoveUp = () => {
|
handleHotkeyMoveUp = () => {
|
||||||
@ -42,22 +74,88 @@ export default class Conversation extends ImmutablePureComponent {
|
|||||||
this.props.onMoveDown(this.props.conversationId);
|
this.props.onMoveDown(this.props.conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
handleConversationMute = () => {
|
||||||
const { accounts, lastStatusId, unread } = this.props;
|
this.props.onMute(this.props.lastStatus);
|
||||||
|
}
|
||||||
|
|
||||||
if (lastStatusId === null) {
|
handleShowMore = () => {
|
||||||
|
this.props.onToggleHidden(this.props.lastStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { accounts, lastStatus, unread, intl } = this.props;
|
||||||
|
|
||||||
|
if (lastStatus === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const menu = [
|
||||||
|
{ text: intl.formatMessage(messages.open), action: this.handleClick },
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
|
||||||
|
menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute });
|
||||||
|
|
||||||
|
if (unread) {
|
||||||
|
menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead });
|
||||||
|
menu.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 handlers = {
|
||||||
|
reply: this.handleReply,
|
||||||
|
open: this.handleClick,
|
||||||
|
moveUp: this.handleHotkeyMoveUp,
|
||||||
|
moveDown: this.handleHotkeyMoveDown,
|
||||||
|
toggleHidden: this.handleShowMore,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusContainer
|
<HotKeys handlers={handlers}>
|
||||||
id={lastStatusId}
|
<div className='conversation focusable muted' tabIndex='0'>
|
||||||
unread={unread}
|
<div className='conversation__avatar'>
|
||||||
otherAccounts={accounts}
|
<AvatarComposite accounts={accounts} size={48} />
|
||||||
onMoveUp={this.handleHotkeyMoveUp}
|
</div>
|
||||||
onMoveDown={this.handleHotkeyMoveDown}
|
|
||||||
onClick={this.handleClick}
|
<div className='conversation__content'>
|
||||||
/>
|
<div className='conversation__content__info'>
|
||||||
|
<div className='conversation__content__relative-time'>
|
||||||
|
<RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='conversation__content__names'>
|
||||||
|
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusContent
|
||||||
|
status={lastStatus}
|
||||||
|
onClick={this.handleClick}
|
||||||
|
expanded={!lastStatus.get('hidden')}
|
||||||
|
onExpandedToggle={this.handleShowMore}
|
||||||
|
collapsable
|
||||||
|
/>
|
||||||
|
|
||||||
|
{lastStatus.get('media_attachments').size > 0 && (
|
||||||
|
<AttachmentList
|
||||||
|
compact
|
||||||
|
media={lastStatus.get('media_attachments')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='status__action-bar'>
|
||||||
|
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReply} />
|
||||||
|
|
||||||
|
<div className='status__action-bar-dropdown'>
|
||||||
|
<DropdownMenuContainer status={lastStatus} items={menu} icon='ellipsis-h' size={18} direction='right' title={intl.formatMessage(messages.more)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,19 +1,74 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import Conversation from '../components/conversation';
|
import Conversation from '../components/conversation';
|
||||||
import { markConversationRead } from '../../../actions/conversations';
|
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
|
||||||
|
import { makeGetStatus } from 'mastodon/selectors';
|
||||||
|
import { replyCompose } from 'mastodon/actions/compose';
|
||||||
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
|
import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'mastodon/actions/statuses';
|
||||||
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
const mapStateToProps = (state, { conversationId }) => {
|
const messages = defineMessages({
|
||||||
const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
|
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
||||||
|
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
const mapStateToProps = () => {
|
||||||
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
|
const getStatus = makeGetStatus();
|
||||||
unread: conversation.get('unread'),
|
|
||||||
lastStatusId: conversation.get('last_status', null),
|
return (state, { conversationId }) => {
|
||||||
|
const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
|
||||||
|
const lastStatusId = conversation.get('last_status', null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
|
||||||
|
unread: conversation.get('unread'),
|
||||||
|
lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { conversationId }) => ({
|
const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({
|
||||||
markRead: () => dispatch(markConversationRead(conversationId)),
|
|
||||||
|
markRead () {
|
||||||
|
dispatch(markConversationRead(conversationId));
|
||||||
|
},
|
||||||
|
|
||||||
|
reply (status, router) {
|
||||||
|
dispatch((_, getState) => {
|
||||||
|
let state = getState();
|
||||||
|
|
||||||
|
if (state.getIn(['compose', 'text']).trim().length !== 0) {
|
||||||
|
dispatch(openModal('CONFIRM', {
|
||||||
|
message: intl.formatMessage(messages.replyMessage),
|
||||||
|
confirm: intl.formatMessage(messages.replyConfirm),
|
||||||
|
onConfirm: () => dispatch(replyCompose(status, router)),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
dispatch(replyCompose(status, router));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
delete () {
|
||||||
|
dispatch(deleteConversation(conversationId));
|
||||||
|
},
|
||||||
|
|
||||||
|
onMute (status) {
|
||||||
|
if (status.get('muted')) {
|
||||||
|
dispatch(unmuteStatus(status.get('id')));
|
||||||
|
} else {
|
||||||
|
dispatch(muteStatus(status.get('id')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onToggleHidden (status) {
|
||||||
|
if (status.get('hidden')) {
|
||||||
|
dispatch(revealStatus(status.get('id')));
|
||||||
|
} else {
|
||||||
|
dispatch(hideStatus(status.get('id')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(Conversation);
|
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Conversation));
|
||||||
|
@ -0,0 +1,190 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { makeGetAccount } from 'mastodon/selectors';
|
||||||
|
import Avatar from 'mastodon/components/avatar';
|
||||||
|
import DisplayName from 'mastodon/components/display_name';
|
||||||
|
import Permalink from 'mastodon/components/permalink';
|
||||||
|
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
|
||||||
|
import IconButton from 'mastodon/components/icon_button';
|
||||||
|
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
||||||
|
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
|
||||||
|
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
||||||
|
import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts';
|
||||||
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
|
import { initMuteModal } from 'mastodon/actions/mutes';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||||
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
|
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
|
||||||
|
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||||
|
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeMapStateToProps = () => {
|
||||||
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
|
const mapStateToProps = (state, { id }) => ({
|
||||||
|
account: getAccount(state, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapStateToProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
|
|
||||||
|
onFollow (account) {
|
||||||
|
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
|
||||||
|
if (unfollowModal) {
|
||||||
|
dispatch(openModal('CONFIRM', {
|
||||||
|
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
||||||
|
confirm: intl.formatMessage(messages.unfollowConfirm),
|
||||||
|
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
dispatch(unfollowAccount(account.get('id')));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dispatch(followAccount(account.get('id')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onBlock (account) {
|
||||||
|
if (account.getIn(['relationship', 'blocking'])) {
|
||||||
|
dispatch(unblockAccount(account.get('id')));
|
||||||
|
} else {
|
||||||
|
dispatch(blockAccount(account.get('id')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onMute (account) {
|
||||||
|
if (account.getIn(['relationship', 'muting'])) {
|
||||||
|
dispatch(unmuteAccount(account.get('id')));
|
||||||
|
} else {
|
||||||
|
dispatch(initMuteModal(account));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @injectIntl
|
||||||
|
@connect(makeMapStateToProps, mapDispatchToProps)
|
||||||
|
class AccountCard extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
onFollow: PropTypes.func.isRequired,
|
||||||
|
onBlock: PropTypes.func.isRequired,
|
||||||
|
onMute: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
_updateEmojis () {
|
||||||
|
const node = this.node;
|
||||||
|
|
||||||
|
if (!node || autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis = node.querySelectorAll('.custom-emoji');
|
||||||
|
|
||||||
|
for (var i = 0; i < emojis.length; i++) {
|
||||||
|
let emoji = emojis[i];
|
||||||
|
if (emoji.classList.contains('status-emoji')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
emoji.classList.add('status-emoji');
|
||||||
|
|
||||||
|
emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
|
||||||
|
emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
this._updateEmojis();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate () {
|
||||||
|
this._updateEmojis();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEmojiMouseEnter = ({ target }) => {
|
||||||
|
target.src = target.getAttribute('data-original');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEmojiMouseLeave = ({ target }) => {
|
||||||
|
target.src = target.getAttribute('data-static');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFollow = () => {
|
||||||
|
this.props.onFollow(this.props.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBlock = () => {
|
||||||
|
this.props.onBlock(this.props.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMute = () => {
|
||||||
|
this.props.onMute(this.props.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRef = (c) => {
|
||||||
|
this.node = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { account, intl } = this.props;
|
||||||
|
|
||||||
|
let buttons;
|
||||||
|
|
||||||
|
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||||
|
const following = account.getIn(['relationship', 'following']);
|
||||||
|
const requested = account.getIn(['relationship', 'requested']);
|
||||||
|
const blocking = account.getIn(['relationship', 'blocking']);
|
||||||
|
const muting = account.getIn(['relationship', 'muting']);
|
||||||
|
|
||||||
|
if (requested) {
|
||||||
|
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
|
||||||
|
} else if (blocking) {
|
||||||
|
buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
|
||||||
|
} else if (muting) {
|
||||||
|
buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
|
||||||
|
} else if (!account.get('moved') || following) {
|
||||||
|
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='directory__card'>
|
||||||
|
<div className='directory__card__img'>
|
||||||
|
<img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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__relationship account__relationship'>
|
||||||
|
{buttons}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='directory__card__extra' ref={this.setRef}>
|
||||||
|
<div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='directory__card__extra'>
|
||||||
|
<div className='accounts-table__count'>{shortNumberFormat(account.get('statuses_count'))} <small><FormattedMessage id='account.posts' defaultMessage='Toots' /></small></div>
|
||||||
|
<div className='accounts-table__count'>{shortNumberFormat(account.get('followers_count'))} <small><FormattedMessage id='account.followers' defaultMessage='Followers' /></small></div>
|
||||||
|
<div className='accounts-table__count'>{account.get('last_status_at') === null ? <FormattedMessage id='account.never_active' defaultMessage='Never' /> : <RelativeTimestamp timestamp={account.get('last_status_at')} />} <small><FormattedMessage id='account.last_status' defaultMessage='Last active' /></small></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
171
app/javascript/mastodon/features/directory/index.js
Normal file
171
app/javascript/mastodon/features/directory/index.js
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import Column from 'mastodon/components/column';
|
||||||
|
import ColumnHeader from 'mastodon/components/column_header';
|
||||||
|
import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'mastodon/actions/columns';
|
||||||
|
import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
|
||||||
|
recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
|
||||||
|
newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
|
||||||
|
local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
|
||||||
|
federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
|
||||||
|
isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
|
||||||
|
domain: state.getIn(['meta', 'domain']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps)
|
||||||
|
@injectIntl
|
||||||
|
class Directory extends React.PureComponent {
|
||||||
|
|
||||||
|
static contextTypes = {
|
||||||
|
router: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
isLoading: PropTypes.bool,
|
||||||
|
accountIds: ImmutablePropTypes.list.isRequired,
|
||||||
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
shouldUpdateScroll: PropTypes.func,
|
||||||
|
columnId: PropTypes.string,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
multiColumn: PropTypes.bool,
|
||||||
|
domain: PropTypes.string.isRequired,
|
||||||
|
params: PropTypes.shape({
|
||||||
|
order: PropTypes.string,
|
||||||
|
local: PropTypes.bool,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
order: null,
|
||||||
|
local: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
handlePin = () => {
|
||||||
|
const { columnId, dispatch } = this.props;
|
||||||
|
|
||||||
|
if (columnId) {
|
||||||
|
dispatch(removeColumn(columnId));
|
||||||
|
} else {
|
||||||
|
dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getParams = (props, state) => ({
|
||||||
|
order: state.order === null ? (props.params.order || 'active') : state.order,
|
||||||
|
local: state.local === null ? (props.params.local || false) : state.local,
|
||||||
|
});
|
||||||
|
|
||||||
|
handleMove = dir => {
|
||||||
|
const { columnId, dispatch } = this.props;
|
||||||
|
dispatch(moveColumn(columnId, dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHeaderClick = () => {
|
||||||
|
this.column.scrollTop();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch(fetchDirectory(this.getParams(this.props, this.state)));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate (prevProps, prevState) {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
const paramsOld = this.getParams(prevProps, prevState);
|
||||||
|
const paramsNew = this.getParams(this.props, this.state);
|
||||||
|
|
||||||
|
if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
|
||||||
|
dispatch(fetchDirectory(paramsNew));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setRef = c => {
|
||||||
|
this.column = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChangeOrder = e => {
|
||||||
|
const { dispatch, columnId } = this.props;
|
||||||
|
|
||||||
|
if (columnId) {
|
||||||
|
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
|
||||||
|
} else {
|
||||||
|
this.setState({ order: e.target.value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChangeLocal = e => {
|
||||||
|
const { dispatch, columnId } = this.props;
|
||||||
|
|
||||||
|
if (columnId) {
|
||||||
|
dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
|
||||||
|
} else {
|
||||||
|
this.setState({ local: e.target.value === '1' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLoadMore = () => {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch(expandDirectory(this.getParams(this.props, this.state)));
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props;
|
||||||
|
const { order, local } = this.getParams(this.props, this.state);
|
||||||
|
const pinned = !!columnId;
|
||||||
|
|
||||||
|
const scrollableArea = (
|
||||||
|
<div className='scrollable' style={{ background: 'transparent' }}>
|
||||||
|
<div className='filter-form'>
|
||||||
|
<div className='filter-form__column' role='group'>
|
||||||
|
<RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} />
|
||||||
|
<RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='filter-form__column' role='group'>
|
||||||
|
<RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} />
|
||||||
|
<RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classNames('directory__list', { loading: isLoading })}>
|
||||||
|
{accountIds.map(accountId => <AccountCard id={accountId} key={accountId} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoadMore onClick={this.handleLoadMore} visible={!isLoading} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||||
|
<ColumnHeader
|
||||||
|
icon='address-book-o'
|
||||||
|
title={intl.formatMessage(messages.title)}
|
||||||
|
onPin={this.handlePin}
|
||||||
|
onMove={this.handleMove}
|
||||||
|
onClick={this.handleHeaderClick}
|
||||||
|
pinned={pinned}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea}
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -3,6 +3,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Hashtag from 'mastodon/components/hashtag';
|
import Hashtag from 'mastodon/components/hashtag';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
export default class Trends extends ImmutablePureComponent {
|
export default class Trends extends ImmutablePureComponent {
|
||||||
|
|
||||||
@ -17,7 +18,7 @@ export default class Trends extends ImmutablePureComponent {
|
|||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
this.props.fetchTrends();
|
this.props.fetchTrends();
|
||||||
this.refreshInterval = setInterval(() => this.props.fetchTrends(), 36000);
|
this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
@ -35,6 +36,8 @@ export default class Trends extends ImmutablePureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='getting-started__trends'>
|
<div className='getting-started__trends'>
|
||||||
|
<h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
|
||||||
|
|
||||||
{trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
|
{trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -107,7 +107,7 @@ class GettingStarted extends ImmutablePureComponent {
|
|||||||
|
|
||||||
if (profile_directory) {
|
if (profile_directory) {
|
||||||
navItems.push(
|
navItems.push(
|
||||||
<ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} href='/explore' />
|
<ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />
|
||||||
);
|
);
|
||||||
|
|
||||||
height += 48;
|
height += 48;
|
||||||
@ -120,7 +120,7 @@ class GettingStarted extends ImmutablePureComponent {
|
|||||||
height += 34;
|
height += 34;
|
||||||
} else if (profile_directory) {
|
} else if (profile_directory) {
|
||||||
navItems.push(
|
navItems.push(
|
||||||
<ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} href='/explore' />
|
<ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />
|
||||||
);
|
);
|
||||||
|
|
||||||
height += 48;
|
height += 48;
|
||||||
|
@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { columnId }) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
onLoad (value) {
|
onLoad (value) {
|
||||||
return api().get('/api/v2/search', { params: { q: value } }).then(response => {
|
return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
|
||||||
return (response.data.hashtags || []).map((tag) => {
|
return (response.data.hashtags || []).map((tag) => {
|
||||||
return { value: tag.name, label: `#${tag.name}` };
|
return { value: tag.name, label: `#${tag.name}` };
|
||||||
});
|
});
|
||||||
|
@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
|||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Column from '../../components/column';
|
import Column from '../../components/column';
|
||||||
import ColumnHeader from '../../components/column_header';
|
import ColumnHeader from '../../components/column_header';
|
||||||
import { expandNotifications, scrollTopNotifications, loadPending } from '../../actions/notifications';
|
import { expandNotifications, scrollTopNotifications, loadPending, mountNotifications, unmountNotifications } from '../../actions/notifications';
|
||||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||||
import NotificationContainer from './containers/notification_container';
|
import NotificationContainer from './containers/notification_container';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
@ -39,7 +39,7 @@ const mapStateToProps = state => ({
|
|||||||
showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
|
showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
|
||||||
notifications: getNotifications(state),
|
notifications: getNotifications(state),
|
||||||
isLoading: state.getIn(['notifications', 'isLoading'], true),
|
isLoading: state.getIn(['notifications', 'isLoading'], true),
|
||||||
isUnread: state.getIn(['notifications', 'unread']) > 0,
|
isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
|
||||||
hasMore: state.getIn(['notifications', 'hasMore']),
|
hasMore: state.getIn(['notifications', 'hasMore']),
|
||||||
numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
|
numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
|
||||||
});
|
});
|
||||||
@ -66,11 +66,16 @@ class Notifications extends React.PureComponent {
|
|||||||
trackScroll: true,
|
trackScroll: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
componentWillMount() {
|
||||||
|
this.props.dispatch(mountNotifications());
|
||||||
|
}
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
this.handleLoadOlder.cancel();
|
this.handleLoadOlder.cancel();
|
||||||
this.handleScrollToTop.cancel();
|
this.handleScrollToTop.cancel();
|
||||||
this.handleScroll.cancel();
|
this.handleScroll.cancel();
|
||||||
this.props.dispatch(scrollTopNotifications(false));
|
this.props.dispatch(scrollTopNotifications(false));
|
||||||
|
this.props.dispatch(unmountNotifications());
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLoadGap = (maxId) => {
|
handleLoadGap = (maxId) => {
|
||||||
|
@ -10,6 +10,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
|
|||||||
import Card from './card';
|
import Card from './card';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Video from '../../video';
|
import Video from '../../video';
|
||||||
|
import Audio from '../../audio';
|
||||||
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
@ -107,7 +108,19 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status.get('media_attachments').size > 0) {
|
if (status.get('media_attachments').size > 0) {
|
||||||
if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
|
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||||
|
const attachment = status.getIn(['media_attachments', 0]);
|
||||||
|
|
||||||
|
media = (
|
||||||
|
<Audio
|
||||||
|
src={attachment.get('url')}
|
||||||
|
alt={attachment.get('description')}
|
||||||
|
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||||
|
height={110}
|
||||||
|
preload
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
const attachment = status.getIn(['media_attachments', 0]);
|
const attachment = status.getIn(['media_attachments', 0]);
|
||||||
|
|
||||||
media = (
|
media = (
|
||||||
|
@ -84,28 +84,38 @@ const makeMapStateToProps = () => {
|
|||||||
const getDescendantsIds = createSelector([
|
const getDescendantsIds = createSelector([
|
||||||
(_, { id }) => id,
|
(_, { id }) => id,
|
||||||
state => state.getIn(['contexts', 'replies']),
|
state => state.getIn(['contexts', 'replies']),
|
||||||
], (statusId, contextReplies) => {
|
state => state.get('statuses'),
|
||||||
let descendantsIds = Immutable.List();
|
], (statusId, contextReplies, statuses) => {
|
||||||
descendantsIds = descendantsIds.withMutations(mutable => {
|
let descendantsIds = [];
|
||||||
const ids = [statusId];
|
const ids = [statusId];
|
||||||
|
|
||||||
while (ids.length > 0) {
|
while (ids.length > 0) {
|
||||||
let id = ids.shift();
|
let id = ids.shift();
|
||||||
const replies = contextReplies.get(id);
|
const replies = contextReplies.get(id);
|
||||||
|
|
||||||
if (statusId !== id) {
|
if (statusId !== id) {
|
||||||
mutable.push(id);
|
descendantsIds.push(id);
|
||||||
}
|
|
||||||
|
|
||||||
if (replies) {
|
|
||||||
replies.reverse().forEach(reply => {
|
|
||||||
ids.unshift(reply);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return descendantsIds;
|
if (replies) {
|
||||||
|
replies.reverse().forEach(reply => {
|
||||||
|
ids.unshift(reply);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
|
||||||
|
if (insertAt !== -1) {
|
||||||
|
descendantsIds.forEach((id, idx) => {
|
||||||
|
if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
|
||||||
|
descendantsIds.splice(idx, 1);
|
||||||
|
descendantsIds.splice(insertAt, 0, id);
|
||||||
|
insertAt += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Immutable.List(descendantsIds);
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => {
|
const mapStateToProps = (state, props) => {
|
||||||
|
@ -12,7 +12,18 @@ import BundleContainer from '../containers/bundle_container';
|
|||||||
import ColumnLoading from './column_loading';
|
import ColumnLoading from './column_loading';
|
||||||
import DrawerLoading from './drawer_loading';
|
import DrawerLoading from './drawer_loading';
|
||||||
import BundleColumnError from './bundle_column_error';
|
import BundleColumnError from './bundle_column_error';
|
||||||
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from '../../ui/util/async-components';
|
import {
|
||||||
|
Compose,
|
||||||
|
Notifications,
|
||||||
|
HomeTimeline,
|
||||||
|
CommunityTimeline,
|
||||||
|
PublicTimeline,
|
||||||
|
HashtagTimeline,
|
||||||
|
DirectTimeline,
|
||||||
|
FavouritedStatuses,
|
||||||
|
ListTimeline,
|
||||||
|
Directory,
|
||||||
|
} from '../../ui/util/async-components';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
import ComposePanel from './compose_panel';
|
import ComposePanel from './compose_panel';
|
||||||
import NavigationPanel from './navigation_panel';
|
import NavigationPanel from './navigation_panel';
|
||||||
@ -30,6 +41,7 @@ const componentMap = {
|
|||||||
'DIRECT': DirectTimeline,
|
'DIRECT': DirectTimeline,
|
||||||
'FAVOURITES': FavouritedStatuses,
|
'FAVOURITES': FavouritedStatuses,
|
||||||
'LIST': ListTimeline,
|
'LIST': ListTimeline,
|
||||||
|
'DIRECTORY': Directory,
|
||||||
};
|
};
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -10,6 +10,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
|||||||
import IconButton from 'mastodon/components/icon_button';
|
import IconButton from 'mastodon/components/icon_button';
|
||||||
import Button from 'mastodon/components/button';
|
import Button from 'mastodon/components/button';
|
||||||
import Video from 'mastodon/features/video';
|
import Video from 'mastodon/features/video';
|
||||||
|
import Audio from 'mastodon/features/audio';
|
||||||
import Textarea from 'react-textarea-autosize';
|
import Textarea from 'react-textarea-autosize';
|
||||||
import UploadProgress from 'mastodon/features/compose/components/upload_progress';
|
import UploadProgress from 'mastodon/features/compose/components/upload_progress';
|
||||||
import CharacterCounter from 'mastodon/features/compose/components/character_counter';
|
import CharacterCounter from 'mastodon/features/compose/components/character_counter';
|
||||||
@ -222,10 +223,10 @@ class FocalPointModal extends ImmutablePureComponent {
|
|||||||
|
|
||||||
<div className='setting-text__toolbar'>
|
<div className='setting-text__toolbar'>
|
||||||
<button disabled={detecting || media.get('type') !== 'image'} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button>
|
<button disabled={detecting || media.get('type') !== 'image'} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button>
|
||||||
<CharacterCounter max={420} text={detecting ? '' : description} />
|
<CharacterCounter max={1500} text={detecting ? '' : description} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button disabled={!dirty || detecting || length(description) > 420} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
|
<Button disabled={!dirty || detecting || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='focal-point-modal__content'>
|
<div className='focal-point-modal__content'>
|
||||||
@ -244,12 +245,23 @@ class FocalPointModal extends ImmutablePureComponent {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{['audio', 'video'].includes(media.get('type')) && (
|
{media.get('type') === 'video' && (
|
||||||
<Video
|
<Video
|
||||||
preview={media.get('preview_url')}
|
preview={media.get('preview_url')}
|
||||||
blurhash={media.get('blurhash')}
|
blurhash={media.get('blurhash')}
|
||||||
src={media.get('url')}
|
src={media.get('url')}
|
||||||
detailed
|
detailed
|
||||||
|
inline
|
||||||
|
editable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{media.get('type') === 'audio' && (
|
||||||
|
<Audio
|
||||||
|
src={media.get('url')}
|
||||||
|
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
||||||
|
height={150}
|
||||||
|
preload
|
||||||
editable
|
editable
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -1,35 +1,72 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { invitesEnabled, version, repository, source_url } from 'mastodon/initial_state';
|
import { invitesEnabled, version, repository, source_url } from 'mastodon/initial_state';
|
||||||
|
import { logOut } from 'mastodon/utils/log_out';
|
||||||
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
|
|
||||||
const LinkFooter = ({ withHotkeys }) => (
|
const messages = defineMessages({
|
||||||
<div className='getting-started__footer'>
|
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
|
||||||
<ul>
|
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
|
||||||
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
|
});
|
||||||
{withHotkeys && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
|
|
||||||
<li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>
|
|
||||||
<li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>
|
|
||||||
<li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
|
|
||||||
<li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
|
|
||||||
<li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
|
|
||||||
<li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
|
|
||||||
<li><a href='/auth/sign_out' data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>
|
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
<FormattedMessage
|
onLogout () {
|
||||||
id='getting_started.open_source_notice'
|
dispatch(openModal('CONFIRM', {
|
||||||
defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.'
|
message: intl.formatMessage(messages.logoutMessage),
|
||||||
values={{ github: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }}
|
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||||
/>
|
onConfirm: () => logOut(),
|
||||||
</p>
|
}));
|
||||||
</div>
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
|
export default @injectIntl
|
||||||
|
@connect(null, mapDispatchToProps)
|
||||||
|
class LinkFooter extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
withHotkeys: PropTypes.bool,
|
||||||
|
onLogout: PropTypes.func.isRequired,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleLogoutClick = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
this.props.onLogout();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { withHotkeys } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='getting-started__footer'>
|
||||||
|
<ul>
|
||||||
|
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
|
||||||
|
{withHotkeys && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
|
||||||
|
<li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>
|
||||||
|
<li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>
|
||||||
|
<li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
|
||||||
|
<li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
|
||||||
|
<li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
|
||||||
|
<li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
|
||||||
|
<li><a href='/auth/sign_out' onClick={this.handleLogoutClick}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
id='getting_started.open_source_notice'
|
||||||
|
defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.'
|
||||||
|
values={{ github: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
LinkFooter.propTypes = {
|
|
||||||
withHotkeys: PropTypes.bool,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LinkFooter;
|
|
||||||
|
@ -18,6 +18,7 @@ const NavigationPanel = () => (
|
|||||||
<NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
|
<NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
|
||||||
<NavLink className='column-link column-link--transparent' to='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></NavLink>
|
<NavLink className='column-link column-link--transparent' to='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></NavLink>
|
||||||
<NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
|
<NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
|
||||||
|
{profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.directory' defaultMessage='Profile directory' /></NavLink>}
|
||||||
|
|
||||||
<ListPanel />
|
<ListPanel />
|
||||||
|
|
||||||
@ -25,7 +26,6 @@ const NavigationPanel = () => (
|
|||||||
|
|
||||||
<a className='column-link column-link--transparent' href='/settings/preferences'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>
|
<a className='column-link column-link--transparent' href='/settings/preferences'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>
|
||||||
<a className='column-link column-link--transparent' href='/relationships'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>
|
<a className='column-link column-link--transparent' href='/relationships'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>
|
||||||
{!!profile_directory && <a className='column-link column-link--transparent' href='/explore'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='navigation_bar.profile_directory' defaultMessage='Profile directory' /></a>}
|
|
||||||
|
|
||||||
{showTrends && <div className='flex-spacer' />}
|
{showTrends && <div className='flex-spacer' />}
|
||||||
{showTrends && <TrendsContainer />}
|
{showTrends && <TrendsContainer />}
|
||||||
|
@ -11,7 +11,7 @@ const mapStateToProps = (state, { intl }) => {
|
|||||||
const value = notification[key];
|
const value = notification[key];
|
||||||
|
|
||||||
if (typeof value === 'object') {
|
if (typeof value === 'object') {
|
||||||
notification[key] = intl.formatMessage(value);
|
notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ import { expandNotifications } from '../../actions/notifications';
|
|||||||
import { fetchFilters } from '../../actions/filters';
|
import { fetchFilters } from '../../actions/filters';
|
||||||
import { clearHeight } from '../../actions/height_cache';
|
import { clearHeight } from '../../actions/height_cache';
|
||||||
import { focusApp, unfocusApp } from 'mastodon/actions/app';
|
import { focusApp, unfocusApp } from 'mastodon/actions/app';
|
||||||
|
import { submitMarkers } from 'mastodon/actions/markers';
|
||||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
||||||
import UploadArea from './components/upload_area';
|
import UploadArea from './components/upload_area';
|
||||||
import ColumnsAreaContainer from './containers/columns_area_container';
|
import ColumnsAreaContainer from './containers/columns_area_container';
|
||||||
@ -47,6 +48,7 @@ import {
|
|||||||
PinnedStatuses,
|
PinnedStatuses,
|
||||||
Lists,
|
Lists,
|
||||||
Search,
|
Search,
|
||||||
|
Directory,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
import { me, forceSingleColumn } from '../../initial_state';
|
import { me, forceSingleColumn } from '../../initial_state';
|
||||||
import { previewState as previewMediaState } from './components/media_modal';
|
import { previewState as previewMediaState } from './components/media_modal';
|
||||||
@ -64,6 +66,7 @@ const mapStateToProps = state => ({
|
|||||||
isComposing: state.getIn(['compose', 'is_composing']),
|
isComposing: state.getIn(['compose', 'is_composing']),
|
||||||
hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0,
|
hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0,
|
||||||
hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
|
hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
|
||||||
|
canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
|
||||||
dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
|
dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -141,14 +144,24 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||||||
return location.state !== previewMediaState && location.state !== previewVideoState;
|
return location.state !== previewMediaState && location.state !== previewVideoState;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleResize = debounce(() => {
|
handleLayoutChange = debounce(() => {
|
||||||
// The cached heights are no longer accurate, invalidate
|
// The cached heights are no longer accurate, invalidate
|
||||||
this.props.onLayoutChange();
|
this.props.onLayoutChange();
|
||||||
|
|
||||||
this.setState({ mobile: isMobile(window.innerWidth) });
|
|
||||||
}, 500, {
|
}, 500, {
|
||||||
trailing: true,
|
trailing: true,
|
||||||
});
|
})
|
||||||
|
|
||||||
|
handleResize = () => {
|
||||||
|
const mobile = isMobile(window.innerWidth);
|
||||||
|
|
||||||
|
if (mobile !== this.state.mobile) {
|
||||||
|
this.handleLayoutChange.cancel();
|
||||||
|
this.props.onLayoutChange();
|
||||||
|
this.setState({ mobile });
|
||||||
|
} else {
|
||||||
|
this.handleLayoutChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setRef = c => {
|
setRef = c => {
|
||||||
this.node = c.getWrappedInstance();
|
this.node = c.getWrappedInstance();
|
||||||
@ -178,6 +191,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||||||
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
|
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
|
||||||
|
|
||||||
<WrappedRoute path='/search' component={Search} content={children} />
|
<WrappedRoute path='/search' component={Search} content={children} />
|
||||||
|
<WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
|
||||||
|
|
||||||
<WrappedRoute path='/statuses/new' component={Compose} content={children} />
|
<WrappedRoute path='/statuses/new' component={Compose} content={children} />
|
||||||
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
|
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
|
||||||
@ -219,6 +233,7 @@ class UI extends React.PureComponent {
|
|||||||
isComposing: PropTypes.bool,
|
isComposing: PropTypes.bool,
|
||||||
hasComposingText: PropTypes.bool,
|
hasComposingText: PropTypes.bool,
|
||||||
hasMediaAttachments: PropTypes.bool,
|
hasMediaAttachments: PropTypes.bool,
|
||||||
|
canUploadMore: PropTypes.bool,
|
||||||
location: PropTypes.object,
|
location: PropTypes.object,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
dropdownMenuIsOpen: PropTypes.bool,
|
dropdownMenuIsOpen: PropTypes.bool,
|
||||||
@ -229,7 +244,9 @@ class UI extends React.PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleBeforeUnload = e => {
|
handleBeforeUnload = e => {
|
||||||
const { intl, isComposing, hasComposingText, hasMediaAttachments } = this.props;
|
const { intl, dispatch, isComposing, hasComposingText, hasMediaAttachments } = this.props;
|
||||||
|
|
||||||
|
dispatch(submitMarkers());
|
||||||
|
|
||||||
if (isComposing && (hasComposingText || hasMediaAttachments)) {
|
if (isComposing && (hasComposingText || hasMediaAttachments)) {
|
||||||
// Setting returnValue to any string causes confirmation dialog.
|
// Setting returnValue to any string causes confirmation dialog.
|
||||||
@ -263,13 +280,14 @@ class UI extends React.PureComponent {
|
|||||||
this.dragTargets.push(e.target);
|
this.dragTargets.push(e.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')) {
|
if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files') && this.props.canUploadMore) {
|
||||||
this.setState({ draggingOver: true });
|
this.setState({ draggingOver: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDragOver = (e) => {
|
handleDragOver = (e) => {
|
||||||
if (this.dataTransferIsText(e.dataTransfer)) return false;
|
if (this.dataTransferIsText(e.dataTransfer)) return false;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
@ -284,12 +302,13 @@ class UI extends React.PureComponent {
|
|||||||
|
|
||||||
handleDrop = (e) => {
|
handleDrop = (e) => {
|
||||||
if (this.dataTransferIsText(e.dataTransfer)) return;
|
if (this.dataTransferIsText(e.dataTransfer)) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
this.setState({ draggingOver: false });
|
this.setState({ draggingOver: false });
|
||||||
this.dragTargets = [];
|
this.dragTargets = [];
|
||||||
|
|
||||||
if (e.dataTransfer && e.dataTransfer.files.length >= 1) {
|
if (e.dataTransfer && e.dataTransfer.files.length >= 1 && this.props.canUploadMore) {
|
||||||
this.props.dispatch(uploadCompose(e.dataTransfer.files));
|
this.props.dispatch(uploadCompose(e.dataTransfer.files));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -137,3 +137,11 @@ export function Search () {
|
|||||||
export function Tesseract () {
|
export function Tesseract () {
|
||||||
return import(/*webpackChunkName: "tesseract" */'tesseract.js');
|
return import(/*webpackChunkName: "tesseract" */'tesseract.js');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Audio () {
|
||||||
|
return import(/* webpackChunkName: "features/audio" */'../../audio');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Directory () {
|
||||||
|
return import(/* webpackChunkName: "features/directory" */'../../directory');
|
||||||
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user