This commit is contained in:
tykayn 2019-09-22 10:40:03 +02:00
commit e01b405c17
539 changed files with 17518 additions and 4109 deletions

View File

@ -3,7 +3,7 @@ version: 2
aliases:
- &defaults
docker:
- image: circleci/ruby:2.6.0-stretch-node
- image: circleci/ruby:2.6-stretch-node
environment: &ruby_environment
BUNDLE_APP_CONFIG: ./.bundle/
DB_HOST: localhost
@ -105,14 +105,14 @@ jobs:
install-ruby2.5:
<<: *defaults
docker:
- image: circleci/ruby:2.5.3-stretch-node
- image: circleci/ruby:2.5-stretch-node
environment: *ruby_environment
<<: *install_ruby_dependencies
install-ruby2.4:
<<: *defaults
docker:
- image: circleci/ruby:2.4.5-stretch-node
- image: circleci/ruby:2.4-stretch-node
environment: *ruby_environment
<<: *install_ruby_dependencies
@ -131,40 +131,40 @@ jobs:
test-ruby2.6:
<<: *defaults
docker:
- image: circleci/ruby:2.6.0-stretch-node
- image: circleci/ruby:2.6-stretch-node
environment: *ruby_environment
- image: circleci/postgres:10.6-alpine
environment:
POSTGRES_USER: root
- image: circleci/redis:5.0.3-alpine3.8
- image: circleci/redis:5-alpine
<<: *test_steps
test-ruby2.5:
<<: *defaults
docker:
- image: circleci/ruby:2.5.3-stretch-node
- image: circleci/ruby:2.5-stretch-node
environment: *ruby_environment
- image: circleci/postgres:10.6-alpine
environment:
POSTGRES_USER: root
- image: circleci/redis:4.0.12-alpine
- image: circleci/redis:5-alpine
<<: *test_steps
test-ruby2.4:
<<: *defaults
docker:
- image: circleci/ruby:2.4.5-stretch-node
- image: circleci/ruby:2.4-stretch-node
environment: *ruby_environment
- image: circleci/postgres:10.6-alpine
environment:
POSTGRES_USER: root
- image: circleci/redis:4.0.12-alpine
- image: circleci/redis:5-alpine
<<: *test_steps
test-webui:
<<: *defaults
docker:
- image: circleci/node:8.15.0-stretch
- image: circleci/node:12.9-stretch
steps:
- *attach_workspace
- run: ./bin/retry yarn test:jest
@ -173,9 +173,11 @@ jobs:
<<: *defaults
steps:
- *attach_workspace
- *install_system_dependencies
- run: bundle exec i18n-tasks check-normalized
- run: bundle exec i18n-tasks unused -l en
- run: bundle exec i18n-tasks check-consistent-interpolations
- run: bundle exec rake repo:check_locales_files
workflows:
version: 2

View File

@ -69,6 +69,7 @@ SMTP_PORT=587
SMTP_LOGIN=
SMTP_PASSWORD=
SMTP_FROM_ADDRESS=notifications@example.com
#SMTP_REPLY_TO=
#SMTP_DOMAIN= # defaults to LOCAL_DOMAIN
#SMTP_DELIVERY_METHOD=smtp # delivery method can also be sendmail
#SMTP_AUTH_METHOD=plain

View File

@ -3,6 +3,69 @@ Changelog
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
### Added

View File

@ -4,22 +4,20 @@ FROM ubuntu:18.04 as build-dep
SHELL ["bash", "-c"]
# Install Node
ENV NODE_VER="8.15.0"
ENV NODE_VER="12.9.1"
RUN echo "Etc/UTC" > /etc/localtime && \
apt update && \
apt -y install wget make gcc g++ python && \
apt -y install wget python && \
cd ~ && \
wget https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER.tar.gz && \
tar xf node-v$NODE_VER.tar.gz && \
cd node-v$NODE_VER && \
./configure --prefix=/opt/node && \
make -j$(nproc) > /dev/null && \
make install
wget https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER-linux-x64.tar.gz && \
tar xf node-v$NODE_VER-linux-x64.tar.gz && \
rm node-v$NODE_VER-linux-x64.tar.gz && \
mv node-v$NODE_VER-linux-x64 /opt/node
# Install jemalloc
ENV JE_VER="5.1.0"
ENV JE_VER="5.2.1"
RUN apt update && \
apt -y install autoconf && \
apt -y install make autoconf gcc g++ && \
cd ~ && \
wget https://github.com/jemalloc/jemalloc/archive/$JE_VER.tar.gz && \
tar xf $JE_VER.tar.gz && \
@ -30,7 +28,7 @@ RUN apt update && \
make install_bin install_include install_lib
# Install ruby
ENV RUBY_VER="2.6.1"
ENV RUBY_VER="2.6.4"
ENV CPPFLAGS="-I/opt/jemalloc/include"
ENV LDFLAGS="-L/opt/jemalloc/lib/"
RUN apt update && \

33
Gemfile
View File

@ -12,10 +12,10 @@ gem 'thor', '~> 0.20'
gem 'hamlit-rails', '~> 0.2'
gem 'pg', '~> 1.1'
gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.2'
gem 'pghero', '~> 2.3'
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-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0'
@ -24,14 +24,14 @@ gem 'streamio-ffmpeg', '~> 3.0'
gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.6'
gem 'addressable', '~> 2.7'
gem 'bootsnap', '~> 1.4', require: false
gem 'browser'
gem 'charlock_holmes', '~> 0.7.6'
gem 'iso-639'
gem 'chewy', '~> 5.0'
gem 'cld3', '~> 3.2.4'
gem 'devise', '~> 4.6'
gem 'devise', '~> 4.7'
gem 'devise-two-factor', '~> 3.1'
group :pam_authentication, optional: true do
@ -43,12 +43,14 @@ gem 'omniauth-cas', '~> 1.1'
gem 'omniauth-saml', '~> 1.10'
gem 'omniauth', '~> 1.9'
gem 'doorkeeper', '~> 5.1'
gem 'discard', '~> 1.1'
gem 'doorkeeper', '~> 5.2'
gem 'fast_blank', '~> 1.0'
gem 'fastimage'
gem 'goldfinger', '~> 2.1'
gem 'hiredis', '~> 0.6'
gem 'redis-namespace', '~> 1.5'
gem 'health_check', '~> 3.0'
gem 'htmlentities', '~> 4.3'
gem 'http', '~> 3.3'
gem 'http_accept_language', '~> 2.1'
@ -57,16 +59,16 @@ gem 'httplog', '~> 1.3'
gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.1'
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 'nokogiri', '~> 1.10'
gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.8'
gem 'oj', '~> 3.9'
gem 'ostatus2', '~> 2.0'
gem 'ox', '~> 2.11'
gem 'parslet'
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
gem 'pundit', '~> 2.0'
gem 'pundit', '~> 2.1'
gem 'premailer-rails'
gem 'rack-attack', '~> 6.1'
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 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'rqrcode', '~> 0.10'
gem 'sanitize', '~> 5.0'
gem 'ruby-progressbar', '~> 1.10'
gem 'sanitize', '~> 5.1'
gem 'sidekiq', '~> 5.2'
gem 'sidekiq-scheduler', '~> 3.0'
gem 'sidekiq-unique-jobs', '~> 6.0'
gem 'sidekiq-bulk', '~>0.2.0'
gem 'simple-navigation', '~> 4.0'
gem 'simple-navigation', '~> 4.1'
gem 'simple_form', '~> 4.1'
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
gem 'stoplight', '~> 2.1.3'
@ -92,7 +95,7 @@ gem 'tzinfo-data', '~> 1.2019'
gem 'webpacker', '~> 4.0'
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 'rdf-normalize', '~> 0.3'
@ -110,14 +113,14 @@ group :production, :test do
end
group :test do
gem 'capybara', '~> 3.28'
gem 'capybara', '~> 3.29'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.1'
gem 'faker', '~> 2.3'
gem 'microformats', '~> 4.1'
gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.0'
gem 'simplecov', '~> 0.17', require: false
gem 'webmock', '~> 3.6'
gem 'webmock', '~> 3.7'
gem 'parallel_tests', '~> 2.29'
end
@ -131,7 +134,7 @@ group :development do
gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler'
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 'bundler-audit', '~> 0.6', require: false

View File

@ -7,8 +7,8 @@ GIT
GIT
remote: https://github.com/ruby-rdf/json-ld.git
revision: 345b7a5733308af827e8491d284dbafa9128d7a2
ref: 345b7a5733308af827e8491d284dbafa9128d7a2
revision: e742697a0906e74e8bb777ef98137bc3955d981d
ref: e742697a0906e74e8bb777ef98137bc3955d981d
specs:
json-ld (3.0.2)
htmlentities (~> 4.3)
@ -83,9 +83,9 @@ GEM
i18n (>= 0.7, < 2)
minitest (~> 5.1)
tzinfo (~> 1.1)
addressable (2.6.0)
public_suffix (>= 2.0.2, < 4.0)
airbrussh (1.3.0)
addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0)
airbrussh (1.3.3)
sshkit (>= 1.6.1, != 1.7.0)
annotate (2.7.5)
activerecord (>= 3.2, < 7.0)
@ -97,8 +97,8 @@ GEM
av (0.9.0)
cocaine (~> 0.5.3)
aws-eventstream (1.0.3)
aws-partitions (1.193.0)
aws-sdk-core (3.61.1)
aws-partitions (1.207.0)
aws-sdk-core (3.65.1)
aws-eventstream (~> 1.0, >= 1.0.2)
aws-partitions (~> 1.0)
aws-sigv4 (~> 1.1)
@ -106,7 +106,7 @@ GEM
aws-sdk-kms (1.24.0)
aws-sdk-core (~> 3, >= 3.61.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-kms (~> 1)
aws-sigv4 (~> 1.1)
@ -122,19 +122,19 @@ GEM
debug_inspector (>= 0.0.1)
blurhash (0.1.3)
ffi (~> 1.10.0)
bootsnap (1.4.4)
bootsnap (1.4.5)
msgpack (~> 1.0)
brakeman (4.6.1)
browser (2.6.1)
builder (3.2.3)
bullet (6.0.1)
bullet (6.0.2)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundler-audit (0.6.1)
bundler (>= 1.2.0, < 3)
thor (~> 0.18)
byebug (11.0.0)
capistrano (3.11.0)
capistrano (3.11.1)
airbrussh (>= 1.0.0)
i18n
rake (>= 10.0.0)
@ -150,7 +150,7 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (3.28.0)
capybara (3.29.0)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
@ -188,10 +188,10 @@ GEM
rack (>= 1)
rake (> 10, < 13)
thor (~> 0.19)
devise (4.6.2)
devise (4.7.1)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0, < 6.0)
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-two-factor (3.1.0)
@ -204,10 +204,12 @@ GEM
devise (>= 4.0.0)
rpam2 (~> 4.0)
diff-lcs (1.3)
discard (1.1.0)
activerecord (>= 4.2, < 7)
docile (1.3.2)
domain_name (0.5.20180417)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.1.0)
doorkeeper (5.2.0)
railties (>= 5)
dotenv (2.7.5)
dotenv-rails (2.7.5)
@ -229,12 +231,12 @@ GEM
tzinfo
excon (0.62.0)
fabrication (2.20.2)
faker (2.1.2)
i18n (>= 0.8)
faker (2.3.0)
i18n (~> 1.6.0)
faraday (0.15.0)
multipart-post (>= 1.2, < 3)
fast_blank (1.0.0)
fastimage (2.1.5)
fastimage (2.1.7)
ffi (1.10.0)
fog-core (2.1.0)
builder
@ -276,6 +278,8 @@ GEM
concurrent-ruby (~> 1.0)
hashdiff (1.0.0)
hashie (3.6.0)
health_check (3.0.0)
railties (>= 5.0)
heapy (0.1.4)
highline (2.0.1)
hiredis (0.6.3)
@ -312,7 +316,7 @@ GEM
jmespath (1.4.0)
json (2.2.0)
json-canonicalization (0.1.0)
json-ld-preloaded (3.0.3)
json-ld-preloaded (3.0.4)
json-ld (~> 3.0)
multi_json (~> 1.12)
rdf (~> 3.0)
@ -360,32 +364,32 @@ GEM
microformats (4.1.0)
json (~> 2.1)
nokogiri (~> 1.8, >= 1.8.3)
mime-types (3.2.2)
mime-types (3.3)
mime-types-data (~> 3.2015)
mime-types-data (3.2018.0812)
mime-types-data (3.2019.0904)
mimemagic (0.3.3)
mini_mime (1.0.2)
mini_portile2 (2.4.0)
minitest (5.11.3)
msgpack (1.2.10)
msgpack (1.3.1)
multi_json (1.13.1)
multipart-post (2.0.0)
necromancer (0.5.0)
net-ldap (0.16.1)
net-scp (1.2.1)
net-ssh (>= 2.6.5)
net-ssh (5.0.2)
nio4r (2.4.0)
net-scp (2.0.0)
net-ssh (>= 2.6.5, < 6.0.0)
net-ssh (5.2.0)
nio4r (2.5.1)
nokogiri (1.10.4)
mini_portile2 (~> 2.4.0)
nokogumbo (2.0.0)
nokogumbo (2.0.1)
nokogiri (~> 1.8, >= 1.8.4)
nsa (0.2.7)
activesupport (>= 4.2, < 6)
concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.8.1)
oj (3.9.1)
omniauth (1.9.0)
hashie (>= 3.4.6, < 3.7.0)
rack (>= 1.6.2, < 3)
@ -414,16 +418,16 @@ GEM
parallel (1.17.0)
parallel_tests (2.29.2)
parallel
parser (2.6.3.0)
parser (2.6.4.0)
ast (~> 2.4.0)
parslet (1.8.2)
pastel (0.7.2)
equatable (~> 0.5.0)
tty-color (~> 0.4.0)
pg (1.1.4)
pghero (2.2.1)
activerecord
pkg-config (1.3.7)
pghero (2.3.0)
activerecord (>= 5)
pkg-config (1.3.8)
premailer (1.11.1)
addressable
css_parser (>= 1.6.0)
@ -440,10 +444,10 @@ GEM
pry (~> 0.10)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (3.1.1)
puma (4.1.0)
public_suffix (4.0.1)
puma (4.1.1)
nio4r (~> 2.0)
pundit (2.0.1)
pundit (2.1.0)
activesupport (>= 3.0.0)
raabro (1.1.6)
rack (2.0.7)
@ -552,7 +556,7 @@ GEM
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 1.7)
rubocop-rails (2.2.1)
rubocop-rails (2.3.2)
rack (>= 1.1)
rubocop (>= 0.72.0)
ruby-progressbar (1.10.1)
@ -561,7 +565,7 @@ GEM
rufus-scheduler (3.5.2)
fugit (~> 1.1, >= 1.1.5)
safe_yaml (1.0.5)
sanitize (5.0.0)
sanitize (5.1.0)
crass (~> 1.0.2)
nokogiri (>= 1.8.0)
nokogumbo (~> 2.0)
@ -581,7 +585,7 @@ GEM
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 4.0, < 7.0)
thor (~> 0)
simple-navigation (4.0.5)
simple-navigation (4.1.0)
activesupport (>= 2.3.2)
simple_form (4.1.0)
actionpack (>= 5.0)
@ -598,7 +602,7 @@ GEM
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets (>= 3.0.0)
sshkit (1.17.0)
sshkit (1.20.0)
net-scp (>= 1.1.2)
net-ssh (>= 2.8.0)
stackprof (0.2.12)
@ -633,7 +637,7 @@ GEM
unf (~> 0.1.0)
tzinfo (1.2.5)
thread_safe (~> 0.1)
tzinfo-data (1.2019.2)
tzinfo-data (1.2019.3)
tzinfo (>= 1.0.0)
unf (0.1.4)
unf_ext
@ -642,7 +646,7 @@ GEM
uniform_notifier (1.12.1)
warden (1.2.8)
rack (>= 2.0.6)
webmock (3.6.2)
webmock (3.7.3)
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@ -666,9 +670,9 @@ PLATFORMS
DEPENDENCIES
active_model_serializers (~> 0.10)
active_record_query_trace (~> 1.6)
addressable (~> 2.6)
addressable (~> 2.7)
annotate (~> 2.7)
aws-sdk-s3 (~> 1.46)
aws-sdk-s3 (~> 1.48)
better_errors (~> 2.5)
binding_of_caller (~> 0.7)
blurhash (~> 0.1)
@ -681,7 +685,7 @@ DEPENDENCIES
capistrano-rails (~> 1.4)
capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0)
capybara (~> 3.28)
capybara (~> 3.29)
charlock_holmes (~> 0.7.6)
chewy (~> 5.0)
cld3 (~> 3.2.4)
@ -689,13 +693,14 @@ DEPENDENCIES
concurrent-ruby
connection_pool
derailed_benchmarks
devise (~> 4.6)
devise (~> 4.7)
devise-two-factor (~> 3.1)
devise_pam_authenticatable2 (~> 9.2)
doorkeeper (~> 5.1)
discard (~> 1.1)
doorkeeper (~> 5.2)
dotenv-rails (~> 2.7)
fabrication (~> 2.20)
faker (~> 2.1)
faker (~> 2.3)
fast_blank (~> 1.0)
fastimage
fog-core (<= 2.1.0)
@ -703,6 +708,7 @@ DEPENDENCIES
fuubar (~> 2.4)
goldfinger (~> 2.1)
hamlit-rails (~> 0.2)
health_check (~> 3.0)
hiredis (~> 0.6)
htmlentities (~> 4.3)
http (~> 3.3)
@ -723,12 +729,12 @@ DEPENDENCIES
mario-redis-lock (~> 1.2)
memory_profiler
microformats (~> 4.1)
mime-types (~> 3.2)
mime-types (~> 3.3)
net-ldap (~> 0.10)
nilsimsa!
nokogiri (~> 1.10)
nsa (~> 0.2)
oj (~> 3.8)
oj (~> 3.9)
omniauth (~> 1.9)
omniauth-cas (~> 1.1)
omniauth-saml (~> 1.10)
@ -739,7 +745,7 @@ DEPENDENCIES
parallel_tests (~> 2.29)
parslet
pg (~> 1.1)
pghero (~> 2.2)
pghero (~> 2.3)
pkg-config (~> 1.3)
posix-spawn!
premailer-rails
@ -747,7 +753,7 @@ DEPENDENCIES
pry-byebug (~> 3.7)
pry-rails (~> 0.3)
puma (~> 4.1)
pundit (~> 2.0)
pundit (~> 2.1)
rack-attack (~> 6.1)
rack-cors (~> 1.0)
rails (~> 5.2.3)
@ -762,13 +768,14 @@ DEPENDENCIES
rspec-rails (~> 3.8)
rspec-sidekiq (~> 3.0)
rubocop (~> 0.74)
rubocop-rails (~> 2.2)
sanitize (~> 5.0)
rubocop-rails (~> 2.3)
ruby-progressbar (~> 1.10)
sanitize (~> 5.1)
sidekiq (~> 5.2)
sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 3.0)
sidekiq-unique-jobs (~> 6.0)
simple-navigation (~> 4.0)
simple-navigation (~> 4.1)
simple_form (~> 4.1)
simplecov (~> 0.17)
sprockets-rails (~> 3.2)
@ -781,7 +788,7 @@ DEPENDENCIES
tty-prompt (~> 0.19)
twitter-text (~> 1.14)
tzinfo-data (~> 1.2019)
webmock (~> 3.6)
webmock (~> 3.7)
webpacker (~> 4.0)
webpush

37
app/chewy/tags_index.rb Normal file
View 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

View File

@ -6,7 +6,7 @@ class AboutController < ApplicationController
before_action :require_open_federation!, only: [:show, :more]
before_action :set_body_classes, only: :show
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]
@ -14,16 +14,35 @@ class AboutController < ApplicationController
def more
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
def terms; end
helper_method :display_blocks?
helper_method :display_blocks_rationale?
helper_method :public_fetch_mode?
helper_method :new_user
private
def require_open_federation!
not_found if whitelist_mode?
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
User.new.tap do |user|
user.build_account
@ -31,8 +50,6 @@ class AboutController < ApplicationController
end
end
helper_method :new_user
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end

View File

@ -18,6 +18,7 @@ class AccountsController < ApplicationController
@pinned_statuses = []
@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)
@statuses = []
@ -27,6 +28,7 @@ class AccountsController < ApplicationController
@pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses?
@statuses = filtered_status_page(params)
@statuses = cache_collection(@statuses, Status)
@rss_url = rss_url
unless @statuses.empty?
@older_url = older_url if @statuses.last.id > filtered_statuses.last.id
@ -37,8 +39,9 @@ class AccountsController < ApplicationController
format.rss do
expires_in 0, public: true
@statuses = cache_collection(default_statuses.without_reblogs.without_replies.limit(PAGE_SIZE), Status)
render xml: RSS::AccountSerializer.render(@account, @statuses)
@statuses = filtered_statuses.without_reblogs.without_replies.limit(PAGE_SIZE)
@statuses = cache_collection(@statuses, Status)
render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
end
format.json do
@ -96,6 +99,14 @@ class AccountsController < ApplicationController
params[:username]
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
pagination_url(max_id: @statuses.last.id)
end
@ -125,7 +136,7 @@ class AccountsController < ApplicationController
end
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
def filtered_status_page(params)

View File

@ -5,7 +5,7 @@ module Admin
before_action :set_account
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
end
@ -30,7 +30,7 @@ module Admin
end
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

View File

@ -41,7 +41,7 @@ module Admin
def 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
end

View File

@ -2,19 +2,20 @@
module Admin
class CustomEmojisController < BaseController
before_action :set_custom_emoji, except: [:index, :new, :create]
before_action :set_filter_params
include ObfuscateFilename
obfuscate_filename [:custom_emoji, :image]
def index
authorize :custom_emoji, :index?
@custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page])
@form = Form::CustomEmojiBatch.new
end
def new
authorize :custom_emoji, :create?
@custom_emoji = CustomEmoji.new
end
@ -31,69 +32,17 @@ module Admin
end
end
def update
authorize @custom_emoji, :update?
if @custom_emoji.update(resource_params)
log_action :update, @custom_emoji
flash[:notice] = I18n.t('admin.custom_emojis.updated_msg')
else
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)
def batch
@form = Form::CustomEmojiBatch.new(form_custom_emoji_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_custom_emojis_path(filter_params)
end
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
params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker)
end
@ -103,12 +52,29 @@ module Admin
end
def filter_params
params.permit(
:local,
:remote,
:by_domain,
:shortcode
)
params.slice(:local, :remote, :by_domain, :shortcode, :page).permit(:local, :remote, :by_domain, :shortcode, :page)
end
def action_from_button
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

View File

@ -5,10 +5,10 @@ module Admin
before_action :set_report_note, only: [:destroy]
def create
authorize ReportNote, :create?
authorize :report_note, :create?
@report_note = current_account.report_notes.new(resource_params)
@report = @report_note.report
@report = @report_note.report
if @report_note.save
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')
else
@report_notes = @report.notes.latest
@report_history = @report.history
@form = Form::StatusBatch.new
@report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at)
@form = Form::StatusBatch.new
render template: 'admin/reports/show'
end

View File

@ -2,13 +2,34 @@
module Admin
class TagsController < BaseController
before_action :set_tags, only: :index
before_action :set_tag, except: :index
before_action :set_usage_by_domain, except: :index
before_action :set_counters, except: :index
before_action :set_tag, except: [:index, :batch, :approve_all, :reject_all]
before_action :set_usage_by_domain, except: [:index, :batch, :approve_all, :reject_all]
before_action :set_counters, except: [:index, :batch, :approve_all, :reject_all]
def 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
def show
@ -27,17 +48,14 @@ module Admin
private
def set_tags
@tags = filtered_tags.page(params[:page])
end
def set_tag
@tag = Tag.find(params[:id])
end
def set_usage_by_domain
@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)))
.joins(:account)
.group('accounts.domain')
@ -51,16 +69,11 @@ module Admin
end
def filtered_tags
scope = Tag
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)
TagFilter.new(filter_params).results
end
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
def tag_params
@ -74,5 +87,17 @@ module Admin
date.to_time(:utc).beginning_of_day.to_i
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

View File

@ -8,6 +8,7 @@ module Admin
authorize @user, :disable_2fa?
@user.disable_two_factor!
log_action :disable_2fa, @user
UserMailer.two_factor_disabled(@user).deliver_later!
redirect_to admin_accounts_path
end

View File

@ -36,6 +36,14 @@ class Api::BaseController < ApplicationController
render json: { error: 'This action is not allowed' }, status: 403
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)
{ json: { error: (error.try(:description) || 'Not authorized') } }
end

View File

@ -29,14 +29,13 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
def 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!(no_replies_scope) if truthy_param?(:exclude_replies)
statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
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
def permitted_account_statuses

View File

@ -58,7 +58,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController
def 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
end

View File

@ -7,6 +7,6 @@ class Api::V1::CustomEmojisController < Api::BaseController
def index
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

View 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

View File

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

View 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

View File

@ -14,12 +14,12 @@ class Api::V1::FollowRequestsController < Api::BaseController
def authorize
AuthorizeFollowService.new.call(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
def reject
RejectFollowService.new.call(account, current_account)
render_empty
render json: account, serializer: REST::RelationshipSerializer, relationships: relationships
end
private
@ -28,6 +28,10 @@ class Api::V1::FollowRequestsController < Api::BaseController
Account.find(params[:id])
end
def relationships(**options)
AccountRelationshipsPresenter.new([params[:id]], current_user.account_id, options)
end
def load_accounts
default_accounts.merge(paginated_follow_requests).to_a
end

View 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

View File

@ -21,7 +21,7 @@ class Api::V1::ReportsController < Api::BaseController
private
def reported_status_ids
reported_account.statuses.find(status_ids).pluck(:id)
reported_account.statuses.with_discarded.find(status_ids).pluck(:id)
end
def status_ids

View File

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

View File

@ -18,6 +18,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
@reblogs_map = { @status.id => false }
authorize status_for_destroy, :unreblog?
status_for_destroy.discard
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)
@ -30,7 +31,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
end
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
def reblog_params

View File

@ -53,7 +53,8 @@ class Api::V1::StatusesController < Api::BaseController
@status = Status.where(account_id: current_user.account).find(params[:id])
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
end

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true
class Api::V1::Timelines::PublicController < Api::BaseController
before_action :require_user!, only: [:show], if: :require_auth?
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
respond_to :json
@ -12,6 +13,10 @@ class Api::V1::Timelines::PublicController < Api::BaseController
private
def require_auth?
!Setting.timeline_preview
end
def load_statuses
cached_public_statuses
end

View File

@ -1,8 +1,32 @@
# 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
@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

View File

@ -21,10 +21,13 @@ class ApplicationController < ActionController::Base
helper_method :whitelist_mode?
rescue_from ActionController::RoutingError, with: :not_found
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
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 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 :require_functional!, if: :user_signed_in?
@ -38,7 +41,7 @@ class ApplicationController < ActionController::Base
private
def https_enabled?
Rails.env.production?
Rails.env.production? && !request.path.start_with?('/health')
end
def authorized_fetch_mode?
@ -95,6 +98,18 @@ class ApplicationController < ActionController::Base
respond_with_error(406)
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?
@single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists?
end

View 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

View File

@ -4,15 +4,38 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
layout 'auth'
before_action :set_body_classes
before_action :require_unconfirmed!
skip_before_action :require_functional!
def new
super
resource.email = current_user.unconfirmed_email || current_user.email if user_signed_in?
end
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
@body_classes = 'lighter'
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)
if user.created_by_application && truthy_param?(:redirect_to_app)
user.created_by_application.redirect_uri

View File

@ -8,8 +8,6 @@ class Auth::SessionsController < Devise::SessionsController
skip_before_action :require_no_authentication, only: [:create]
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_body_classes
@ -22,33 +20,35 @@ class Auth::SessionsController < Devise::SessionsController
end
def create
super do |resource|
remember_me(resource)
flash.delete(:notice)
self.resource = begin
if user_params[:email].blank? && session[:otp_user_id].present?
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
def destroy
tmp_stored_location = stored_location_for(:user)
super
session.delete(:challenge_passed_at)
flash.delete(:notice)
store_location_for(:user, tmp_stored_location) if continue_after?
end
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
params.require(:user).permit(:email, :password, :otp_attempt)
end
@ -71,32 +71,17 @@ class Auth::SessionsController < Devise::SessionsController
super
end
def two_factor_enabled?
find_user.try(:otp_required_for_login?)
end
def valid_otp_attempt?(user)
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
rescue OpenSSL::Cipher::CipherError => _error
rescue OpenSSL::Cipher::CipherError
false
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)
if valid_otp_attempt?(user)
session.delete(:otp_user_id)
remember_me(user)
sign_in(user)
authenticate_and_respond(user)
else
flash.now[:alert] = I18n.t('users.invalid_otp_token')
prompt_for_two_factor(user)
@ -108,6 +93,13 @@ class Auth::SessionsController < Devise::SessionsController
render :two_factor
end
def authenticate_and_respond(user)
sign_in(user)
remember_me(user)
respond_with user, location: after_sign_in_path_for(user)
end
private
def set_instance_presenter
@ -120,9 +112,11 @@ class Auth::SessionsController < Devise::SessionsController
def home_paths(resource)
paths = [about_path]
if single_user_mode? && resource.is_a?(User)
paths << short_account_path(username: resource.account)
end
paths
end

View 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

View File

@ -5,7 +5,10 @@ module ExportControllerConcern
included do
before_action :authenticate_user!
before_action :require_not_suspended!
before_action :load_export
skip_before_action :require_functional!
end
private
@ -27,4 +30,8 @@ module ExportControllerConcern
def export_filename
"#{controller_name}.csv"
end
def require_not_suspended!
forbidden if current_account.suspended?
end
end

View File

@ -23,6 +23,19 @@ module SignatureVerification
@signature_verification_failure_code || 401
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
return @signed_request_account if defined?(@signed_request_account)
@ -154,7 +167,7 @@ module SignatureVerification
.with_fallback { nil }
.with_threshold(1)
.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
end

View File

@ -7,7 +7,6 @@ class DirectoriesController < ApplicationController
before_action :require_enabled!
before_action :set_instance_presenter
before_action :set_tag, only: :show
before_action :set_tags
before_action :set_accounts
def index
@ -28,13 +27,10 @@ class DirectoriesController < ApplicationController
@tag = Tag.discoverable.find_normalized!(params[:id])
end
def set_tags
@tags = Tag.discoverable.limit(30).reject { |tag| tag.cached_sample_accounts.empty? }
end
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.not_excluded_by_account(current_account)) if current_account
end
end

View File

@ -43,7 +43,7 @@ class InvitesController < ApplicationController
end
def resource_params
params.require(:invite).permit(:max_uses, :expires_in, :autofollow)
params.require(:invite).permit(:max_uses, :expires_in, :autofollow, :comment)
end
def set_body_classes

View File

@ -7,6 +7,10 @@ class MediaProxyController < ApplicationController
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
RedisLock.acquire(lock_options) do |lock|
if lock.acquired?

View File

@ -29,7 +29,7 @@ class RemoteFollowController < ApplicationController
end
def session_params
{ acct: session[:remote_follow] }
{ acct: session[:remote_follow] || current_account&.username }
end
def set_body_classes

View File

@ -32,7 +32,7 @@ class RemoteInteractionController < ApplicationController
end
def session_params
{ acct: session[:remote_follow] }
{ acct: session[:remote_follow] || current_account&.username }
end
def set_status

View 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

View File

@ -14,12 +14,11 @@ class Settings::DeletesController < Settings::BaseController
end
def destroy
if current_user.valid_password?(delete_params[:password])
Admin::SuspensionWorker.perform_async(current_user.account_id, true)
sign_out
if challenge_passed?
destroy_account!
redirect_to new_user_session_path, notice: I18n.t('deletes.success_msg')
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
@ -29,11 +28,25 @@ class Settings::DeletesController < Settings::BaseController
redirect_to root_path unless Setting.open_deletion
end
def delete_params
params.require(:form_delete_confirmation).permit(:password)
def resource_params
params.require(:form_delete_confirmation).permit(:password, :username)
end
def require_not_suspended!
forbidden if current_account.suspended?
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

View File

@ -6,6 +6,9 @@ class Settings::ExportsController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
before_action :require_not_suspended!
skip_before_action :require_functional!
def show
@export = Export.new(current_account)
@ -34,4 +37,8 @@ class Settings::ExportsController < Settings::BaseController
def lock_options
{ redis: Redis.current, key: "backup:#{current_user.id}" }
end
def require_not_suspended!
forbidden if current_account.suspended?
end
end

View File

@ -4,31 +4,59 @@ class Settings::MigrationsController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
before_action :require_not_suspended!
before_action :set_migrations
before_action :set_cooldown
skip_before_action :require_functional!
def show
@migration = Form::Migration.new(account: current_account.moved_to_account)
@migration = current_account.migrations.build
end
def update
@migration = Form::Migration.new(resource_params)
def create
@migration = current_account.migrations.build(resource_params)
if @migration.valid? && migration_account_changed?
current_account.update!(moved_to_account: @migration.account)
if @migration.save_with_challenge(current_user)
current_account.update!(moved_to_account: @migration.target_account)
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
render :show
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
def resource_params
params.require(:migration).permit(:acct)
params.require(:account_migration).permit(:acct, :current_password, :current_username)
end
def migration_account_changed?
current_account.moved_to_account_id != @migration.account&.id &&
current_account.id != @migration.account&.id
def set_migrations
@migrations = current_account.migrations.includes(:target_account).order(id: :desc).reject(&:new_record?)
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

View File

@ -3,9 +3,12 @@
module Settings
module TwoFactorAuthentication
class ConfirmationsController < BaseController
include ChallengableConcern
layout 'admin'
before_action :authenticate_user!
before_action :require_challenge!
before_action :ensure_otp_secret
skip_before_action :require_functional!
@ -15,13 +18,15 @@ module Settings
end
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')
current_user.otp_required_for_login = true
@recovery_codes = current_user.generate_otp_backup_codes!
current_user.save!
UserMailer.two_factor_enabled(current_user).deliver_later!
render 'settings/two_factor_authentication/recovery_codes/index'
else
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
@ -33,7 +38,7 @@ module Settings
private
def confirmation_params
params.require(:form_two_factor_confirmation).permit(:code)
params.require(:form_two_factor_confirmation).permit(:otp_attempt)
end
def prepare_two_factor_form

View File

@ -3,16 +3,22 @@
module Settings
module TwoFactorAuthentication
class RecoveryCodesController < BaseController
include ChallengableConcern
layout 'admin'
before_action :authenticate_user!
before_action :require_challenge!, on: :create
skip_before_action :require_functional!
def create
@recovery_codes = current_user.generate_otp_backup_codes!
current_user.save!
UserMailer.two_factor_recovery_codes_changed(current_user).deliver_later!
flash.now[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated')
render :index
end
end

View File

@ -2,10 +2,13 @@
module Settings
class TwoFactorAuthenticationsController < BaseController
include ChallengableConcern
layout 'admin'
before_action :authenticate_user!
before_action :verify_otp_required, only: [:create]
before_action :require_challenge!, only: [:create]
skip_before_action :require_functional!
@ -23,6 +26,7 @@ module Settings
if acceptable_code?
current_user.otp_required_for_login = false
current_user.save!
UserMailer.two_factor_disabled(current_user).deliver_later!
redirect_to settings_two_factor_authentication_path
else
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
@ -34,7 +38,7 @@ module Settings
private
def confirmation_params
params.require(:form_two_factor_confirmation).permit(:code)
params.require(:form_two_factor_confirmation).permit(:otp_attempt)
end
def verify_otp_required
@ -42,8 +46,8 @@ module Settings
end
def acceptable_code?
current_user.validate_and_consume_otp!(confirmation_params[:code]) ||
current_user.invalidate_otp_backup_code!(confirmation_params[:code])
current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) ||
current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt])
end
end
end

View File

@ -5,18 +5,22 @@ module WellKnown
include RoutingHelper
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
@account = Account.find_local!(username_from_resource)
expires_in 3.days, public: true
render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json'
rescue ActiveRecord::RecordNotFound
head 404
end
private
def set_account
@account = Account.find_local!(username_from_resource)
end
def username_from_resource
resource_user = resource_param
username, domain = resource_user.split('@')
@ -28,5 +32,17 @@ module WellKnown
def resource_param
params.require(:resource)
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

View File

@ -5,7 +5,7 @@ module Admin::FilterHelper
REPORT_FILTERS = %i(resolved account_id target_account_id).freeze
INVITE_FILTER = %i(available expired).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
FOLLOWERS_FILTERS = %i(relationship status by_domain activity order).freeze

View File

@ -77,8 +77,12 @@ module ApplicationHelper
content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
end
def custom_emoji_tag(custom_emoji)
image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:")
def custom_emoji_tag(custom_emoji, animate = true)
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
def opengraph(property, content)

View File

@ -8,4 +8,16 @@ module InstanceHelper
def site_hostname
@site_hostname ||= Addressable::URI.parse("//#{Rails.configuration.x.local_domain}").display_uri.host
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

View File

@ -43,7 +43,8 @@ module SettingsHelper
oc: 'Occitan',
pl: 'Polski',
pt: 'Português',
'pt-BR': 'Português do Brasil',
'pt-PT': 'Português (Portugal)',
'pt-BR': 'Português (Brasil)',
ro: 'Română',
ru: 'Русский',
sk: 'Slovenčina',
@ -86,4 +87,12 @@ module SettingsHelper
'desktop'
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

View File

@ -34,6 +34,26 @@ module StatusesHelper
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
content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976')
end

View File

@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl';
const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
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';
@ -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 {
type: ALERT_SHOW,
title,
message,
message_values,
};
};
export function showAlertForError(error) {
if (error.response) {
const { data, status, statusText } = error.response;
const { data, status, statusText, headers } = error.response;
if (status === 404 || status === 410) {
// Skip these errors as they are reflected in the UI
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 title = `${status}`;

View File

@ -356,6 +356,8 @@ const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
cancelFetchComposeSuggestionsTags();
}
dispatch(updateSuggestionTags(token));
api(getState).get('/api/v2/search', {
cancelToken: new CancelToken(cancel => {
cancelFetchComposeSuggestionsTags = cancel;

View File

@ -15,6 +15,10 @@ export const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE';
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 = () => ({
type: CONVERSATIONS_MOUNT,
});
@ -82,3 +86,27 @@ export const updateConversations = conversation => dispatch => {
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,
});

View 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,
});

View 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));
};

View File

@ -28,6 +28,9 @@ export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
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(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);
done();
}).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 {
type: NOTIFICATIONS_EXPAND_SUCCESS,
notifications,
next,
isLoadingRecent: isLoadingRecent,
usePendingItems,
skipLoading: !isLoadingMore,
};
@ -214,3 +218,11 @@ export function setFilter (filterType) {
dispatch(saveSettings());
};
};
export const mountNotifications = () => ({
type: NOTIFICATIONS_MOUNT,
});
export const unmountNotifications = () => ({
type: NOTIFICATIONS_UNMOUNT,
});

View File

@ -9,18 +9,18 @@ export default class AutosuggestHashtag extends React.PureComponent {
tag: PropTypes.shape({
name: PropTypes.string.isRequired,
url: PropTypes.string,
history: PropTypes.array.isRequired,
history: PropTypes.array,
}).isRequired,
};
render () {
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 (
<div className='autosuggest-hashtag'>
<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>
);
}

View File

@ -35,35 +35,35 @@ export default class AvatarComposite extends React.PureComponent {
if (size === 2) {
if (index === 0) {
right = '2px';
right = '1px';
} else {
left = '2px';
left = '1px';
}
} else if (size === 3) {
if (index === 0) {
right = '2px';
right = '1px';
} else if (index > 0) {
left = '2px';
left = '1px';
}
if (index === 1) {
bottom = '2px';
bottom = '1px';
} else if (index > 1) {
top = '2px';
top = '1px';
}
} else if (size === 4) {
if (index === 0 || index === 2) {
right = '2px';
right = '1px';
}
if (index === 1 || index === 3) {
left = '2px';
left = '1px';
}
if (index < 2) {
bottom = '2px';
bottom = '1px';
} else {
top = '2px';
top = '1px';
}
}
@ -88,7 +88,13 @@ export default class AvatarComposite extends React.PureComponent {
return (
<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>
);
}

View File

@ -35,7 +35,19 @@ export default class ColumnBackButton extends React.PureComponent {
if (multiColumn) {
return component;
} 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);
}
}
}

View File

@ -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>
</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>;
}
@ -142,7 +142,7 @@ class ColumnHeader extends React.PureComponent {
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>;
}
@ -178,7 +178,19 @@ class ColumnHeader extends React.PureComponent {
if (multiColumn || placeholder) {
return component;
} 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);
}
}
}

View File

@ -12,11 +12,11 @@ const Hashtag = ({ hashtag }) => (
#<span>{hashtag.get('name')}</span>
</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 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 className='trends__item__sparkline'>

View File

@ -159,7 +159,7 @@ class Item extends React.PureComponent {
if (attachment.get('type') === 'unknown') {
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}%` }}>
<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' />
</a>
</div>
@ -315,15 +315,22 @@ class MediaGallery extends React.PureComponent {
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()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
} 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} />;
} else {
spoilerButton = (
@ -335,7 +342,7 @@ class MediaGallery extends React.PureComponent {
return (
<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}
</div>

View File

@ -32,8 +32,38 @@ class Poll extends ImmutablePureComponent {
state = {
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 => {
const { target: { value } } = e;
@ -68,12 +98,11 @@ class Poll extends ImmutablePureComponent {
this.props.dispatch(fetchPoll(this.props.poll.get('id')));
};
renderOption (option, optionIndex) {
renderOption (option, optionIndex, showResults) {
const { poll, disabled } = this.props;
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 active = !!this.state.selected[`${optionIndex}`];
const showResults = poll.get('voted') || poll.get('expired');
let titleEmojified = option.get('title_emojified');
if (!titleEmojified) {
@ -112,19 +141,20 @@ class Poll extends ImmutablePureComponent {
render () {
const { poll, intl } = this.props;
const { expired } = this.state;
if (!poll) {
return null;
}
const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
const showResults = poll.get('voted') || poll.get('expired');
const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
const showResults = poll.get('voted') || expired;
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
return (
<div className='poll'>
<ul>
{poll.get('options').map((option, i) => this.renderOption(option, i))}
{poll.get('options').map((option, i) => this.renderOption(option, i, showResults))}
</ul>
<div className='poll__footer'>

View 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>
);
}
}

View File

@ -172,8 +172,9 @@ export default class ScrollableList extends PureComponent {
const someItemInserted = React.Children.count(prevProps.children) > 0 &&
React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
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();
} else {
return null;
@ -198,7 +199,12 @@ export default class ScrollableList extends PureComponent {
this.clearMouseIdleTimer();
this.detachScrollListener();
this.detachIntersectionObserver();
detachFullscreenListener(this.onFullScreenChange);
if (this.props.onScrollToTop) {
this.props.onScrollToTop();
}
}
onFullScreenChange = () => {
@ -261,6 +267,13 @@ export default class ScrollableList extends PureComponent {
handleLoadPending = e => {
e.preventDefault();
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 () {

View File

@ -12,7 +12,7 @@ import AttachmentList from './attachment_list';
import Card from '../features/status/components/card';
import { injectIntl, FormattedMessage } from 'react-intl';
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 classNames from 'classnames';
import Icon from 'mastodon/components/icon';
@ -199,11 +199,15 @@ class Status extends ImmutablePureComponent {
};
renderLoadingMediaGallery () {
return <div className='media_gallery' style={{ height: '110px' }} />;
return <div className='media-gallery' style={{ height: '110px' }} />;
}
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) => {
@ -278,12 +282,27 @@ class Status extends ImmutablePureComponent {
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) {
return (
<div ref={this.handleRef}>
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
{status.get('content')}
</div>
<HotKeys handlers={handlers}>
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex='0'>
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
{status.get('content')}
</div>
</HotKeys>
);
}
@ -333,7 +352,23 @@ class Status extends ImmutablePureComponent {
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]);
media = (
@ -394,19 +429,6 @@ class Status extends ImmutablePureComponent {
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 (
<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}>

View File

@ -230,7 +230,7 @@ export default class StatusContent extends React.PureComponent {
);
} else if (this.props.onClick) {
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')} />
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}

View File

@ -12,6 +12,8 @@ import { hydrateStore } from '../actions/store';
import { connectUserStream } from '../actions/streaming';
import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales';
import { previewState as previewMediaState } from 'mastodon/features/ui/components/media_modal';
import { previewState as previewVideoState } from 'mastodon/features/ui/components/video_modal';
import initialState from '../initial_state';
import ErrorBoundary from '../components/error_boundary';
@ -35,6 +37,10 @@ class MastodonMount extends React.PureComponent {
showIntroduction: PropTypes.bool,
};
shouldUpdateScroll (_, { location }) {
return location.state !== previewMediaState && location.state !== previewVideoState;
}
render () {
const { showIntroduction } = this.props;
@ -44,7 +50,7 @@ class MastodonMount extends React.PureComponent {
return (
<BrowserRouter basename='/web'>
<ScrollContext>
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
<Route path='/' component={UI} />
</ScrollContext>
</BrowserRouter>

View File

@ -7,6 +7,8 @@ import MediaGallery from '../components/media_gallery';
import Video from '../features/video';
import Card from '../features/status/components/card';
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 { getScrollbarWidth } from '../features/ui/components/modal_root';
import MediaModal from '../features/ui/components/media_modal';
@ -15,7 +17,7 @@ import { List as ImmutableList, fromJS } from 'immutable';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll };
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
export default class MediaContainer extends PureComponent {
@ -62,12 +64,13 @@ export default class MediaContainer extends PureComponent {
{[].map.call(components, (component, i) => {
const componentName = component.getAttribute('data-component');
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, {
...(media ? { media: fromJS(media) } : {}),
...(card ? { card: fromJS(card) } : {}),
...(poll ? { poll: fromJS(poll) } : {}),
...(media ? { media: fromJS(media) } : {}),
...(card ? { card: fromJS(card) } : {}),
...(poll ? { poll: fromJS(poll) } : {}),
...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
...(componentName === 'Video' ? {
onOpenVideo: this.handleOpenVideo,
@ -81,6 +84,7 @@ export default class MediaContainer extends PureComponent {
component,
);
})}
<ModalRoot onClose={this.handleCloseMedia}>
{this.state.media && (
<MediaModal

View File

@ -56,6 +56,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
onReply (status, router) {
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),

View File

@ -96,6 +96,12 @@ export default class MediaItem extends ImmutablePureComponent {
if (attachment.get('type') === 'unknown') {
// Skip
} else if (attachment.get('type') === 'audio') {
thumbnail = (
<span className='account-gallery__item__icons'>
<Icon id='music' />
</span>
);
} else if (attachment.get('type') === 'image') {
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;

View File

@ -100,7 +100,7 @@ class AccountGallery extends ImmutablePureComponent {
}
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') }));
} else {
const media = attachment.getIn(['status', 'media_attachments']);

View 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>
);
}
}

View File

@ -18,9 +18,10 @@ const mapStateToProps = (state, { onlyMedia, columnId }) => {
const uuid = columnId;
const columns = state.getIn(['settings', 'columns']);
const index = columns.findIndex(c => c.get('uuid') === uuid);
const timelineState = state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`]);
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']),
};
};

View File

@ -23,9 +23,14 @@ class ActionBar extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleLogout = () => {
this.props.onLogout();
}
render () {
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.filters), href: '/filters' });
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 (
<div className='compose__action-bar'>

View File

@ -12,6 +12,7 @@ export default class NavigationBar extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
onLogout: PropTypes.func.isRequired,
onClose: PropTypes.func,
};
@ -33,7 +34,7 @@ export default class NavigationBar extends ImmutablePureComponent {
<div className='navigation-bar__actions'>
<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>
);

View File

@ -1,11 +1,29 @@
import { connect } from 'react-redux';
import { defineMessages, injectIntl } from 'react-intl';
import NavigationBar from '../components/navigation_bar';
import { logOut } from 'mastodon/utils/log_out';
import { openModal } from 'mastodon/actions/modal';
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 => {
return {
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));

View File

@ -12,9 +12,11 @@ import Motion from '../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import SearchResultsContainer from './containers/search_results_container';
import { changeComposing } from '../../actions/compose';
import { openModal } from 'mastodon/actions/modal';
import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
import { mascot } from '../../initial_state';
import Icon from 'mastodon/components/icon';
import { logOut } from 'mastodon/utils/log_out';
const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
@ -25,6 +27,8 @@ const messages = defineMessages({
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
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) => ({
@ -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 = () => {
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>
)}
<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>
);
}

View File

@ -2,9 +2,28 @@ import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
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 = {
router: PropTypes.object,
@ -13,11 +32,12 @@ export default class Conversation extends ImmutablePureComponent {
static propTypes = {
conversationId: PropTypes.string.isRequired,
accounts: ImmutablePropTypes.list.isRequired,
lastStatusId: PropTypes.string,
lastStatus: ImmutablePropTypes.map,
unread:PropTypes.bool.isRequired,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
markRead: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleClick = () => {
@ -25,13 +45,25 @@ export default class Conversation extends ImmutablePureComponent {
return;
}
const { lastStatusId, unread, markRead } = this.props;
const { lastStatus, unread, markRead } = this.props;
if (unread) {
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 = () => {
@ -42,22 +74,88 @@ export default class Conversation extends ImmutablePureComponent {
this.props.onMoveDown(this.props.conversationId);
}
render () {
const { accounts, lastStatusId, unread } = this.props;
handleConversationMute = () => {
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;
}
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 (
<StatusContainer
id={lastStatusId}
unread={unread}
otherAccounts={accounts}
onMoveUp={this.handleHotkeyMoveUp}
onMoveDown={this.handleHotkeyMoveDown}
onClick={this.handleClick}
/>
<HotKeys handlers={handlers}>
<div className='conversation focusable muted' tabIndex='0'>
<div className='conversation__avatar'>
<AvatarComposite accounts={accounts} size={48} />
</div>
<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>
);
}

View File

@ -1,19 +1,74 @@
import { connect } from 'react-redux';
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 conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
const messages = defineMessages({
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 {
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
unread: conversation.get('unread'),
lastStatusId: conversation.get('last_status', null),
const mapStateToProps = () => {
const getStatus = makeGetStatus();
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 }) => ({
markRead: () => dispatch(markConversationRead(conversationId)),
const mapDispatchToProps = (dispatch, { intl, 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));

View File

@ -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>
);
}
}

View 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>
);
}
}

View File

@ -3,6 +3,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Hashtag from 'mastodon/components/hashtag';
import { FormattedMessage } from 'react-intl';
export default class Trends extends ImmutablePureComponent {
@ -17,7 +18,7 @@ export default class Trends extends ImmutablePureComponent {
componentDidMount () {
this.props.fetchTrends();
this.refreshInterval = setInterval(() => this.props.fetchTrends(), 36000);
this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000);
}
componentWillUnmount () {
@ -35,6 +36,8 @@ export default class Trends extends ImmutablePureComponent {
return (
<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} />)}
</div>
);

View File

@ -107,7 +107,7 @@ class GettingStarted extends ImmutablePureComponent {
if (profile_directory) {
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;
@ -120,7 +120,7 @@ class GettingStarted extends ImmutablePureComponent {
height += 34;
} else if (profile_directory) {
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;

View File

@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { columnId }) => ({
},
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 { value: tag.name, label: `#${tag.name}` };
});

View File

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Column from '../../components/column';
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 NotificationContainer from './containers/notification_container';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
@ -39,7 +39,7 @@ const mapStateToProps = state => ({
showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
notifications: getNotifications(state),
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']),
numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
});
@ -66,11 +66,16 @@ class Notifications extends React.PureComponent {
trackScroll: true,
};
componentWillMount() {
this.props.dispatch(mountNotifications());
}
componentWillUnmount () {
this.handleLoadOlder.cancel();
this.handleScrollToTop.cancel();
this.handleScroll.cancel();
this.props.dispatch(scrollTopNotifications(false));
this.props.dispatch(unmountNotifications());
}
handleLoadGap = (maxId) => {

View File

@ -10,6 +10,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
import Card from './card';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from '../../video';
import Audio from '../../audio';
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
@ -107,7 +108,19 @@ export default class DetailedStatus extends ImmutablePureComponent {
}
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]);
media = (

View File

@ -84,28 +84,38 @@ const makeMapStateToProps = () => {
const getDescendantsIds = createSelector([
(_, { id }) => id,
state => state.getIn(['contexts', 'replies']),
], (statusId, contextReplies) => {
let descendantsIds = Immutable.List();
descendantsIds = descendantsIds.withMutations(mutable => {
const ids = [statusId];
state => state.get('statuses'),
], (statusId, contextReplies, statuses) => {
let descendantsIds = [];
const ids = [statusId];
while (ids.length > 0) {
let id = ids.shift();
const replies = contextReplies.get(id);
while (ids.length > 0) {
let id = ids.shift();
const replies = contextReplies.get(id);
if (statusId !== id) {
mutable.push(id);
}
if (replies) {
replies.reverse().forEach(reply => {
ids.unshift(reply);
});
}
if (statusId !== id) {
descendantsIds.push(id);
}
});
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) => {

View File

@ -12,7 +12,18 @@ import BundleContainer from '../containers/bundle_container';
import ColumnLoading from './column_loading';
import DrawerLoading from './drawer_loading';
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 ComposePanel from './compose_panel';
import NavigationPanel from './navigation_panel';
@ -30,6 +41,7 @@ const componentMap = {
'DIRECT': DirectTimeline,
'FAVOURITES': FavouritedStatuses,
'LIST': ListTimeline,
'DIRECTORY': Directory,
};
const messages = defineMessages({

View File

@ -10,6 +10,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import IconButton from 'mastodon/components/icon_button';
import Button from 'mastodon/components/button';
import Video from 'mastodon/features/video';
import Audio from 'mastodon/features/audio';
import Textarea from 'react-textarea-autosize';
import UploadProgress from 'mastodon/features/compose/components/upload_progress';
import CharacterCounter from 'mastodon/features/compose/components/character_counter';
@ -222,10 +223,10 @@ class FocalPointModal extends ImmutablePureComponent {
<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>
<CharacterCounter max={420} text={detecting ? '' : description} />
<CharacterCounter max={1500} text={detecting ? '' : description} />
</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 className='focal-point-modal__content'>
@ -244,12 +245,23 @@ class FocalPointModal extends ImmutablePureComponent {
</div>
)}
{['audio', 'video'].includes(media.get('type')) && (
{media.get('type') === 'video' && (
<Video
preview={media.get('preview_url')}
blurhash={media.get('blurhash')}
src={media.get('url')}
detailed
inline
editable
/>
)}
{media.get('type') === 'audio' && (
<Audio
src={media.get('url')}
duration={media.getIn(['meta', 'original', 'duration'], 0)}
height={150}
preload
editable
/>
)}

View File

@ -1,35 +1,72 @@
import { connect } from 'react-redux';
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Link } from 'react-router-dom';
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 }) => (
<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' data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
</ul>
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' },
});
<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>
);
const mapDispatchToProps = (dispatch, { intl }) => ({
onLogout () {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
onConfirm: () => logOut(),
}));
},
});
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;

View File

@ -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='/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>
{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 />
@ -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='/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 && <TrendsContainer />}

View File

@ -11,7 +11,7 @@ const mapStateToProps = (state, { intl }) => {
const value = notification[key];
if (typeof value === 'object') {
notification[key] = intl.formatMessage(value);
notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
}
}));

View File

@ -16,6 +16,7 @@ import { expandNotifications } from '../../actions/notifications';
import { fetchFilters } from '../../actions/filters';
import { clearHeight } from '../../actions/height_cache';
import { focusApp, unfocusApp } from 'mastodon/actions/app';
import { submitMarkers } from 'mastodon/actions/markers';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
import UploadArea from './components/upload_area';
import ColumnsAreaContainer from './containers/columns_area_container';
@ -47,6 +48,7 @@ import {
PinnedStatuses,
Lists,
Search,
Directory,
} from './util/async-components';
import { me, forceSingleColumn } from '../../initial_state';
import { previewState as previewMediaState } from './components/media_modal';
@ -64,6 +66,7 @@ const mapStateToProps = state => ({
isComposing: state.getIn(['compose', 'is_composing']),
hasComposingText: state.getIn(['compose', 'text']).trim().length !== 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,
});
@ -141,14 +144,24 @@ class SwitchingColumnsArea extends React.PureComponent {
return location.state !== previewMediaState && location.state !== previewVideoState;
}
handleResize = debounce(() => {
handleLayoutChange = debounce(() => {
// The cached heights are no longer accurate, invalidate
this.props.onLayoutChange();
this.setState({ mobile: isMobile(window.innerWidth) });
}, 500, {
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 => {
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='/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/:statusId' exact component={Status} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
@ -219,6 +233,7 @@ class UI extends React.PureComponent {
isComposing: PropTypes.bool,
hasComposingText: PropTypes.bool,
hasMediaAttachments: PropTypes.bool,
canUploadMore: PropTypes.bool,
location: PropTypes.object,
intl: PropTypes.object.isRequired,
dropdownMenuIsOpen: PropTypes.bool,
@ -229,7 +244,9 @@ class UI extends React.PureComponent {
};
handleBeforeUnload = e => {
const { intl, isComposing, hasComposingText, hasMediaAttachments } = this.props;
const { intl, dispatch, isComposing, hasComposingText, hasMediaAttachments } = this.props;
dispatch(submitMarkers());
if (isComposing && (hasComposingText || hasMediaAttachments)) {
// Setting returnValue to any string causes confirmation dialog.
@ -263,13 +280,14 @@ class UI extends React.PureComponent {
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 });
}
}
handleDragOver = (e) => {
if (this.dataTransferIsText(e.dataTransfer)) return false;
e.preventDefault();
e.stopPropagation();
@ -284,12 +302,13 @@ class UI extends React.PureComponent {
handleDrop = (e) => {
if (this.dataTransferIsText(e.dataTransfer)) return;
e.preventDefault();
this.setState({ draggingOver: false });
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));
}
}

View File

@ -137,3 +137,11 @@ export function Search () {
export function Tesseract () {
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