Merge tag 'v2.0.0' into kosmos

This commit is contained in:
Basti 2017-10-28 22:51:56 +02:00
commit 63e288ed6f
5131 changed files with 24596 additions and 13218 deletions

View File

@ -1,5 +1,6 @@
# Service dependencies # Service dependencies
# You may set REDIS_URL instead for more advanced options # You may set REDIS_URL instead for more advanced options
# You may also set REDIS_NAMESPACE to share Redis between multiple Mastodon servers
REDIS_HOST=redis REDIS_HOST=redis
REDIS_PORT=6379 REDIS_PORT=6379
# You may set DATABASE_URL instead for more advanced options # You may set DATABASE_URL instead for more advanced options

View File

@ -5,12 +5,14 @@ env:
browser: true browser: true
node: true node: true
es6: true es6: true
jest: true
parser: babel-eslint parser: babel-eslint
plugins: plugins:
- react - react
- jsx-a11y - jsx-a11y
- import
parserOptions: parserOptions:
sourceType: module sourceType: module
@ -21,8 +23,14 @@ parserOptions:
modules: true modules: true
spread: true spread: true
rules: settings:
import/extensions:
- .js
import/ignore:
- node_modules
- \\.(css|scss|json)$
rules:
brace-style: warn brace-style: warn
comma-dangle: comma-dangle:
- error - error
@ -125,3 +133,17 @@ rules:
jsx-a11y/role-supports-aria-props: off jsx-a11y/role-supports-aria-props: off
jsx-a11y/scope: warn jsx-a11y/scope: warn
jsx-a11y/tabindex-no-positive: warn jsx-a11y/tabindex-no-positive: warn
import/extensions:
- error
- always
- js: never
import/newline-after-import: error
import/no-extraneous-dependencies:
- error
- devDependencies:
- "config/webpack/**"
- "app/javascript/mastodon/test_setup.js"
- "app/javascript/**/__tests__/**"
import/no-unresolved: error
import/no-webpack-loader-syntax: error

View File

@ -1 +1 @@
2.4.1 2.4.2

View File

@ -26,18 +26,16 @@ addons:
postgresql: 9.4 postgresql: 9.4
apt: apt:
sources: sources:
- ubuntu-toolchain-r-test
- trusty-media - trusty-media
packages: packages:
- ffmpeg - ffmpeg
- g++-6
- libprotobuf-dev - libprotobuf-dev
- protobuf-compiler - protobuf-compiler
- libicu-dev - libicu-dev
rvm: rvm:
- 2.3.4 - 2.3.4
- 2.4.1 - 2.4.2
services: services:
- redis-server - redis-server
@ -55,5 +53,5 @@ before_script:
script: script:
- travis_retry bundle exec parallel_test spec/ --group-by filesize --type rspec - travis_retry bundle exec parallel_test spec/ --group-by filesize --type rspec
- npm test - yarn test
- bundle exec i18n-tasks unused - bundle exec i18n-tasks check-normalized && bundle exec i18n-tasks unused

46
.yarnclean Normal file
View File

@ -0,0 +1,46 @@
# test directories
__tests__
test
tests
powered-test
# asset directories
docs
doc
website
images
# assets
# examples
example
examples
# code coverage directories
coverage
.nyc_output
# build scripts
Makefile
Gulpfile.js
Gruntfile.js
# configs
.tern-project
.gitattributes
.editorconfig
.*ignore
.eslintrc
.jshintrc
.flowconfig
.documentup.json
.yarn-metadata.json
.*.yml
*.yml
# misc
*.gz
*.md
# for specific ignore
!.svgo.yml

View File

@ -8,8 +8,25 @@
# /config/locales/*.fr.yml @żelipapą # /config/locales/*.fr.yml @żelipapą
# /config/locales/fr.yml @żelipapą # /config/locales/fr.yml @żelipapą
# Polish
/app/javascript/mastodon/locales/pl.json @m4sk1n /app/javascript/mastodon/locales/pl.json @m4sk1n
/app/views/user_mailer/*.pl.html.erb @m4sk1n /app/views/user_mailer/*.pl.html.erb @m4sk1n
/app/views/user_mailer/*.pl.text.erb @m4sk1n /app/views/user_mailer/*.pl.text.erb @m4sk1n
/config/locales/*.pl.yml @m4sk1n /config/locales/*.pl.yml @m4sk1n
/config/locales/pl.yml @m4sk1n /config/locales/pl.yml @m4sk1n
# French
/app/javascript/mastodon/locales/fr.json @aldarone
/app/javascript/mastodon/locales/whitelist_fr.json @aldarone
/app/views/user_mailer/*.fr.html.erb @aldarone
/app/views/user_mailer/*.fr.text.erb @aldarone
/config/locales/*.fr.yml @aldarone
/config/locales/fr.yml @aldarone
# Dutch
/app/javascript/mastodon/locales/nl.json @jeroenpraat
/app/javascript/mastodon/locales/whitelist_nl.json @jeroenpraat
/app/views/user_mailer/*.nl.html.erb @jeroenpraat
/app/views/user_mailer/*.nl.text.erb @jeroenpraat
/config/locales/*.nl.yml @jeroenpraat
/config/locales/nl.yml @jeroenpraat

View File

@ -1,4 +1,4 @@
FROM ruby:2.4.1-alpine3.6 FROM ruby:2.4.2-alpine3.6
LABEL maintainer="https://github.com/tootsuite/mastodon" \ LABEL maintainer="https://github.com/tootsuite/mastodon" \
description="A GNU Social-compatible microblogging server" description="A GNU Social-compatible microblogging server"
@ -7,6 +7,8 @@ ENV UID=991 GID=991 \
RAILS_SERVE_STATIC_FILES=true \ RAILS_SERVE_STATIC_FILES=true \
RAILS_ENV=production NODE_ENV=production RAILS_ENV=production NODE_ENV=production
ARG YARN_VERSION=1.1.0
ARG YARN_DOWNLOAD_SHA256=171c1f9ee93c488c0d774ac6e9c72649047c3f896277d88d0f805266519430f3
ARG LIBICONV_VERSION=1.15 ARG LIBICONV_VERSION=1.15
ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178 ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178
@ -19,6 +21,7 @@ RUN apk -U upgrade \
build-base \ build-base \
icu-dev \ icu-dev \
libidn-dev \ libidn-dev \
libressl \
libtool \ libtool \
postgresql-dev \ postgresql-dev \
protobuf-dev \ protobuf-dev \
@ -32,16 +35,21 @@ RUN apk -U upgrade \
imagemagick \ imagemagick \
libidn \ libidn \
libpq \ libpq \
nodejs-npm \
nodejs \ nodejs \
nodejs-npm \
protobuf \ protobuf \
su-exec \ su-exec \
tini \ tini \
yarn \
&& update-ca-certificates \ && update-ca-certificates \
&& mkdir -p /tmp/src /opt \
&& wget -O yarn.tar.gz "https://github.com/yarnpkg/yarn/releases/download/v$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz" \
&& echo "$YARN_DOWNLOAD_SHA256 *yarn.tar.gz" | sha256sum -c - \
&& tar -xzf yarn.tar.gz -C /tmp/src \
&& rm yarn.tar.gz \
&& mv /tmp/src/yarn-v$YARN_VERSION /opt/yarn \
&& ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn \
&& wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \ && wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \
&& echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \ && echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \
&& mkdir -p /tmp/src \
&& tar -xzf libiconv.tar.gz -C /tmp/src \ && tar -xzf libiconv.tar.gz -C /tmp/src \
&& rm libiconv.tar.gz \ && rm libiconv.tar.gz \
&& cd /tmp/src/libiconv-$LIBICONV_VERSION \ && cd /tmp/src/libiconv-$LIBICONV_VERSION \
@ -52,11 +60,12 @@ RUN apk -U upgrade \
&& cd /mastodon \ && cd /mastodon \
&& rm -rf /tmp/* /var/cache/apk/* && rm -rf /tmp/* /var/cache/apk/*
COPY Gemfile Gemfile.lock package.json yarn.lock /mastodon/ COPY Gemfile Gemfile.lock package.json yarn.lock .yarnclean /mastodon/
RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \ RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \
&& bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \ && bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \
&& yarn --ignore-optional --pure-lockfile && yarn --pure-lockfile \
&& yarn cache clean
COPY . /mastodon COPY . /mastodon

View File

@ -42,6 +42,7 @@ gem 'kaminari', '~> 1.0'
gem 'link_header', '~> 0.0' gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.1' gem 'mime-types', '~> 3.1'
gem 'nokogiri', '~> 1.7' gem 'nokogiri', '~> 1.7'
gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.0' gem 'oj', '~> 3.0'
gem 'ostatus2', '~> 2.0' gem 'ostatus2', '~> 2.0'
gem 'ox', '~> 2.5' gem 'ox', '~> 2.5'
@ -64,10 +65,10 @@ gem 'sidekiq-bulk', '~>0.1.1'
gem 'simple-navigation', '~> 4.0' gem 'simple-navigation', '~> 4.0'
gem 'simple_form', '~> 3.4' gem 'simple_form', '~> 3.4'
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
gem 'statsd-instrument', '~> 2.1' gem 'strong_migrations'
gem 'twitter-text', '~> 1.14' gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2017' gem 'tzinfo-data', '~> 1.2017'
gem 'webpacker', '~> 2.0' gem 'webpacker', '~> 3.0'
gem 'webpush' gem 'webpush'
gem 'json-ld-preloaded', '~> 2.2.1' gem 'json-ld-preloaded', '~> 2.2.1'
@ -102,8 +103,8 @@ group :development do
gem 'letter_opener', '~> 1.4' gem 'letter_opener', '~> 1.4'
gem 'letter_opener_web', '~> 1.3' gem 'letter_opener_web', '~> 1.3'
gem 'rubocop', require: false gem 'rubocop', require: false
gem 'brakeman', '~> 3.6', require: false gem 'brakeman', '~> 4.0', require: false
gem 'bundler-audit', '~> 0.5', require: false gem 'bundler-audit', '~> 0.6', require: false
gem 'scss_lint', '~> 0.53', require: false gem 'scss_lint', '~> 0.53', require: false
gem 'capistrano', '~> 3.8' gem 'capistrano', '~> 3.8'

View File

@ -57,33 +57,33 @@ GEM
encryptor (~> 3.0.0) encryptor (~> 3.0.0)
av (0.9.0) av (0.9.0)
cocaine (~> 0.5.3) cocaine (~> 0.5.3)
aws-sdk (2.10.21) aws-sdk (2.10.46)
aws-sdk-resources (= 2.10.21) aws-sdk-resources (= 2.10.46)
aws-sdk-core (2.10.21) aws-sdk-core (2.10.46)
aws-sigv4 (~> 1.0) aws-sigv4 (~> 1.0)
jmespath (~> 1.0) jmespath (~> 1.0)
aws-sdk-resources (2.10.21) aws-sdk-resources (2.10.46)
aws-sdk-core (= 2.10.21) aws-sdk-core (= 2.10.46)
aws-sigv4 (1.0.1) aws-sigv4 (1.0.2)
bcrypt (3.1.11) bcrypt (3.1.11)
better_errors (2.1.1) better_errors (2.3.0)
coderay (>= 1.0.0) coderay (>= 1.0.0)
erubis (>= 2.6.6) erubi (>= 1.0.0)
rack (>= 0.9.0) rack (>= 0.9.0)
binding_of_caller (0.7.2) binding_of_caller (0.7.2)
debug_inspector (>= 0.0.1) debug_inspector (>= 0.0.1)
bootsnap (1.1.2) bootsnap (1.1.3)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (3.7.2) brakeman (4.0.1)
browser (2.4.0) browser (2.5.1)
builder (3.2.3) builder (3.2.3)
bullet (5.5.1) bullet (5.6.1)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.10.0) uniform_notifier (~> 1.10.0)
bundler-audit (0.6.0) bundler-audit (0.6.0)
bundler (~> 1.2) bundler (~> 1.2)
thor (~> 0.18) thor (~> 0.18)
capistrano (3.8.2) capistrano (3.9.1)
airbrussh (>= 1.0.0) airbrussh (>= 1.0.0)
i18n i18n
rake (>= 10.0.0) rake (>= 10.0.0)
@ -99,9 +99,9 @@ GEM
sshkit (~> 1.3) sshkit (~> 1.3)
capistrano-yarn (2.0.2) capistrano-yarn (2.0.2)
capistrano (~> 3.0) capistrano (~> 3.0)
capybara (2.14.4) capybara (2.15.1)
addressable addressable
mime-types (>= 1.16) mini_mime (>= 0.1.3)
nokogiri (>= 1.3.3) nokogiri (>= 1.3.3)
rack (>= 1.0.0) rack (>= 1.0.0)
rack-test (>= 0.5.4) rack-test (>= 0.5.4)
@ -115,7 +115,7 @@ GEM
climate_control (0.2.0) climate_control (0.2.0)
cocaine (0.5.8) cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0) climate_control (>= 0.0.3, < 1.0)
coderay (1.1.1) coderay (1.1.2)
colorize (0.8.1) colorize (0.8.1)
concurrent-ruby (1.0.5) concurrent-ruby (1.0.5)
connection_pool (2.2.1) connection_pool (2.2.1)
@ -151,13 +151,12 @@ GEM
thread_safe thread_safe
encryptor (3.0.0) encryptor (3.0.0)
erubi (1.6.1) erubi (1.6.1)
erubis (2.7.0)
et-orbi (1.0.5) et-orbi (1.0.5)
tzinfo tzinfo
excon (0.58.0) excon (0.59.0)
execjs (2.7.0) execjs (2.7.0)
fabrication (2.16.2) fabrication (2.16.3)
faker (1.7.3) faker (1.8.4)
i18n (~> 0.5) i18n (~> 0.5)
fast_blank (1.0.0) fast_blank (1.0.0)
ffi (1.9.18) ffi (1.9.18)
@ -194,7 +193,7 @@ GEM
railties (>= 4.0.1) railties (>= 4.0.1)
hamster (3.0.0) hamster (3.0.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
hashdiff (0.3.5) hashdiff (0.3.7)
highline (1.7.8) highline (1.7.8)
hiredis (0.6.1) hiredis (0.6.1)
hkdf (0.3.0) hkdf (0.3.0)
@ -213,11 +212,11 @@ GEM
colorize colorize
rack rack
i18n (0.8.6) i18n (0.8.6)
i18n-tasks (0.9.16) i18n-tasks (0.9.18)
activesupport (>= 4.0.2) activesupport (>= 4.0.2)
ast (>= 2.1.0) ast (>= 2.1.0)
easy_translate (>= 0.5.0) easy_translate (>= 0.5.0)
erubis erubi
highline (>= 1.7.3) highline (>= 1.7.3)
i18n i18n
parser (>= 2.2.3.0) parser (>= 2.2.3.0)
@ -231,7 +230,7 @@ GEM
json-ld (2.1.5) json-ld (2.1.5)
multi_json (~> 1.12) multi_json (~> 1.12)
rdf (~> 2.2) rdf (~> 2.2)
json-ld-preloaded (2.2.1) json-ld-preloaded (2.2.2)
json-ld (~> 2.1, >= 2.1.5) json-ld (~> 2.1, >= 2.1.5)
multi_json (~> 1.11) multi_json (~> 1.11)
rdf (~> 2.2) rdf (~> 2.2)
@ -258,10 +257,11 @@ GEM
letter_opener (~> 1.0) letter_opener (~> 1.0)
railties (>= 3.2) railties (>= 3.2)
link_header (0.0.8) link_header (0.0.8)
lograge (0.5.1) lograge (0.6.0)
actionpack (>= 4, < 5.2) actionpack (>= 4, < 5.2)
activesupport (>= 4, < 5.2) activesupport (>= 4, < 5.2)
railties (>= 4, < 5.2) railties (>= 4, < 5.2)
request_store (~> 1.0)
loofah (2.0.3) loofah (2.0.3)
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
mail (2.6.6) mail (2.6.6)
@ -276,27 +276,33 @@ GEM
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2016.0521) mime-types-data (3.2016.0521)
mimemagic (0.3.2) mimemagic (0.3.2)
mini_mime (0.1.4)
mini_portile2 (2.2.0) mini_portile2 (2.2.0)
minitest (5.10.3) minitest (5.10.3)
msgpack (1.1.0) msgpack (1.1.0)
multi_json (1.12.1) multi_json (1.12.2)
net-scp (1.2.1) net-scp (1.2.1)
net-ssh (>= 2.6.5) net-ssh (>= 2.6.5)
net-ssh (4.1.0) net-ssh (4.2.0)
nio4r (2.1.0) nio4r (2.1.0)
nokogiri (1.8.0) nokogiri (1.8.0)
mini_portile2 (~> 2.2.0) mini_portile2 (~> 2.2.0)
nokogumbo (1.4.13) nokogumbo (1.4.13)
nokogiri nokogiri
oj (3.3.4) nsa (0.2.4)
openssl (2.0.4) activesupport (>= 4.2, < 6)
concurrent-ruby (~> 1.0.0)
sidekiq (>= 3.5.0)
statsd-ruby (~> 1.2.0)
oj (3.3.5)
openssl (2.0.5)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ostatus2 (2.0.1) ostatus2 (2.0.1)
addressable (~> 2.4) addressable (~> 2.4)
http (~> 2.0) http (~> 2.0)
nokogiri (~> 1.6) nokogiri (~> 1.6)
openssl (~> 2.0) openssl (~> 2.0)
ox (2.5.0) ox (2.6.0)
paperclip (5.1.0) paperclip (5.1.0)
activemodel (>= 4.2.0) activemodel (>= 4.2.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
@ -306,15 +312,15 @@ GEM
paperclip-av-transcoder (0.6.4) paperclip-av-transcoder (0.6.4)
av (~> 0.9.0) av (~> 0.9.0)
paperclip (>= 2.5.2) paperclip (>= 2.5.2)
parallel (1.11.2) parallel (1.12.0)
parallel_tests (2.14.2) parallel_tests (2.15.0)
parallel parallel
parser (2.4.0.0) parser (2.4.0.0)
ast (~> 2.2) ast (~> 2.2)
pg (0.21.0) pg (0.21.0)
pghero (1.7.0) pghero (1.7.0)
activerecord activerecord
pkg-config (1.2.4) pkg-config (1.2.7)
powerpack (0.1.1) powerpack (0.1.1)
pry (0.10.4) pry (0.10.4)
coderay (~> 1.1.0) coderay (~> 1.1.0)
@ -334,6 +340,8 @@ GEM
rack-cors (0.4.1) rack-cors (0.4.1)
rack-protection (2.0.0) rack-protection (2.0.0)
rack rack
rack-proxy (0.6.2)
rack
rack-test (0.7.0) rack-test (0.7.0)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rack-timeout (0.4.2) rack-timeout (0.4.2)
@ -371,8 +379,8 @@ GEM
thor (>= 0.18.1, < 2.0) thor (>= 0.18.1, < 2.0)
rainbow (2.2.2) rainbow (2.2.2)
rake rake
rake (12.0.0) rake (12.1.0)
rdf (2.2.8) rdf (2.2.9)
hamster (~> 3.0) hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.3.2) rdf-normalize (0.3.2)
@ -396,6 +404,7 @@ GEM
redis-store (>= 1.2, < 2) redis-store (>= 1.2, < 2)
redis-store (1.3.0) redis-store (1.3.0)
redis (>= 2.2) redis (>= 2.2)
request_store (1.3.2)
responders (2.4.0) responders (2.4.0)
actionpack (>= 4.2.0, < 5.3) actionpack (>= 4.2.0, < 5.3)
railties (>= 4.2.0, < 5.3) railties (>= 4.2.0, < 5.3)
@ -410,7 +419,7 @@ GEM
rspec-mocks (3.6.0) rspec-mocks (3.6.0)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.6.0) rspec-support (~> 3.6.0)
rspec-rails (3.6.0) rspec-rails (3.6.1)
actionpack (>= 3.0) actionpack (>= 3.0)
activesupport (>= 3.0) activesupport (>= 3.0)
railties (>= 3.0) railties (>= 3.0)
@ -422,15 +431,15 @@ GEM
rspec-core (~> 3.0, >= 3.0.0) rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0) sidekiq (>= 2.4.0)
rspec-support (3.6.0) rspec-support (3.6.0)
rubocop (0.49.1) rubocop (0.50.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 2.3.3.1, < 3.0) parser (>= 2.3.3.1, < 3.0)
powerpack (~> 0.1) powerpack (~> 0.1)
rainbow (>= 1.99.1, < 3.0) rainbow (>= 2.2.2, < 3.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1) unicode-display_width (~> 1.0, >= 1.0.1)
ruby-oembed (0.12.0) ruby-oembed (0.12.0)
ruby-progressbar (1.8.1) ruby-progressbar (1.8.3)
rufus-scheduler (3.4.2) rufus-scheduler (3.4.2)
et-orbi (~> 1.0) et-orbi (~> 1.0)
safe_yaml (1.0.4) safe_yaml (1.0.4)
@ -438,7 +447,7 @@ GEM
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.4.4) nokogiri (>= 1.4.4)
nokogumbo (~> 1.4.1) nokogumbo (~> 1.4.1)
sass (3.4.24) sass (3.4.25)
scss_lint (0.54.0) scss_lint (0.54.0)
rake (>= 0.9, < 13) rake (>= 0.9, < 13)
sass (~> 3.4.20) sass (~> 3.4.20)
@ -450,12 +459,12 @@ GEM
sidekiq-bulk (0.1.1) sidekiq-bulk (0.1.1)
activesupport activesupport
sidekiq sidekiq
sidekiq-scheduler (2.1.8) sidekiq-scheduler (2.1.9)
redis (~> 3) redis (~> 3)
rufus-scheduler (~> 3.2) rufus-scheduler (~> 3.2)
sidekiq (>= 3) sidekiq (>= 3)
tilt (>= 1.4.0) tilt (>= 1.4.0)
sidekiq-unique-jobs (5.0.9) sidekiq-unique-jobs (5.0.10)
sidekiq (>= 4.0, <= 6.0) sidekiq (>= 4.0, <= 6.0)
thor (~> 0) thor (~> 0)
simple-navigation (4.0.5) simple-navigation (4.0.5)
@ -463,11 +472,11 @@ GEM
simple_form (3.5.0) simple_form (3.5.0)
actionpack (> 4, < 5.2) actionpack (> 4, < 5.2)
activemodel (> 4, < 5.2) activemodel (> 4, < 5.2)
simplecov (0.14.1) simplecov (0.15.1)
docile (~> 1.1.0) docile (~> 1.1.0)
json (>= 1.8, < 3) json (>= 1.8, < 3)
simplecov-html (~> 0.10.0) simplecov-html (~> 0.10.0)
simplecov-html (0.10.1) simplecov-html (0.10.2)
slop (3.6.0) slop (3.6.0)
sprockets (3.7.1) sprockets (3.7.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
@ -476,10 +485,12 @@ GEM
actionpack (>= 4.0) actionpack (>= 4.0)
activesupport (>= 4.0) activesupport (>= 4.0)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
sshkit (1.13.1) sshkit (1.14.0)
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
statsd-instrument (2.1.4) statsd-ruby (1.2.1)
strong_migrations (0.1.9)
activerecord (>= 3.2.0)
temple (0.8.0) temple (0.8.0)
terminal-table (1.8.0) terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1) unicode-display_width (~> 1.1, >= 1.1.1)
@ -502,13 +513,13 @@ GEM
uniform_notifier (1.10.0) uniform_notifier (1.10.0)
warden (1.2.7) warden (1.2.7)
rack (>= 1.0) rack (>= 1.0)
webmock (3.0.1) webmock (3.1.0)
addressable (>= 2.3.6) addressable (>= 2.3.6)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff hashdiff
webpacker (2.0) webpacker (3.0.1)
activesupport (>= 4.2) activesupport (>= 4.2)
multi_json (~> 1.2) rack-proxy (>= 0.6.1)
railties (>= 4.2) railties (>= 4.2)
webpush (0.3.2) webpush (0.3.2)
hkdf (~> 0.2) hkdf (~> 0.2)
@ -531,10 +542,10 @@ DEPENDENCIES
better_errors (~> 2.1) better_errors (~> 2.1)
binding_of_caller (~> 0.7) binding_of_caller (~> 0.7)
bootsnap bootsnap
brakeman (~> 3.6) brakeman (~> 4.0)
browser browser
bullet (~> 5.5) bullet (~> 5.5)
bundler-audit (~> 0.5) bundler-audit (~> 0.6)
capistrano (~> 3.8) capistrano (~> 3.8)
capistrano-rails (~> 1.2) capistrano-rails (~> 1.2)
capistrano-rbenv (~> 2.1) capistrano-rbenv (~> 2.1)
@ -572,6 +583,7 @@ DEPENDENCIES
microformats (~> 4.0) microformats (~> 4.0)
mime-types (~> 3.1) mime-types (~> 3.1)
nokogiri (~> 1.7) nokogiri (~> 1.7)
nsa (~> 0.2)
oj (~> 3.0) oj (~> 3.0)
ostatus2 (~> 2.0) ostatus2 (~> 2.0)
ox (~> 2.5) ox (~> 2.5)
@ -611,16 +623,16 @@ DEPENDENCIES
simple_form (~> 3.4) simple_form (~> 3.4)
simplecov (~> 0.14) simplecov (~> 0.14)
sprockets-rails (~> 3.2) sprockets-rails (~> 3.2)
statsd-instrument (~> 2.1) strong_migrations
twitter-text (~> 1.14) twitter-text (~> 1.14)
tzinfo-data (~> 1.2017) tzinfo-data (~> 1.2017)
uglifier (~> 3.2) uglifier (~> 3.2)
webmock (~> 3.0) webmock (~> 3.0)
webpacker (~> 2.0) webpacker (~> 3.0)
webpush webpush
RUBY VERSION RUBY VERSION
ruby 2.4.1p111 ruby 2.4.2p198
BUNDLED WITH BUNDLED WITH
1.15.4 1.15.4

View File

@ -1,4 +1,4 @@
web: PORT=3000 bundle exec puma -C config/puma.rb web: PORT=3000 bundle exec puma -C config/puma.rb
sidekiq: PORT=3000 bundle exec sidekiq sidekiq: PORT=3000 bundle exec sidekiq
stream: PORT=4000 yarn run start stream: PORT=4000 yarn run start
webpack: ./bin/webpack-dev-server --host 0.0.0.0 webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0

View File

@ -7,46 +7,62 @@
[travis]: https://travis-ci.org/tootsuite/mastodon [travis]: https://travis-ci.org/tootsuite/mastodon
[code_climate]: https://codeclimate.com/github/tootsuite/mastodon [code_climate]: https://codeclimate.com/github/tootsuite/mastodon
Mastodon is a free, open-source social network server. A decentralized solution to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly. Mastodon is a **free, open-source social network server** based on **open web protocols** like ActivityPub and OStatus. The social focus of the project is a viable decentralized alternative to commercial social media silos that returns the control of the content distribution channels to the people. The technical focus of the project is a good user interface, a clean REST API for 3rd party apps and robust anti-abuse tools.
An alternative implementation of the GNU social project. Based on [ActivityStreams](https://en.wikipedia.org/wiki/Activity_Streams_(format)), [Webfinger](https://en.wikipedia.org/wiki/WebFinger), [WebSub](https://en.wikipedia.org/wiki/WebSub) and [Salmon](https://en.wikipedia.org/wiki/Salmon_(protocol)). Click on the screenshot below to watch a demo of the UI:
Click on the screenshot to watch a demo of the UI:
[![Screenshot](https://i.imgur.com/pG3Nnz3.jpg)][youtube_demo] [![Screenshot](https://i.imgur.com/pG3Nnz3.jpg)][youtube_demo]
[youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU [youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU
The project focus is a clean REST API and a good user interface. Ruby on Rails is used for the back-end, while React.js and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided. **Ruby on Rails** is used for the back-end, while **React.js** and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided.
If you would like, you can [support the development of this project on Patreon][patreon]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd` If you would like, you can [support the development of this project on Patreon][patreon]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd`
[patreon]: https://www.patreon.com/user?u=619786 [patreon]: https://www.patreon.com/user?u=619786
---
## Resources ## Resources
- [List of Mastodon instances](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md)
- [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com)
- [API overview](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md)
- [Frequently Asked Questions](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md) - [Frequently Asked Questions](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md)
- [Use this tool to find Twitter friends on Mastodon](https://bridge.joinmastodon.org)
- [API overview](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md)
- [List of Mastodon instances](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md)
- [List of apps](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md) - [List of apps](https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md)
- [List of sponsors](https://joinmastodon.org/sponsors)
## Features ## Features
- **Fully interoperable with GNU social and any OStatus platform** **No vendor lock-in: Fully interoperable with any conforming platform**
Whatever implements Atom feeds, ActivityStreams, Salmon, WebSub and Webfinger is part of the network
- **Real-time timeline updates** It doesn't have to be Mastodon, whatever implements ActivityPub or OStatus is part of the social network!
See the updates of people you're following appear in real-time in the UI via WebSockets
- **Federated thread resolving** **Real-time timeline updates**
If someone you follow replies to a user unknown to the server, the server fetches the full thread so you can view it without leaving the UI
- **Media attachments like images and WebM** See the updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well!
Upload and view images and WebM videos attached to the updates
- **OAuth2 and a straightforward REST API** **Federated thread resolving**
Mastodon acts as an OAuth2 provider so 3rd party apps can use the API, which is RESTful and simple
- **Background processing for long-running tasks** If someone you follow replies to a user unknown to the server, the server fetches the full thread so you can view it without leaving the UI
Mastodon tries to be as fast and responsive as possible, so all long-running tasks that can be delegated to background processing, are
- **Deployable via Docker** **Media attachments like images and short videos**
You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy
Upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos are looped - like vines!
**OAuth2 and a straightforward REST API**
Mastodon acts as an OAuth2 provider so 3rd party apps can use the API
**Fast response times**
Mastodon tries to be as fast and responsive as possible, so all long-running tasks are delegated to background processing
**Deployable via Docker**
You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy
---
## Development ## Development
@ -62,9 +78,8 @@ You can open issues for bugs you've found or features you think are missing. You
**IRC channel**: #mastodon on irc.freenode.net **IRC channel**: #mastodon on irc.freenode.net
---
## Extra credits ## Extra credits
- The [Emoji One](https://github.com/Ranks/emojione) pack has been used for the emojis The elephant friend illustrations are created by [Dopatwo](https://mastodon.social/@dopatwo)
- The error page image courtesy of [Dopatwo](https://www.youtube.com/user/dopatwo)
![Mastodon error image](https://mastodon.social/oops.png)

View File

@ -26,7 +26,10 @@ class AccountsController < ApplicationController
end end
format.json do format.json do
render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' render json: @account,
serializer: ActivityPub::ActorSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end end
end end
end end

View File

@ -9,9 +9,9 @@ class ActivityPub::InboxesController < Api::BaseController
if signed_request_account if signed_request_account
upgrade_account upgrade_account
process_payload process_payload
head 201
else
head 202 head 202
else
[signature_verification_failure_reason, 401]
end end
end end
@ -32,6 +32,7 @@ class ActivityPub::InboxesController < Api::BaseController
end end
Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed? Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed?
DeliveryFailureTracker.track_inverse_success!(signed_request_account)
end end
def process_payload def process_payload

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
class Admin::AccountModerationNotesController < Admin::BaseController
def create
@account_moderation_note = current_account.account_moderation_notes.new(resource_params)
if @account_moderation_note.save
@target_account = @account_moderation_note.target_account
redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.created_msg')
else
@account = @account_moderation_note.target_account
@moderation_notes = @account.targeted_moderation_notes.latest
render template: 'admin/accounts/show'
end
end
def destroy
@account_moderation_note = AccountModerationNote.find(params[:id])
@target_account = @account_moderation_note.target_account
@account_moderation_note.destroy
redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.destroyed_msg')
end
private
def resource_params
params.require(:account_moderation_note).permit(
:content,
:target_account_id
)
end
end

View File

@ -9,7 +9,10 @@ module Admin
@accounts = filtered_accounts.page(params[:page]) @accounts = filtered_accounts.page(params[:page])
end end
def show; end def show
@account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
@moderation_notes = @account.targeted_moderation_notes.latest
end
def subscribe def subscribe
Pubsubhubbub::SubscribeWorker.perform_async(@account.id) Pubsubhubbub::SubscribeWorker.perform_async(@account.id)

View File

@ -0,0 +1,73 @@
# frozen_string_literal: true
module Admin
class CustomEmojisController < BaseController
before_action :set_custom_emoji, except: [:index, :new, :create]
def index
@custom_emojis = filtered_custom_emojis.page(params[:page])
end
def new
@custom_emoji = CustomEmoji.new
end
def create
@custom_emoji = CustomEmoji.new(resource_params)
if @custom_emoji.save
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.created_msg')
else
render :new
end
end
def destroy
@custom_emoji.destroy
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg')
end
def copy
emoji = CustomEmoji.new(domain: nil, shortcode: @custom_emoji.shortcode, image: @custom_emoji.image)
if emoji.save
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(params[:page])
end
def enable
@custom_emoji.update!(disabled: false)
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg')
end
def disable
@custom_emoji.update!(disabled: true)
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg')
end
private
def set_custom_emoji
@custom_emoji = CustomEmoji.find(params[:id])
end
def resource_params
params.require(:custom_emoji).permit(:shortcode, :image)
end
def filtered_custom_emojis
CustomEmojiFilter.new(filter_params).results
end
def filter_params
params.permit(
:local,
:remote
)
end
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
module Admin
class EmailDomainBlocksController < BaseController
before_action :set_email_domain_block, only: [:show, :destroy]
def index
@email_domain_blocks = EmailDomainBlock.page(params[:page])
end
def new
@email_domain_block = EmailDomainBlock.new
end
def create
@email_domain_block = EmailDomainBlock.new(resource_params)
if @email_domain_block.save
redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg')
else
render :new
end
end
def destroy
@email_domain_block.destroy
redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg')
end
private
def set_email_domain_block
@email_domain_block = EmailDomainBlock.find(params[:id])
end
def resource_params
params.require(:email_domain_block).permit(:domain)
end
end
end

View File

@ -7,9 +7,11 @@ class Api::SalmonController < Api::BaseController
def update def update
if verify_payload? if verify_payload?
process_salmon process_salmon
head 201
else
head 202 head 202
elsif payload.present?
[signature_verification_failure_reason, 401]
else
head 400
end end
end end

View File

@ -7,7 +7,10 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
respond_to :json respond_to :json
def index def index
@accounts = Account.where(id: account_ids).select('id') accounts = Account.where(id: account_ids).select('id')
# .where doesn't guarantee that our results are in the same order
# we requested them, so return the "right" order to the requestor.
@accounts = accounts.index_by(&:id).values_at(*account_ids)
render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships
end end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Api::V1::Apps::CredentialsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read }
respond_to :json
def show
render json: doorkeeper_token.application, serializer: REST::StatusSerializer::ApplicationSerializer
end
end

View File

@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::AppsController < Api::BaseController class Api::V1::AppsController < Api::BaseController
respond_to :json
def create def create
@app = Doorkeeper::Application.create!(application_options) @app = Doorkeeper::Application.create!(application_options)
render json: @app, serializer: REST::ApplicationSerializer render json: @app, serializer: REST::ApplicationSerializer

View File

@ -15,19 +15,17 @@ class Api::V1::BlocksController < Api::BaseController
private private
def load_accounts def load_accounts
default_accounts.merge(paginated_blocks).to_a paginated_blocks.map(&:target_account)
end
def default_accounts
Account.includes(:blocked_by).references(:blocked_by)
end end
def paginated_blocks def paginated_blocks
Block.where(account: current_account).paginate_by_max_id( @paginated_blocks ||= Block.eager_load(:target_account)
limit_param(DEFAULT_ACCOUNTS_LIMIT), .where(account: current_account)
params[:max_id], .paginate_by_max_id(
params[:since_id] limit_param(DEFAULT_ACCOUNTS_LIMIT),
) params[:max_id],
params[:since_id]
)
end end
def insert_pagination_headers def insert_pagination_headers
@ -41,21 +39,21 @@ class Api::V1::BlocksController < Api::BaseController
end end
def prev_path def prev_path
unless @accounts.empty? unless paginated_blocks.empty?
api_v1_blocks_url pagination_params(since_id: pagination_since_id) api_v1_blocks_url pagination_params(since_id: pagination_since_id)
end end
end end
def pagination_max_id def pagination_max_id
@accounts.last.blocked_by_ids.last paginated_blocks.last.id
end end
def pagination_since_id def pagination_since_id
@accounts.first.blocked_by_ids.first paginated_blocks.first.id
end end
def records_continue? def records_continue?
@accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) paginated_blocks.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end end
def pagination_params(core_params) def pagination_params(core_params)

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Api::V1::CustomEmojisController < Api::BaseController
respond_to :json
def index
render json: CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer
end
end

View File

@ -10,7 +10,7 @@ class Api::V1::MediaController < Api::BaseController
respond_to :json respond_to :json
def create def create
@media = current_account.media_attachments.create!(file: media_params[:file]) @media = current_account.media_attachments.create!(media_params)
render json: @media, serializer: REST::MediaAttachmentSerializer render json: @media, serializer: REST::MediaAttachmentSerializer
rescue Paperclip::Errors::NotIdentifiedByImageMagickError rescue Paperclip::Errors::NotIdentifiedByImageMagickError
render json: file_type_error, status: 422 render json: file_type_error, status: 422
@ -18,10 +18,16 @@ class Api::V1::MediaController < Api::BaseController
render json: processing_error, status: 500 render json: processing_error, status: 500
end end
def update
@media = current_account.media_attachments.where(status_id: nil).find(params[:id])
@media.update!(media_params)
render json: @media, serializer: REST::MediaAttachmentSerializer
end
private private
def media_params def media_params
params.permit(:file) params.permit(:file, :description)
end end
def file_type_error def file_type_error

View File

@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base
helper_method :current_account helper_method :current_account
helper_method :current_session helper_method :current_session
helper_method :current_theme
helper_method :single_user_mode? helper_method :single_user_mode?
rescue_from ActionController::RoutingError, with: :not_found rescue_from ActionController::RoutingError, with: :not_found
@ -77,6 +78,11 @@ class ApplicationController < ActionController::Base
@current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id']) @current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id'])
end end
def current_theme
return Setting.default_settings['theme'] unless Themes.instance.names.include? current_user&.setting_theme
current_user.setting_theme
end
def cache_collection(raw, klass) def cache_collection(raw, klass)
return raw unless klass.respond_to?(:with_includes) return raw unless klass.respond_to?(:with_includes)

View File

@ -6,6 +6,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :check_enabled_registrations, only: [:new, :create] before_action :check_enabled_registrations, only: [:new, :create]
before_action :configure_sign_up_params, only: [:create] before_action :configure_sign_up_params, only: [:create]
before_action :set_sessions, only: [:edit, :update] before_action :set_sessions, only: [:edit, :update]
before_action :set_instance_presenter, only: [:new, :create, :update]
def destroy def destroy
not_found not_found
@ -39,6 +40,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
private private
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def determine_layout def determine_layout
%w(edit update).include?(action_name) ? 'admin' : 'auth' %w(edit update).include?(action_name) ? 'admin' : 'auth'
end end

View File

@ -8,6 +8,7 @@ class Auth::SessionsController < Devise::SessionsController
skip_before_action :require_no_authentication, only: [:create] skip_before_action :require_no_authentication, only: [:create]
skip_before_action :check_suspension, only: [:destroy] skip_before_action :check_suspension, only: [:destroy]
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
before_action :set_instance_presenter, only: [:new]
def create def create
super do |resource| super do |resource|
@ -84,6 +85,10 @@ class Auth::SessionsController < Devise::SessionsController
private private
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def home_paths(resource) def home_paths(resource)
paths = [about_path] paths = [about_path]
if single_user_mode? && resource.is_a?(User) if single_user_mode? && resource.is_a?(User)

View File

@ -9,10 +9,15 @@ module SignatureVerification
request.headers['Signature'].present? request.headers['Signature'].present?
end end
def signature_verification_failure_reason
return @signature_verification_failure_reason if defined?(@signature_verification_failure_reason)
end
def signed_request_account def signed_request_account
return @signed_request_account if defined?(@signed_request_account) return @signed_request_account if defined?(@signed_request_account)
unless signed_request? unless signed_request?
@signature_verification_failure_reason = 'Request not signed'
@signed_request_account = nil @signed_request_account = nil
return return
end end
@ -27,6 +32,7 @@ module SignatureVerification
end end
if incompatible_signature?(signature_params) if incompatible_signature?(signature_params)
@signature_verification_failure_reason = 'Incompatible request signature'
@signed_request_account = nil @signed_request_account = nil
return return
end end
@ -34,6 +40,7 @@ module SignatureVerification
account = account_from_key_id(signature_params['keyId']) account = account_from_key_id(signature_params['keyId'])
if account.nil? if account.nil?
@signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
@signed_request_account = nil @signed_request_account = nil
return return
end end
@ -44,7 +51,18 @@ module SignatureVerification
if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
@signed_request_account = account @signed_request_account = account
@signed_request_account @signed_request_account
elsif account.possibly_stale?
account = account.refresh!
if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
@signed_request_account = account
@signed_request_account
else
@signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}"
@signed_request_account = nil
end
else else
@signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}"
@signed_request_account = nil @signed_request_account = nil
end end
end end
@ -99,7 +117,7 @@ module SignatureVerification
ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, '')) ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, ''))
elsif !ActivityPub::TagManager.instance.local_uri?(key_id) elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account) account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account)
account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id) account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false)
account account
end end
end end

View File

@ -7,12 +7,14 @@ module UserTrackingConcern
UPDATE_SIGN_IN_HOURS = 24 UPDATE_SIGN_IN_HOURS = 24
included do included do
before_action :set_user_activity, if: %i(user_signed_in? user_needs_sign_in_update?) before_action :set_user_activity
end end
private private
def set_user_activity def set_user_activity
return unless user_needs_sign_in_update?
# Mark as signed-in today # Mark as signed-in today
current_user.update_tracked_fields!(request) current_user.update_tracked_fields!(request)
@ -21,7 +23,7 @@ module UserTrackingConcern
end end
def user_needs_sign_in_update? def user_needs_sign_in_update?
current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < UPDATE_SIGN_IN_HOURS.hours.ago user_signed_in? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < UPDATE_SIGN_IN_HOURS.hours.ago)
end end
def user_needs_feed_update? def user_needs_feed_update?

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class EmojisController < ApplicationController
before_action :set_emoji
def show
respond_to do |format|
format.json do
render json: @emoji,
serializer: ActivityPub::EmojiSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end
end
end
private
def set_emoji
@emoji = CustomEmoji.local.find(params[:id])
end
end

View File

@ -10,19 +10,39 @@ class FollowerAccountsController < ApplicationController
format.html format.html
format.json do format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' render json: collection_presenter,
serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end end
end end
end end
private private
def page_url(page)
account_followers_url(@account, page: page) unless page.nil?
end
def collection_presenter def collection_presenter
ActivityPub::CollectionPresenter.new( page = ActivityPub::CollectionPresenter.new(
id: account_followers_url(@account), id: account_followers_url(@account, page: params.fetch(:page, 1)),
type: :ordered, type: :ordered,
size: @account.followers_count, size: @account.followers_count,
items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) } items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) },
part_of: account_followers_url(@account),
next: page_url(@follows.next_page),
prev: page_url(@follows.prev_page)
) )
if params[:page].present?
page
else
ActivityPub::CollectionPresenter.new(
id: account_followers_url(@account),
type: :ordered,
size: @account.followers_count,
first: page
)
end
end end
end end

View File

@ -10,19 +10,39 @@ class FollowingAccountsController < ApplicationController
format.html format.html
format.json do format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' render json: collection_presenter,
serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end end
end end
end end
private private
def page_url(page)
account_following_index_url(@account, page: page) unless page.nil?
end
def collection_presenter def collection_presenter
ActivityPub::CollectionPresenter.new( page = ActivityPub::CollectionPresenter.new(
id: account_following_index_url(@account), id: account_following_index_url(@account, page: params.fetch(:page, 1)),
type: :ordered, type: :ordered,
size: @account.following_count, size: @account.following_count,
items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) } items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) },
part_of: account_following_index_url(@account),
next: page_url(@follows.next_page),
prev: page_url(@follows.prev_page)
) )
if params[:page].present?
page
else
ActivityPub::CollectionPresenter.new(
id: account_following_index_url(@account),
type: :ordered,
size: @account.following_count,
first: page
)
end
end end
end end

View File

@ -1,11 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class ManifestsController < ApplicationController class ManifestsController < ApplicationController
before_action :set_instance_presenter def show
render json: InstancePresenter.new, serializer: ManifestSerializer
def show; end
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end end
end end

View File

@ -18,7 +18,7 @@ class MediaProxyController < ApplicationController
def redownload! def redownload!
@media_attachment.file_remote_url = @media_attachment.remote_url @media_attachment.file_remote_url = @media_attachment.remote_url
@media_attachment.touch(:created_at) @media_attachment.created_at = Time.now.utc
@media_attachment.save! @media_attachment.save!
end end

View File

@ -9,7 +9,7 @@ class Settings::FollowerDomainsController < ApplicationController
def show def show
@account = current_account @account = current_account
@domains = current_account.followers.reorder(nil).group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10) @domains = current_account.followers.reorder('MIN(follows.id) DESC').group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10)
end end
def update def update

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
class Settings::NotificationsController < ApplicationController
layout 'admin'
before_action :authenticate_user!
def show; end
def update
user_settings.update(user_settings_params.to_h)
if current_user.save
redirect_to settings_notifications_path, notice: I18n.t('generic.changes_saved_msg')
else
render :show
end
end
private
def user_settings
UserSettingsDecorator.new(current_user)
end
def user_settings_params
params.require(:user).permit(
notification_emails: %i(follow follow_request reblog favourite mention digest),
interactions: %i(must_be_follower must_be_following)
)
end
end

View File

@ -39,8 +39,10 @@ class Settings::PreferencesController < ApplicationController
:setting_boost_modal, :setting_boost_modal,
:setting_delete_modal, :setting_delete_modal,
:setting_auto_play_gif, :setting_auto_play_gif,
:setting_reduce_motion,
:setting_system_font_ui, :setting_system_font_ui,
:setting_noindex, :setting_noindex,
:setting_theme,
notification_emails: %i(follow follow_request reblog favourite mention digest), notification_emails: %i(follow follow_request reblog favourite mention digest),
interactions: %i(must_be_follower must_be_following) interactions: %i(must_be_follower must_be_following)
) )

View File

@ -21,13 +21,19 @@ class StatusesController < ApplicationController
end end
format.json do format.json do
render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' render json: @status,
serializer: ActivityPub::NoteSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end end
end end
end end
def activity def activity
render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' render json: @status,
serializer: ActivityPub::ActivitySerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end end
def embed def embed

View File

@ -1,24 +1,40 @@
# frozen_string_literal: true # frozen_string_literal: true
class TagsController < ApplicationController class TagsController < ApplicationController
layout 'public' before_action :set_body_classes
before_action :set_instance_presenter
def show def show
@tag = Tag.find_by!(name: params[:id].downcase) @tag = Tag.find_by!(name: params[:id].downcase)
@statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
@statuses = cache_collection(@statuses, Status)
respond_to do |format| respond_to do |format|
format.html format.html do
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
@initial_state_json = serializable_resource.to_json
end
format.json do format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
@statuses = cache_collection(@statuses, Status)
render json: collection_presenter,
serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end end
end end
end end
private private
def set_body_classes
@body_classes = 'tag-body'
end
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def collection_presenter def collection_presenter
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(
id: tag_url(@tag), id: tag_url(@tag),
@ -27,4 +43,11 @@ class TagsController < ApplicationController
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
) )
end end
def initial_state_params
{
settings: {},
token: current_session&.token,
}
end
end end

View File

@ -0,0 +1,4 @@
# frozen_string_literal: true
module Admin::AccountModerationNotesHelper
end

View File

@ -1,24 +0,0 @@
# frozen_string_literal: true
module EmojiHelper
def emojify(text)
return text if text.blank?
text.gsub(emoji_pattern) do |match|
emoji = Emoji.instance.unicode($1) # rubocop:disable Style/PerlBackrefs
if emoji
emoji
else
match
end
end
end
def emoji_pattern
@emoji_pattern ||=
/(?<=[^[:alnum:]:]|\n|^)
(#{Emoji.instance.names.map { |name| Regexp.escape(name) }.join('|')})
(?=[^[:alnum:]:]|$)/x
end
end

View File

@ -22,7 +22,18 @@ module JsonLdHelper
graph.dump(:normalize) graph.dump(:normalize)
end end
def fetch_resource(uri) def fetch_resource(uri, id)
unless id
json = fetch_resource_without_id_validation(uri)
return unless json
uri = json['id']
end
json = fetch_resource_without_id_validation(uri)
json.present? && json['id'] == uri ? json : nil
end
def fetch_resource_without_id_validation(uri)
response = build_request(uri).perform response = build_request(uri).perform
return if response.code != 200 return if response.code != 200
body_to_json(response.to_s) body_to_json(response.to_s)

View File

@ -27,6 +27,7 @@ module SettingsHelper
pt: 'Português', pt: 'Português',
'pt-BR': 'Português do Brasil', 'pt-BR': 'Português do Brasil',
ru: 'Русский', ru: 'Русский',
sv: 'Svenska',
th: 'ภาษาไทย', th: 'ภาษาไทย',
tr: 'Türkçe', tr: 'Türkçe',
uk: 'Українська', uk: 'Українська',

View File

@ -122,7 +122,7 @@ export function unfollowAccount(id) {
dispatch(unfollowAccountRequest(id)); dispatch(unfollowAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => { api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => {
dispatch(unfollowAccountSuccess(response.data)); dispatch(unfollowAccountSuccess(response.data, getState().get('statuses')));
}).catch(error => { }).catch(error => {
dispatch(unfollowAccountFail(error)); dispatch(unfollowAccountFail(error));
}); });
@ -157,10 +157,11 @@ export function unfollowAccountRequest(id) {
}; };
}; };
export function unfollowAccountSuccess(relationship) { export function unfollowAccountSuccess(relationship, statuses) {
return { return {
type: ACCOUNT_UNFOLLOW_SUCCESS, type: ACCOUNT_UNFOLLOW_SUCCESS,
relationship, relationship,
statuses,
}; };
}; };

View File

@ -1,4 +1,7 @@
import api from '../api'; import api from '../api';
import { throttle } from 'lodash';
import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
import { useEmoji } from './emojis';
import { import {
updateTimeline, updateTimeline,
@ -14,6 +17,7 @@ export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
export const COMPOSE_REPLY = 'COMPOSE_REPLY'; export const COMPOSE_REPLY = 'COMPOSE_REPLY';
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
export const COMPOSE_MENTION = 'COMPOSE_MENTION'; export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_RESET = 'COMPOSE_RESET';
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
@ -36,6 +40,10 @@ export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL';
export function changeCompose(text) { export function changeCompose(text) {
return { return {
type: COMPOSE_CHANGE, type: COMPOSE_CHANGE,
@ -62,6 +70,12 @@ export function cancelReplyCompose() {
}; };
}; };
export function resetCompose() {
return {
type: COMPOSE_RESET,
};
};
export function mentionCompose(account, router) { export function mentionCompose(account, router) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ dispatch({
@ -164,6 +178,40 @@ export function uploadCompose(files) {
}; };
}; };
export function changeUploadCompose(id, description) {
return (dispatch, getState) => {
dispatch(changeUploadComposeRequest());
api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
dispatch(changeUploadComposeSuccess(response.data));
}).catch(error => {
dispatch(changeUploadComposeFail(id, error));
});
};
};
export function changeUploadComposeRequest() {
return {
type: COMPOSE_UPLOAD_CHANGE_REQUEST,
skipLoading: true,
};
};
export function changeUploadComposeSuccess(media) {
return {
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
media: media,
skipLoading: true,
};
};
export function changeUploadComposeFail(error) {
return {
type: COMPOSE_UPLOAD_CHANGE_FAIL,
error: error,
skipLoading: true,
};
};
export function uploadComposeRequest() { export function uploadComposeRequest() {
return { return {
type: COMPOSE_UPLOAD_REQUEST, type: COMPOSE_UPLOAD_REQUEST,
@ -208,21 +256,42 @@ export function clearComposeSuggestions() {
}; };
}; };
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
api(getState).get('/api/v1/accounts/search', {
params: {
q: token.slice(1),
resolve: false,
limit: 4,
},
}).then(response => {
dispatch(readyComposeSuggestionsAccounts(token, response.data));
});
}, 200, { leading: true, trailing: true });
const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
dispatch(readyComposeSuggestionsEmojis(token, results));
};
export function fetchComposeSuggestions(token) { export function fetchComposeSuggestions(token) {
return (dispatch, getState) => { return (dispatch, getState) => {
api(getState).get('/api/v1/accounts/search', { if (token[0] === ':') {
params: { fetchComposeSuggestionsEmojis(dispatch, getState, token);
q: token, } else {
resolve: false, fetchComposeSuggestionsAccounts(dispatch, getState, token);
limit: 4, }
},
}).then(response => {
dispatch(readyComposeSuggestions(token, response.data));
});
}; };
}; };
export function readyComposeSuggestions(token, accounts) { export function readyComposeSuggestionsEmojis(token, emojis) {
return {
type: COMPOSE_SUGGESTIONS_READY,
token,
emojis,
};
};
export function readyComposeSuggestionsAccounts(token, accounts) {
return { return {
type: COMPOSE_SUGGESTIONS_READY, type: COMPOSE_SUGGESTIONS_READY,
token, token,
@ -230,13 +299,23 @@ export function readyComposeSuggestions(token, accounts) {
}; };
}; };
export function selectComposeSuggestion(position, token, accountId) { export function selectComposeSuggestion(position, token, suggestion) {
return (dispatch, getState) => { return (dispatch, getState) => {
const completion = getState().getIn(['accounts', accountId, 'acct']); let completion, startPosition;
if (typeof suggestion === 'object' && suggestion.id) {
completion = suggestion.native || suggestion.colons;
startPosition = position - 1;
dispatch(useEmoji(suggestion));
} else {
completion = getState().getIn(['accounts', suggestion, 'acct']);
startPosition = position;
}
dispatch({ dispatch({
type: COMPOSE_SUGGESTION_SELECT, type: COMPOSE_SUGGESTION_SELECT,
position, position: startPosition,
token, token,
completion, completion,
}); });

View File

@ -0,0 +1,14 @@
import { saveSettings } from './settings';
export const EMOJI_USE = 'EMOJI_USE';
export function useEmoji(emoji) {
return dispatch => {
dispatch({
type: EMOJI_USE,
emoji,
});
dispatch(saveSettings());
};
};

View File

@ -31,6 +31,7 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
const unescapeHTML = (html) => { const unescapeHTML = (html) => {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
html = html.replace(/<br \/>|<br>|\n/, ' ');
wrapper.innerHTML = html; wrapper.innerHTML = html;
return wrapper.textContent; return wrapper.textContent;
}; };

View File

@ -1,6 +1,8 @@
import axios from 'axios'; import axios from 'axios';
import { debounce } from 'lodash';
export const SETTING_CHANGE = 'SETTING_CHANGE'; export const SETTING_CHANGE = 'SETTING_CHANGE';
export const SETTING_SAVE = 'SETTING_SAVE';
export function changeSetting(key, value) { export function changeSetting(key, value) {
return dispatch => { return dispatch => {
@ -14,10 +16,16 @@ export function changeSetting(key, value) {
}; };
}; };
const debouncedSave = debounce((dispatch, getState) => {
if (getState().getIn(['settings', 'saved'])) {
return;
}
const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS();
axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
}, 5000, { trailing: true });
export function saveSettings() { export function saveSettings() {
return (_, getState) => { return (dispatch, getState) => debouncedSave(dispatch, getState);
axios.put('/api/web/settings', {
data: getState().get('settings').toJS(),
});
};
}; };

View File

@ -5,8 +5,7 @@ export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
const convertState = rawState => const convertState = rawState =>
fromJS(rawState, (k, v) => fromJS(rawState, (k, v) =>
Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x => Iterable.isIndexed(v) ? v.toList() : v.toMap());
Number.isNaN(x * 1) ? x : x * 1));
export function hydrateStore(rawState) { export function hydrateStore(rawState) {
const state = convertState(rawState); const state = convertState(rawState);

View File

@ -17,6 +17,8 @@ export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE';
export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
return { return {
type: TIMELINE_REFRESH_SUCCESS, type: TIMELINE_REFRESH_SUCCESS,
@ -30,6 +32,16 @@ export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
export function updateTimeline(timeline, status) { export function updateTimeline(timeline, status) {
return (dispatch, getState) => { return (dispatch, getState) => {
const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : []; const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
const parents = [];
if (status.in_reply_to_id) {
let parent = getState().getIn(['statuses', status.in_reply_to_id]);
while (parent && parent.get('in_reply_to_id')) {
parents.push(parent.get('id'));
parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]);
}
}
dispatch({ dispatch({
type: TIMELINE_UPDATE, type: TIMELINE_UPDATE,
@ -37,6 +49,14 @@ export function updateTimeline(timeline, status) {
status, status,
references, references,
}); });
if (parents.length > 0) {
dispatch({
type: TIMELINE_CONTEXT_UPDATE,
status,
references: parents,
});
}
}; };
}; };

View File

@ -1,5 +1,5 @@
import 'intl'; import 'intl';
import 'intl/locale-data/jsonp/en.js'; import 'intl/locale-data/jsonp/en';
import 'es6-symbol/implement'; import 'es6-symbol/implement';
import includes from 'array-includes'; import includes from 'array-includes';
import assign from 'object-assign'; import assign from 'object-assign';

View File

@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Avatar /> Autoplay renders a animated avatar 1`] = `
<div
className="account__avatar"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
Object {
"backgroundImage": "url(/animated/alice.gif)",
"backgroundSize": "100px 100px",
"height": "100px",
"width": "100px",
}
}
/>
`;
exports[`<Avatar /> Still renders a still avatar 1`] = `
<div
className="account__avatar"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
Object {
"backgroundImage": "url(/static/alice.jpg)",
"backgroundSize": "100px 100px",
"height": "100px",
"width": "100px",
}
}
/>
`;

View File

@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<AvatarOverlay renders a overlay avatar 1`] = `
<div
className="account__avatar-overlay"
>
<div
className="account__avatar-overlay-base"
style={
Object {
"backgroundImage": "url(/static/alice.jpg)",
}
}
/>
<div
className="account__avatar-overlay-overlay"
style={
Object {
"backgroundImage": "url(/static/eve.jpg)",
}
}
/>
</div>
`;

View File

@ -0,0 +1,114 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Button /> adds class "button-secondary" if props.secondary given 1`] = `
<button
className="button button-secondary"
disabled={undefined}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
/>
`;
exports[`<Button /> renders a button element 1`] = `
<button
className="button"
disabled={undefined}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
/>
`;
exports[`<Button /> renders a disabled attribute if props.disabled given 1`] = `
<button
className="button"
disabled={true}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
/>
`;
exports[`<Button /> renders class="button--block" if props.block given 1`] = `
<button
className="button button--block"
disabled={undefined}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
/>
`;
exports[`<Button /> renders the children 1`] = `
<button
className="button"
disabled={undefined}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
>
<p>
children
</p>
</button>
`;
exports[`<Button /> renders the given text 1`] = `
<button
className="button"
disabled={undefined}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
>
foo
</button>
`;
exports[`<Button /> renders the props.text instead of children 1`] = `
<button
className="button"
disabled={undefined}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
>
foo
</button>
`;

View File

@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DisplayName /> renders display name + account name 1`] = `
<span
className="display-name"
>
<strong
className="display-name__html"
dangerouslySetInnerHTML={
Object {
"__html": "<p>Foo</p>",
}
}
/>
<span
className="display-name__account"
>
@
bar@baz
</span>
</span>
`;

View File

@ -0,0 +1,36 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS } from 'immutable';
import Avatar from '../avatar';
describe('<Avatar />', () => {
const account = fromJS({
username: 'alice',
acct: 'alice',
display_name: 'Alice',
avatar: '/animated/alice.gif',
avatar_static: '/static/alice.jpg',
});
const size = 100;
describe('Autoplay', () => {
it('renders a animated avatar', () => {
const component = renderer.create(<Avatar account={account} animate size={size} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});
describe('Still', () => {
it('renders a still avatar', () => {
const component = renderer.create(<Avatar account={account} size={size} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});
// TODO add autoplay test if possible
});

View File

@ -0,0 +1,29 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS } from 'immutable';
import AvatarOverlay from '../avatar_overlay';
describe('<AvatarOverlay', () => {
const account = fromJS({
username: 'alice',
acct: 'alice',
display_name: 'Alice',
avatar: '/animated/alice.gif',
avatar_static: '/static/alice.jpg',
});
const friend = fromJS({
username: 'eve',
acct: 'eve@blackhat.lair',
display_name: 'Evelyn',
avatar: '/animated/eve.gif',
avatar_static: '/static/eve.jpg',
});
it('renders a overlay avatar', () => {
const component = renderer.create(<AvatarOverlay account={account} friend={friend} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@ -0,0 +1,75 @@
import { shallow } from 'enzyme';
import React from 'react';
import renderer from 'react-test-renderer';
import Button from '../button';
describe('<Button />', () => {
it('renders a button element', () => {
const component = renderer.create(<Button />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('renders the given text', () => {
const text = 'foo';
const component = renderer.create(<Button text={text} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('handles click events using the given handler', () => {
const handler = jest.fn();
const button = shallow(<Button onClick={handler} />);
button.find('button').simulate('click');
expect(handler.mock.calls.length).toEqual(1);
});
it('does not handle click events if props.disabled given', () => {
const handler = jest.fn();
const button = shallow(<Button onClick={handler} disabled />);
button.find('button').simulate('click');
expect(handler.mock.calls.length).toEqual(0);
});
it('renders a disabled attribute if props.disabled given', () => {
const component = renderer.create(<Button disabled />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('renders the children', () => {
const children = <p>children</p>;
const component = renderer.create(<Button>{children}</Button>);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('renders the props.text instead of children', () => {
const text = 'foo';
const children = <p>children</p>;
const component = renderer.create(<Button text={text}>{children}</Button>);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('renders class="button--block" if props.block given', () => {
const component = renderer.create(<Button block />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('adds class "button-secondary" if props.secondary given', () => {
const component = renderer.create(<Button secondary />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@ -0,0 +1,18 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS } from 'immutable';
import DisplayName from '../display_name';
describe('<DisplayName />', () => {
it('renders display name + account name', () => {
const account = fromJS({
username: 'bar',
acct: 'bar@baz',
display_name_html: '<p>Foo</p>',
});
const component = renderer.create(<DisplayName account={account} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@ -21,7 +21,7 @@ export default class Account extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.map.isRequired,
me: PropTypes.number.isRequired, me: PropTypes.string.isRequired,
onFollow: PropTypes.func.isRequired, onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired,

View File

@ -0,0 +1,42 @@
import React from 'react';
import PropTypes from 'prop-types';
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
const assetHost = process.env.CDN_HOST || '';
export default class AutosuggestEmoji extends React.PureComponent {
static propTypes = {
emoji: PropTypes.object.isRequired,
};
render () {
const { emoji } = this.props;
let url;
if (emoji.custom) {
url = emoji.imageUrl;
} else {
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
if (!mapping) {
return null;
}
url = `${assetHost}/emoji/${mapping.filename}.svg`;
}
return (
<div className='autosuggest-emoji'>
<img
className='emojione'
src={url}
alt={emoji.native || emoji.colons}
/>
{emoji.colons}
</div>
);
}
}

View File

@ -1,10 +1,12 @@
import React from 'react'; import React from 'react';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { isRtl } from '../rtl'; import { isRtl } from '../rtl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import classNames from 'classnames';
const textAtCursorMatchesToken = (str, caretPosition) => { const textAtCursorMatchesToken = (str, caretPosition) => {
let word; let word;
@ -18,11 +20,11 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
word = str.slice(left, right + caretPosition); word = str.slice(left, right + caretPosition);
} }
if (!word || word.trim().length < 2 || word[0] !== '@') { if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) {
return [null, null]; return [null, null];
} }
word = word.trim().toLowerCase().slice(1); word = word.trim().toLowerCase();
if (word.length > 0) { if (word.length > 0) {
return [left + 1, word]; return [left + 1, word];
@ -123,12 +125,22 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
this.props.onKeyDown(e); this.props.onKeyDown(e);
} }
onKeyUp = e => {
if (e.key === 'Escape' && this.state.suggestionsHidden) {
document.querySelector('.ui').parentElement.focus();
}
if (this.props.onKeyUp) {
this.props.onKeyUp(e);
}
}
onBlur = () => { onBlur = () => {
this.setState({ suggestionsHidden: true }); this.setState({ suggestionsHidden: true });
} }
onSuggestionClick = (e) => { onSuggestionClick = (e) => {
const suggestion = Number(e.currentTarget.getAttribute('data-index')); const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
e.preventDefault(); e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.textarea.focus(); this.textarea.focus();
@ -151,9 +163,28 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
} }
} }
renderSuggestion = (suggestion, i) => {
const { selectedSuggestion } = this.state;
let inner, key;
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
} else {
inner = <AutosuggestAccountContainer id={suggestion} />;
key = suggestion;
}
return (
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
{inner}
</div>
);
}
render () { render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props; const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
const { suggestionsHidden, selectedSuggestion } = this.state; const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' }; const style = { direction: 'ltr' };
if (isRtl(value)) { if (isRtl(value)) {
@ -164,6 +195,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
<div className='autosuggest-textarea'> <div className='autosuggest-textarea'>
<label> <label>
<span style={{ display: 'none' }}>{placeholder}</span> <span style={{ display: 'none' }}>{placeholder}</span>
<Textarea <Textarea
inputRef={this.setTextarea} inputRef={this.setTextarea}
className='autosuggest-textarea__textarea' className='autosuggest-textarea__textarea'
@ -173,7 +205,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
value={value} value={value}
onChange={this.onChange} onChange={this.onChange}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp} onKeyUp={this.onKeyUp}
onBlur={this.onBlur} onBlur={this.onBlur}
onPaste={this.onPaste} onPaste={this.onPaste}
style={style} style={style}
@ -181,18 +213,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
</label> </label>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map((suggestion, i) => ( {suggestions.map(this.renderSuggestion)}
<div
role='button'
tabIndex='0'
key={suggestion}
data-index={suggestion}
className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`}
onMouseDown={this.onSuggestionClick}
>
<AutosuggestAccountContainer id={suggestion} />
</div>
))}
</div> </div>
</div> </div>
); );

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import Motion from 'react-motion/lib/Motion'; import Motion from '../features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';

View File

@ -135,7 +135,7 @@ export default class ColumnHeader extends React.PureComponent {
return ( return (
<div className={wrapperClassName}> <div className={wrapperClassName}>
<h1 tabIndex={focusable && '0'} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}> <h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}>
<i className={`fa fa-fw fa-${icon} column-header__icon`} /> <i className={`fa fa-fw fa-${icon} column-header__icon`} />
{title} {title}
@ -145,7 +145,7 @@ export default class ColumnHeader extends React.PureComponent {
</div> </div>
</h1> </h1>
<div className={collapsibleClassName} tabIndex={collapsed && -1} onTransitionEnd={this.handleTransitionEnd}> <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
<div className='column-header__collapsible-inner'> <div className='column-header__collapsible-inner'>
{(!collapsed || animating) && collapsedContent} {(!collapsed || animating) && collapsedContent}
</div> </div>

View File

@ -1,53 +1,59 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import IconButton from './icon_button';
import Overlay from 'react-overlays/lib/Overlay';
import Motion from '../features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import detectPassiveEvents from 'detect-passive-events';
export default class DropdownMenu extends React.PureComponent { const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
class DropdownMenu extends React.PureComponent {
static contextTypes = { static contextTypes = {
router: PropTypes.object, router: PropTypes.object,
}; };
static propTypes = { static propTypes = {
isUserTouching: PropTypes.func,
isModalOpen: PropTypes.bool.isRequired,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
icon: PropTypes.string.isRequired,
items: PropTypes.array.isRequired, items: PropTypes.array.isRequired,
size: PropTypes.number.isRequired, onClose: PropTypes.func.isRequired,
direction: PropTypes.string, style: PropTypes.object,
status: ImmutablePropTypes.map, placement: PropTypes.string,
ariaLabel: PropTypes.string, arrowOffsetLeft: PropTypes.string,
disabled: PropTypes.bool, arrowOffsetTop: PropTypes.string,
}; };
static defaultProps = { static defaultProps = {
ariaLabel: 'Menu', style: {},
isModalOpen: false, placement: 'bottom',
isUserTouching: () => false,
}; };
state = { handleDocumentClick = e => {
direction: 'left', if (this.node && !this.node.contains(e.target)) {
expanded: false, this.props.onClose();
}; }
setRef = (c) => {
this.dropdown = c;
} }
handleClick = (e) => { componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
handleClick = e => {
const i = Number(e.currentTarget.getAttribute('data-index')); const i = Number(e.currentTarget.getAttribute('data-index'));
const { action, to } = this.props.items[i]; const { action, to } = this.props.items[i];
if (this.props.isModalOpen) { this.props.onClose();
this.props.onModalClose();
}
// Don't call e.preventDefault() when the item uses 'href' property.
// ex. "Edit profile" on the account action bar
if (typeof action === 'function') { if (typeof action === 'function') {
e.preventDefault(); e.preventDefault();
@ -56,46 +62,18 @@ export default class DropdownMenu extends React.PureComponent {
e.preventDefault(); e.preventDefault();
this.context.router.history.push(to); this.context.router.history.push(to);
} }
this.dropdown.hide();
} }
handleShow = () => { renderItem (option, i) {
if (this.props.isUserTouching()) { if (option === null) {
this.props.onModalOpen({ return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
status: this.props.status,
actions: this.props.items,
onClick: this.handleClick,
});
} else {
this.setState({ expanded: true });
}
}
handleHide = () => this.setState({ expanded: false })
handleToggle = (e) => {
if (e.key === 'Enter') {
if (this.props.isUserTouching()) {
this.handleShow();
} else {
this.setState({ expanded: !this.state.expanded });
}
} else if (e.key === 'Escape') {
this.setState({ expanded: false });
}
}
renderItem = (item, i) => {
if (item === null) {
return <li key={`sep-${i}`} className='dropdown__sep' />;
} }
const { text, href = '#' } = item; const { text, href = '#' } = option;
return ( return (
<li className='dropdown__content-list-item' key={`${text}-${i}`}> <li className='dropdown-menu__item' key={`${text}-${i}`}>
<a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i} className='dropdown__content-list-link'> <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i}>
{text} {text}
</a> </a>
</li> </li>
@ -103,43 +81,130 @@ export default class DropdownMenu extends React.PureComponent {
} }
render () { render () {
const { icon, items, size, direction, ariaLabel, disabled } = this.props; const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
const { expanded } = this.state;
const isUserTouching = this.props.isUserTouching();
const directionClass = (direction === 'left') ? 'dropdown__left' : 'dropdown__right';
const iconStyle = { fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` };
const iconClassname = `fa fa-fw fa-${icon} dropdown__icon`;
if (disabled) {
return (
<div className='icon-button disabled' style={iconStyle} aria-label={ariaLabel}>
<i className={iconClassname} aria-hidden />
</div>
);
}
const dropdownItems = expanded && (
<ul role='group' className='dropdown__content-list' onClick={this.handleHide}>
{items.map(this.renderItem)}
</ul>
);
// No need to render the actual dropdown if we use the modal. If we
// don't render anything <Dropdow /> breaks, so we just put an empty div.
const dropdownContent = !isUserTouching ? (
<DropdownContent className={directionClass} >
{dropdownItems}
</DropdownContent>
) : <div />;
return ( return (
<Dropdown ref={this.setRef} active={isUserTouching ? false : expanded} onShow={this.handleShow} onHide={this.handleHide}> <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
<DropdownTrigger className='icon-button' style={iconStyle} role='button' aria-expanded={expanded} onKeyDown={this.handleToggle} tabIndex='0' aria-label={ariaLabel}> {({ opacity, scaleX, scaleY }) => (
<i className={iconClassname} aria-hidden /> <div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
</DropdownTrigger> <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
{dropdownContent} <ul>
</Dropdown> {items.map((option, i) => this.renderItem(option, i))}
</ul>
</div>
)}
</Motion>
);
}
}
export default class Dropdown extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
icon: PropTypes.string.isRequired,
items: PropTypes.array.isRequired,
size: PropTypes.number.isRequired,
ariaLabel: PropTypes.string,
disabled: PropTypes.bool,
status: ImmutablePropTypes.map,
isUserTouching: PropTypes.func,
isModalOpen: PropTypes.bool.isRequired,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
};
static defaultProps = {
ariaLabel: 'Menu',
};
state = {
expanded: false,
};
handleClick = () => {
if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) {
const { status, items } = this.props;
this.props.onModalOpen({
status,
actions: items,
onClick: this.handleItemClick,
});
return;
}
this.setState({ expanded: !this.state.expanded });
}
handleClose = () => {
if (this.props.onModalClose) {
this.props.onModalClose();
}
this.setState({ expanded: false });
}
handleKeyDown = e => {
switch(e.key) {
case 'Enter':
this.handleClick();
break;
case 'Escape':
this.handleClose();
break;
}
}
handleItemClick = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const { action, to } = this.props.items[i];
this.handleClose();
if (typeof action === 'function') {
e.preventDefault();
action();
} else if (to) {
e.preventDefault();
this.context.router.history.push(to);
}
}
setTargetRef = c => {
this.target = c;
}
findTarget = () => {
return this.target;
}
render () {
const { icon, items, size, ariaLabel, disabled } = this.props;
const { expanded } = this.state;
return (
<div onKeyDown={this.handleKeyDown}>
<IconButton
icon={icon}
title={ariaLabel}
active={expanded}
disabled={disabled}
size={size}
ref={this.setTargetRef}
onClick={this.handleClick}
/>
<Overlay show={expanded} placement='bottom' target={this.findTarget}>
<DropdownMenu items={items} onClose={this.handleClose} />
</Overlay>
</div>
); );
} }

View File

@ -5,6 +5,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
static propTypes = { static propTypes = {
src: PropTypes.string.isRequired, src: PropTypes.string.isRequired,
alt: PropTypes.string,
width: PropTypes.number, width: PropTypes.number,
height: PropTypes.number, height: PropTypes.number,
time: PropTypes.number, time: PropTypes.number,
@ -31,15 +32,20 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
} }
render () { render () {
const { src, muted, controls, alt } = this.props;
return ( return (
<div className='extended-video-player'> <div className='extended-video-player'>
<video <video
ref={this.setRef} ref={this.setRef}
src={this.props.src} src={src}
autoPlay autoPlay
muted={this.props.muted} role='button'
controls={this.props.controls} tabIndex='0'
loop={!this.props.controls} aria-label={alt}
muted={muted}
controls={controls}
loop={!controls}
/> />
</div> </div>
); );

View File

@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import Motion from 'react-motion/lib/Motion'; import Motion from '../features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames';
export default class IconButton extends React.PureComponent { export default class IconButton extends React.PureComponent {
@ -50,42 +51,41 @@ export default class IconButton extends React.PureComponent {
...(this.props.active ? this.props.activeStyle : {}), ...(this.props.active ? this.props.activeStyle : {}),
}; };
const classes = ['icon-button']; const {
active,
animate,
className,
disabled,
expanded,
icon,
inverted,
overlay,
pressed,
tabIndex,
title,
} = this.props;
if (this.props.active) { const classes = classNames(className, 'icon-button', {
classes.push('active'); active,
} disabled,
inverted,
if (this.props.disabled) { overlayed: overlay,
classes.push('disabled'); });
}
if (this.props.inverted) {
classes.push('inverted');
}
if (this.props.overlay) {
classes.push('overlayed');
}
if (this.props.className) {
classes.push(this.props.className);
}
return ( return (
<Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}> <Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
{({ rotate }) => {({ rotate }) =>
<button <button
aria-label={this.props.title} aria-label={title}
aria-pressed={this.props.pressed} aria-pressed={pressed}
aria-expanded={this.props.expanded} aria-expanded={expanded}
title={this.props.title} title={title}
className={classes.join(' ')} className={classes}
onClick={this.handleClick} onClick={this.handleClick}
style={style} style={style}
tabIndex={this.props.tabIndex} tabIndex={tabIndex}
> >
<i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' /> <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
</button> </button>
} }
</Motion> </Motion>

View File

@ -1,10 +1,15 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
import { is } from 'immutable';
export default class IntersectionObserverArticle extends ImmutablePureComponent { // Diff these props in the "rendered" state
const updateOnPropsForRendered = ['id', 'index', 'listLength'];
// Diff these props in the "unrendered" state
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
export default class IntersectionObserverArticle extends React.Component {
static propTypes = { static propTypes = {
intersectionObserverWrapper: PropTypes.object.isRequired, intersectionObserverWrapper: PropTypes.object.isRequired,
@ -22,18 +27,15 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent
} }
shouldComponentUpdate (nextProps, nextState) { shouldComponentUpdate (nextProps, nextState) {
if (!nextState.isIntersecting && nextState.isHidden) { const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
// It's only if we're not intersecting (i.e. offscreen) and isHidden is true const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
// that either "isIntersecting" or "isHidden" matter, and then they're if (!!isUnrendered !== !!willBeUnrendered) {
// the only things that matter (and updated ARIA attributes). // If we're going from rendered to unrendered (or vice versa) then update
return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength;
} else if (nextState.isIntersecting && !this.state.isIntersecting) {
// If we're going from a non-intersecting state to an intersecting state,
// (i.e. offscreen to onscreen), then we definitely need to re-render
return true; return true;
} }
// Otherwise, diff based on "updateOnProps" and "updateOnStates" // Otherwise, diff based on props
return super.shouldComponentUpdate(nextProps, nextState); const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
} }
componentDidMount () { componentDidMount () {
@ -56,26 +58,31 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent
} }
handleIntersection = (entry) => { handleIntersection = (entry) => {
const { onHeightChange, saveHeightKey, id } = this.props; this.entry = entry;
if (this.node && this.node.children.length !== 0) { scheduleIdleTask(this.calculateHeight);
// save the height of the fully-rendered element this.setState(this.updateStateAfterIntersection);
this.height = getRectFromEntry(entry).height; }
if (onHeightChange && saveHeightKey) { updateStateAfterIntersection = (prevState) => {
onHeightChange(saveHeightKey, id, this.height); if (prevState.isIntersecting && !this.entry.isIntersecting) {
} scheduleIdleTask(this.hideIfNotIntersecting);
} }
return {
isIntersecting: this.entry.isIntersecting,
isHidden: false,
};
}
this.setState((prevState) => { calculateHeight = () => {
if (prevState.isIntersecting && !entry.isIntersecting) { const { onHeightChange, saveHeightKey, id } = this.props;
scheduleIdleTask(this.hideIfNotIntersecting); // save the height of the fully-rendered element (this is expensive
} // on Chrome, where we need to fall back to getBoundingClientRect)
return { this.height = getRectFromEntry(this.entry).height;
isIntersecting: entry.isIntersecting,
isHidden: false, if (onHeightChange && saveHeightKey) {
}; onHeightChange(saveHeightKey, id, this.height);
}); }
} }
hideIfNotIntersecting = () => { hideIfNotIntersecting = () => {

View File

@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { is } from 'immutable';
import IconButton from './icon_button'; import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../is_mobile'; import { isIOS } from '../is_mobile';
import classNames from 'classnames';
const messages = defineMessages({ const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
@ -17,6 +19,7 @@ class Item extends React.PureComponent {
static propTypes = { static propTypes = {
attachment: ImmutablePropTypes.map.isRequired, attachment: ImmutablePropTypes.map.isRequired,
standalone: PropTypes.bool,
index: PropTypes.number.isRequired, index: PropTypes.number.isRequired,
size: PropTypes.number.isRequired, size: PropTypes.number.isRequired,
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
@ -25,6 +28,9 @@ class Item extends React.PureComponent {
static defaultProps = { static defaultProps = {
autoPlayGif: false, autoPlayGif: false,
standalone: false,
index: 0,
size: 1,
}; };
handleMouseEnter = (e) => { handleMouseEnter = (e) => {
@ -57,7 +63,7 @@ class Item extends React.PureComponent {
} }
render () { render () {
const { attachment, index, size } = this.props; const { attachment, index, size, standalone } = this.props;
let width = 50; let width = 50;
let height = 100; let height = 100;
@ -129,16 +135,17 @@ class Item extends React.PureComponent {
onClick={this.handleClick} onClick={this.handleClick}
target='_blank' target='_blank'
> >
<img src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' /> <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} />
</a> </a>
); );
} else if (attachment.get('type') === 'gifv') { } else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && this.props.autoPlayGif; const autoPlay = !isIOS() && this.props.autoPlayGif;
thumbnail = ( thumbnail = (
<div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}> <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
<video <video
className='media-gallery__item-gifv-thumbnail' className='media-gallery__item-gifv-thumbnail'
aria-label={attachment.get('description')}
role='application' role='application'
src={attachment.get('url')} src={attachment.get('url')}
onClick={this.handleClick} onClick={this.handleClick}
@ -155,7 +162,7 @@ class Item extends React.PureComponent {
} }
return ( return (
<div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
{thumbnail} {thumbnail}
</div> </div>
); );
@ -168,7 +175,9 @@ export default class MediaGallery extends React.PureComponent {
static propTypes = { static propTypes = {
sensitive: PropTypes.bool, sensitive: PropTypes.bool,
standalone: PropTypes.bool,
media: ImmutablePropTypes.list.isRequired, media: ImmutablePropTypes.list.isRequired,
size: PropTypes.object,
height: PropTypes.number.isRequired, height: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired, onOpenMedia: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
@ -177,6 +186,7 @@ export default class MediaGallery extends React.PureComponent {
static defaultProps = { static defaultProps = {
autoPlayGif: false, autoPlayGif: false,
standalone: false,
}; };
state = { state = {
@ -184,7 +194,7 @@ export default class MediaGallery extends React.PureComponent {
}; };
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
if (nextProps.sensitive !== this.props.sensitive) { if (!is(nextProps.media, this.props.media)) {
this.setState({ visible: !nextProps.sensitive }); this.setState({ visible: !nextProps.sensitive });
} }
} }
@ -197,12 +207,42 @@ export default class MediaGallery extends React.PureComponent {
this.props.onOpenMedia(this.props.media, index); this.props.onOpenMedia(this.props.media, index);
} }
handleRef = (node) => {
if (node && this.isStandaloneEligible()) {
// offsetWidth triggers a layout, so only calculate when we need to
this.setState({
width: node.offsetWidth,
});
}
}
isStandaloneEligible() {
const { media, standalone } = this.props;
return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
}
render () { render () {
const { media, intl, sensitive } = this.props; const { media, intl, sensitive, height } = this.props;
const { width, visible } = this.state;
let children; let children;
if (!this.state.visible) { const style = {};
if (this.isStandaloneEligible()) {
if (!visible && width) {
// only need to forcibly set the height in "sensitive" mode
style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
} else {
// layout automatically, using image's natural aspect ratio
style.height = '';
}
} else {
// crop the image
style.height = height;
}
if (!visible) {
let warning; let warning;
if (sensitive) { if (sensitive) {
@ -212,20 +252,25 @@ export default class MediaGallery extends React.PureComponent {
} }
children = ( children = (
<button className='media-spoiler' onClick={this.handleOpen}> <button className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
<span className='media-spoiler__warning'>{warning}</span> <span className='media-spoiler__warning'>{warning}</span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</button> </button>
); );
} else { } else {
const size = media.take(4).size; const size = media.take(4).size;
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} autoPlayGif={this.props.autoPlayGif} />;
} else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
}
} }
return ( return (
<div className='media-gallery' style={{ height: `${this.props.height}px` }}> <div className='media-gallery' style={style}>
<div className={`spoiler-button ${this.state.visible ? 'spoiler-button--visible' : ''}`}> <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
</div> </div>
{children} {children}

View File

@ -1,7 +1,15 @@
import React from 'react'; import React from 'react';
import { injectIntl, FormattedRelative } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
const messages = defineMessages({
just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
days: { id: 'relative_time.days', defaultMessage: '{number}d' },
});
const dateFormatOptions = { const dateFormatOptions = {
hour12: false, hour12: false,
year: 'numeric', year: 'numeric',
@ -11,6 +19,47 @@ const dateFormatOptions = {
minute: '2-digit', minute: '2-digit',
}; };
const shortDateFormatOptions = {
month: 'numeric',
day: 'numeric',
};
const SECOND = 1000;
const MINUTE = 1000 * 60;
const HOUR = 1000 * 60 * 60;
const DAY = 1000 * 60 * 60 * 24;
const MAX_DELAY = 2147483647;
const selectUnits = delta => {
const absDelta = Math.abs(delta);
if (absDelta < MINUTE) {
return 'second';
} else if (absDelta < HOUR) {
return 'minute';
} else if (absDelta < DAY) {
return 'hour';
}
return 'day';
};
const getUnitDelay = units => {
switch (units) {
case 'second':
return SECOND;
case 'minute':
return MINUTE;
case 'hour':
return HOUR;
case 'day':
return DAY;
default:
return MAX_DELAY;
}
};
@injectIntl @injectIntl
export default class RelativeTimestamp extends React.Component { export default class RelativeTimestamp extends React.Component {
@ -19,20 +68,78 @@ export default class RelativeTimestamp extends React.Component {
timestamp: PropTypes.string.isRequired, timestamp: PropTypes.string.isRequired,
}; };
shouldComponentUpdate (nextProps) { state = {
now: this.props.intl.now(),
};
shouldComponentUpdate (nextProps, nextState) {
// As of right now the locale doesn't change without a new page load, // As of right now the locale doesn't change without a new page load,
// but we might as well check in case that ever changes. // but we might as well check in case that ever changes.
return this.props.timestamp !== nextProps.timestamp || return this.props.timestamp !== nextProps.timestamp ||
this.props.intl.locale !== nextProps.intl.locale; this.props.intl.locale !== nextProps.intl.locale ||
this.state.now !== nextState.now;
}
componentWillReceiveProps (nextProps) {
if (this.props.timestamp !== nextProps.timestamp) {
this.setState({ now: this.props.intl.now() });
}
}
componentDidMount () {
this._scheduleNextUpdate(this.props, this.state);
}
componentWillUpdate (nextProps, nextState) {
this._scheduleNextUpdate(nextProps, nextState);
}
componentWillUnmount () {
clearTimeout(this._timer);
}
_scheduleNextUpdate (props, state) {
clearTimeout(this._timer);
const { timestamp } = props;
const delta = (new Date(timestamp)).getTime() - state.now;
const unitDelay = getUnitDelay(selectUnits(delta));
const unitRemainder = Math.abs(delta % unitDelay);
const updateInterval = 1000 * 10;
const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
this._timer = setTimeout(() => {
this.setState({ now: this.props.intl.now() });
}, delay);
} }
render () { render () {
const { timestamp, intl } = this.props; const { timestamp, intl } = this.props;
const date = new Date(timestamp);
const date = new Date(timestamp);
const delta = this.state.now - date.getTime();
let relativeTime;
if (delta < 10 * SECOND) {
relativeTime = intl.formatMessage(messages.just_now);
} else if (delta < 3 * DAY) {
if (delta < MINUTE) {
relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
} else if (delta < HOUR) {
relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) });
} else if (delta < DAY) {
relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) });
} else {
relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
}
} else {
relativeTime = intl.formatDate(date, shortDateFormatOptions);
}
return ( return (
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}> <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
<FormattedRelative value={date} /> {relativeTime}
</time> </time>
); );
} }

View File

@ -6,6 +6,8 @@ import LoadMore from './load_more';
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import classNames from 'classnames';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
export default class ScrollableList extends PureComponent { export default class ScrollableList extends PureComponent {
@ -66,6 +68,7 @@ export default class ScrollableList extends PureComponent {
componentDidMount () { componentDidMount () {
this.attachScrollListener(); this.attachScrollListener();
this.attachIntersectionObserver(); this.attachIntersectionObserver();
attachFullscreenListener(this.onFullScreenChange);
// Handle initial scroll posiiton // Handle initial scroll posiiton
this.handleScroll(); this.handleScroll();
@ -92,6 +95,11 @@ export default class ScrollableList extends PureComponent {
componentWillUnmount () { componentWillUnmount () {
this.detachScrollListener(); this.detachScrollListener();
this.detachIntersectionObserver(); this.detachIntersectionObserver();
detachFullscreenListener(this.onFullScreenChange);
}
onFullScreenChange = () => {
this.setState({ fullscreen: isFullscreen() });
} }
attachIntersectionObserver () { attachIntersectionObserver () {
@ -137,34 +145,9 @@ export default class ScrollableList extends PureComponent {
return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600); return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
} }
handleKeyDown = (e) => {
if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
const article = (() => {
switch (e.key) {
case 'PageDown':
return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
case 'PageUp':
return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
case 'End':
return this.node.querySelector('[role="feed"] > article:last-of-type');
case 'Home':
return this.node.querySelector('[role="feed"] > article:first-of-type');
default:
return null;
}
})();
if (article) {
e.preventDefault();
article.focus();
article.scrollIntoView();
}
}
}
render () { render () {
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
const { fullscreen } = this.state;
const childrenCount = React.Children.count(children); const childrenCount = React.Children.count(children);
const loadMore = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; const loadMore = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
@ -172,8 +155,8 @@ export default class ScrollableList extends PureComponent {
if (isLoading || childrenCount > 0 || !emptyMessage) { if (isLoading || childrenCount > 0 || !emptyMessage) {
scrollableArea = ( scrollableArea = (
<div className='scrollable' ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}> <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
<div role='feed' className='item-list' onKeyDown={this.handleKeyDown}> <div role='feed' className='item-list'>
{prepend} {prepend}
{React.Children.map(this.props.children, (child, index) => ( {React.Children.map(this.props.children, (child, index) => (

View File

@ -10,6 +10,8 @@ import StatusActionBar from './status_action_bar';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video } from '../features/ui/util/async-components'; import { MediaGallery, Video } from '../features/ui/util/async-components';
import { HotKeys } from 'react-hotkeys';
import classNames from 'classnames';
// We use the component (and not the container) since we do not want // We use the component (and not the container) since we do not want
// to use the progress bar to show download progress // to use the progress bar to show download progress
@ -34,11 +36,13 @@ export default class Status extends ImmutablePureComponent {
onBlock: PropTypes.func, onBlock: PropTypes.func,
onEmbed: PropTypes.func, onEmbed: PropTypes.func,
onHeightChange: PropTypes.func, onHeightChange: PropTypes.func,
me: PropTypes.number, me: PropTypes.string,
boostModal: PropTypes.bool, boostModal: PropTypes.bool,
autoPlayGif: PropTypes.bool, autoPlayGif: PropTypes.bool,
muted: PropTypes.bool, muted: PropTypes.bool,
hidden: PropTypes.bool, hidden: PropTypes.bool,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
}; };
state = { state = {
@ -70,7 +74,7 @@ export default class Status extends ImmutablePureComponent {
handleAccountClick = (e) => { handleAccountClick = (e) => {
if (this.context.router && e.button === 0) { if (this.context.router && e.button === 0) {
const id = Number(e.currentTarget.getAttribute('data-id')); const id = e.currentTarget.getAttribute('data-id');
e.preventDefault(); e.preventDefault();
this.context.router.history.push(`/accounts/${id}`); this.context.router.history.push(`/accounts/${id}`);
} }
@ -89,16 +93,62 @@ export default class Status extends ImmutablePureComponent {
} }
handleOpenVideo = startTime => { handleOpenVideo = startTime => {
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime);
}
handleHotkeyReply = e => {
e.preventDefault();
this.props.onReply(this._properStatus(), this.context.router.history);
}
handleHotkeyFavourite = () => {
this.props.onFavourite(this._properStatus());
}
handleHotkeyBoost = e => {
this.props.onReblog(this._properStatus(), e);
}
handleHotkeyMention = e => {
e.preventDefault();
this.props.onMention(this._properStatus().get('account'), this.context.router.history);
}
handleHotkeyOpen = () => {
this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`);
}
handleHotkeyOpenProfile = () => {
this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`);
}
handleHotkeyMoveUp = () => {
this.props.onMoveUp(this.props.status.get('id'));
}
handleHotkeyMoveDown = () => {
this.props.onMoveDown(this.props.status.get('id'));
}
_properStatus () {
const { status } = this.props;
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
return status.get('reblog');
} else {
return status;
}
} }
render () { render () {
let media = null; let media = null;
let statusAvatar; let statusAvatar, prepend;
const { status, account, hidden, ...other } = this.props; const { hidden } = this.props;
const { isExpanded } = this.state; const { isExpanded } = this.state;
let { status, account, ...other } = this.props;
if (status === null) { if (status === null) {
return null; return null;
} }
@ -115,16 +165,15 @@ export default class Status extends ImmutablePureComponent {
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
return ( prepend = (
<div className='status__wrapper' data-id={status.get('id')} > <div className='status__prepend'>
<div className='status__prepend'> <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
</div>
<Status {...other} status={status.get('reblog')} account={status.get('account')} />
</div> </div>
); );
account = status.get('account');
status = status.get('reblog');
} }
if (status.get('media_attachments').size > 0 && !this.props.muted) { if (status.get('media_attachments').size > 0 && !this.props.muted) {
@ -160,26 +209,43 @@ export default class Status extends ImmutablePureComponent {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />; statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
} }
return ( const handlers = this.props.muted ? {} : {
<div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}> reply: this.handleHotkeyReply,
<div className='status__info'> favourite: this.handleHotkeyFavourite,
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> boost: this.handleHotkeyBoost,
mention: this.handleHotkeyMention,
open: this.handleHotkeyOpen,
openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
};
<a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'> return (
<div className='status__avatar'> <HotKeys handlers={handlers}>
{statusAvatar} <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}>
{prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}>
<div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
<a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'>
<div className='status__avatar'>
{statusAvatar}
</div>
<DisplayName account={status.get('account')} />
</a>
</div> </div>
<DisplayName account={status.get('account')} /> <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
</a>
{media}
<StatusActionBar status={status} account={account} {...other} />
</div>
</div> </div>
</HotKeys>
<StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
{media}
<StatusActionBar {...this.props} />
</div>
); );
} }

View File

@ -13,6 +13,7 @@ const messages = defineMessages({
block: { id: 'account.block', defaultMessage: 'Block @{name}' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' }, reply: { id: 'status.reply', defaultMessage: 'Reply' },
share: { id: 'status.share', defaultMessage: 'Share' }, share: { id: 'status.share', defaultMessage: 'Share' },
more: { id: 'status.more', defaultMessage: 'More' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
@ -46,7 +47,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
onEmbed: PropTypes.func, onEmbed: PropTypes.func,
onMuteConversation: PropTypes.func, onMuteConversation: PropTypes.func,
onPin: PropTypes.func, onPin: PropTypes.func,
me: PropTypes.number, me: PropTypes.string,
withDismiss: PropTypes.bool, withDismiss: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -179,7 +180,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
{shareButton} {shareButton}
<div className='status__action-bar-dropdown'> <div className='status__action-bar-dropdown'>
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' /> <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
</div> </div>
</div> </div>
); );

View File

@ -122,6 +122,7 @@ export default class StatusContent extends React.PureComponent {
const directionStyle = { direction: 'ltr' }; const directionStyle = { direction: 'ltr' };
const classNames = classnames('status__content', { const classNames = classnames('status__content', {
'status__content--with-action': this.props.onClick && this.context.router, 'status__content--with-action': this.props.onClick && this.context.router,
'status__content--with-spoiler': status.get('spoiler_text').length > 0,
}); });
if (isRtl(status.get('search_index'))) { if (isRtl(status.get('search_index'))) {
@ -144,7 +145,7 @@ export default class StatusContent extends React.PureComponent {
} }
return ( return (
<div className={classNames} ref={this.setRef} tabIndex='0' aria-label={status.get('search_index')} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> <div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
<span dangerouslySetInnerHTML={spoilerContent} /> <span dangerouslySetInnerHTML={spoilerContent} />
{' '} {' '}
@ -153,7 +154,7 @@ export default class StatusContent extends React.PureComponent {
{mentionsPlaceholder} {mentionsPlaceholder}
<div tabIndex={!hidden && 0} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} /> <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} />
</div> </div>
); );
} else if (this.props.onClick) { } else if (this.props.onClick) {
@ -161,7 +162,6 @@ export default class StatusContent extends React.PureComponent {
<div <div
ref={this.setRef} ref={this.setRef}
tabIndex='0' tabIndex='0'
aria-label={status.get('search_index')}
className={classNames} className={classNames}
style={directionStyle} style={directionStyle}
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
@ -173,7 +173,6 @@ export default class StatusContent extends React.PureComponent {
return ( return (
<div <div
tabIndex='0' tabIndex='0'
aria-label={status.get('search_index')}
ref={this.setRef} ref={this.setRef}
className='status__content' className='status__content'
style={directionStyle} style={directionStyle}

View File

@ -25,18 +25,45 @@ export default class StatusList extends ImmutablePureComponent {
trackScroll: true, trackScroll: true,
}; };
handleMoveUp = id => {
const elementIndex = this.props.statusIds.indexOf(id) - 1;
this._selectChild(elementIndex);
}
handleMoveDown = id => {
const elementIndex = this.props.statusIds.indexOf(id) + 1;
this._selectChild(elementIndex);
}
_selectChild (index) {
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
element.focus();
}
}
setRef = c => {
this.node = c;
}
render () { render () {
const { statusIds, ...other } = this.props; const { statusIds, ...other } = this.props;
const { isLoading } = other; const { isLoading } = other;
const scrollableContent = (isLoading || statusIds.size > 0) ? ( const scrollableContent = (isLoading || statusIds.size > 0) ? (
statusIds.map((statusId) => ( statusIds.map((statusId) => (
<StatusContainer key={statusId} id={statusId} /> <StatusContainer
key={statusId}
id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
/>
)) ))
) : null; ) : null;
return ( return (
<ScrollableList {...other}> <ScrollableList {...other} ref={this.setRef}>
{scrollableContent} {scrollableContent}
</ScrollableList> </ScrollableList>
); );

View File

@ -1,204 +0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../is_mobile';
const messages = defineMessages({
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' },
expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' },
});
@injectIntl
export default class VideoPlayer extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
width: PropTypes.number,
height: PropTypes.number,
sensitive: PropTypes.bool,
intl: PropTypes.object.isRequired,
autoplay: PropTypes.bool,
onOpenVideo: PropTypes.func.isRequired,
};
static defaultProps = {
width: 239,
height: 110,
};
state = {
visible: !this.props.sensitive,
preview: true,
muted: true,
hasAudio: true,
videoError: false,
};
handleClick = () => {
this.setState({ muted: !this.state.muted });
}
handleVideoClick = (e) => {
e.stopPropagation();
const node = this.video;
if (node.paused) {
node.play();
} else {
node.pause();
}
}
handleOpen = () => {
this.setState({ preview: !this.state.preview });
}
handleVisibility = () => {
this.setState({
visible: !this.state.visible,
preview: true,
});
}
handleExpand = () => {
this.video.pause();
this.props.onOpenVideo(this.props.media, this.video.currentTime);
}
setRef = (c) => {
this.video = c;
}
handleLoadedData = () => {
if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) {
this.setState({ hasAudio: false });
}
}
handleVideoError = () => {
this.setState({ videoError: true });
}
componentDidMount () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}
componentDidUpdate () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}
componentWillUnmount () {
if (!this.video) {
return;
}
this.video.removeEventListener('loadeddata', this.handleLoadedData);
this.video.removeEventListener('error', this.handleVideoError);
}
render () {
const { media, intl, width, height, sensitive, autoplay } = this.props;
let spoilerButton = (
<div className={`status__video-player-spoiler ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}>
<IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
</div>
);
let expandButton = '';
if (this.context.router) {
expandButton = (
<div className='status__video-player-expand'>
<IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} />
</div>
);
}
let muteButton = '';
if (this.state.hasAudio) {
muteButton = (
<div className='status__video-player-mute'>
<IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} />
</div>
);
}
if (!this.state.visible) {
if (sensitive) {
return (
<button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
{spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</button>
);
} else {
return (
<button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
{spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</button>
);
}
}
if (this.state.preview && !autoplay) {
return (
<button className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}>
{spoilerButton}
<div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div>
</button>
);
}
if (this.state.videoError) {
return (
<div style={{ width: `${width}px`, height: `${height}px` }} className='video-error-cover' >
<span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span>
</div>
);
}
return (
<div className='status__video-player' style={{ width: `${width}px`, height: `${height}px` }}>
{spoilerButton}
{muteButton}
{expandButton}
<video
className='status__video-player-video'
role='button'
tabIndex='0'
ref={this.setRef}
src={media.get('url')}
autoPlay={!isIOS()}
loop
muted={this.state.muted}
onClick={this.handleVideoClick}
/>
</div>
);
}
}

View File

@ -3,14 +3,14 @@ import { Provider } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import configureStore from '../store/configureStore'; import configureStore from '../store/configureStore';
import { showOnboardingOnce } from '../actions/onboarding'; import { showOnboardingOnce } from '../actions/onboarding';
import BrowserRouter from 'react-router-dom/BrowserRouter'; import { BrowserRouter, Route } from 'react-router-dom';
import Route from 'react-router-dom/Route'; import { ScrollContext } from 'react-router-scroll';
import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext';
import UI from '../features/ui'; import UI from '../features/ui';
import { hydrateStore } from '../actions/store'; import { hydrateStore } from '../actions/store';
import { connectUserStream } from '../actions/streaming'; import { connectUserStream } from '../actions/streaming';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales'; import { getLocale } from '../locales';
const { localeData, messages } = getLocale(); const { localeData, messages } = getLocale();
addLocaleData(localeData); addLocaleData(localeData);

View File

@ -6,6 +6,7 @@ import { hydrateStore } from '../actions/store';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales'; import { getLocale } from '../locales';
import PublicTimeline from '../features/standalone/public_timeline'; import PublicTimeline from '../features/standalone/public_timeline';
import HashtagTimeline from '../features/standalone/hashtag_timeline';
const { localeData, messages } = getLocale(); const { localeData, messages } = getLocale();
addLocaleData(localeData); addLocaleData(localeData);
@ -22,15 +23,24 @@ export default class TimelineContainer extends React.PureComponent {
static propTypes = { static propTypes = {
locale: PropTypes.string.isRequired, locale: PropTypes.string.isRequired,
hashtag: PropTypes.string,
}; };
render () { render () {
const { locale } = this.props; const { locale, hashtag } = this.props;
let timeline;
if (hashtag) {
timeline = <HashtagTimeline hashtag={hashtag} />;
} else {
timeline = <PublicTimeline />;
}
return ( return (
<IntlProvider locale={locale} messages={messages}> <IntlProvider locale={locale} messages={messages}>
<Provider store={store}> <Provider store={store}>
<PublicTimeline /> {timeline}
</Provider> </Provider>
</IntlProvider> </IntlProvider>
); );

View File

@ -1,30 +0,0 @@
import { unicodeMapping } from './emojione_light';
import Trie from 'substring-trie';
const trie = new Trie(Object.keys(unicodeMapping));
const emojify = str => {
let rtn = '';
for (;;) {
let match, i = 0;
while (i < str.length && str[i] !== '<' && !(match = trie.search(str.slice(i)))) {
i += str.codePointAt(i) < 65536 ? 1 : 2;
}
if (i === str.length)
break;
else if (str[i] === '<') {
let tagend = str.indexOf('>', i + 1) + 1;
if (!tagend)
break;
rtn += str.slice(0, tagend);
str = str.slice(tagend);
} else {
const [filename, shortCode] = unicodeMapping[match];
rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`;
str = str.slice(i + match.length);
}
}
return rtn + str;
};
export default emojify;

View File

@ -1,13 +0,0 @@
// @preval
// Force tree shaking on emojione by exposing just a subset of its functionality
const emojione = require('emojione');
const mappedUnicode = emojione.mapUnicodeToShort();
const excluded = ['®', '©', '™'];
module.exports.unicodeMapping = Object.keys(emojione.jsEscapeMap)
.filter(c => !excluded.includes(c))
.map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]])
.map(([unicodeStr, shortCode]) => ({ [unicodeStr]: [emojione.emojioneList[shortCode].fname, shortCode.slice(1, shortCode.length - 1)] }))
.reduce((x, y) => Object.assign(x, y), { });

View File

@ -2,7 +2,7 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import Link from 'react-router-dom/Link'; import { Link } from 'react-router-dom';
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
@ -26,7 +26,7 @@ export default class ActionBar extends React.PureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.map.isRequired,
me: PropTypes.number.isRequired, me: PropTypes.string.isRequired,
onFollow: PropTypes.func, onFollow: PropTypes.func,
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired,

View File

@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from '../../../components/icon_button'; import IconButton from '../../../components/icon_button';
import Motion from 'react-motion/lib/Motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
@ -77,7 +77,7 @@ export default class Header extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map, account: ImmutablePropTypes.map,
me: PropTypes.number.isRequired, me: PropTypes.string.isRequired,
onFollow: PropTypes.func.isRequired, onFollow: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
autoPlayGif: PropTypes.bool.isRequired, autoPlayGif: PropTypes.bool.isRequired,

View File

@ -16,9 +16,9 @@ import { ScrollContainer } from 'react-router-scroll';
import LoadMore from '../../components/load_more'; import LoadMore from '../../components/load_more';
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
medias: getAccountGallery(state, Number(props.params.accountId)), medias: getAccountGallery(state, props.params.accountId),
isLoading: state.getIn(['timelines', `account:${Number(props.params.accountId)}:media`, 'isLoading']), isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
hasMore: !!state.getIn(['timelines', `account:${Number(props.params.accountId)}:media`, 'next']), hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']),
autoPlayGif: state.getIn(['meta', 'auto_play_gif']), autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
}); });
@ -35,20 +35,20 @@ export default class AccountGallery extends ImmutablePureComponent {
}; };
componentDidMount () { componentDidMount () {
this.props.dispatch(fetchAccount(Number(this.props.params.accountId))); this.props.dispatch(fetchAccount(this.props.params.accountId));
this.props.dispatch(refreshAccountMediaTimeline(Number(this.props.params.accountId))); this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
} }
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId))); this.props.dispatch(fetchAccount(nextProps.params.accountId));
this.props.dispatch(refreshAccountMediaTimeline(Number(this.props.params.accountId))); this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
} }
} }
handleScrollToBottom = () => { handleScrollToBottom = () => {
if (this.props.hasMore) { if (this.props.hasMore) {
this.props.dispatch(expandAccountMediaTimeline(Number(this.props.params.accountId))); this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
} }
} }

View File

@ -10,7 +10,7 @@ export default class Header extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map, account: ImmutablePropTypes.map,
me: PropTypes.number.isRequired, me: PropTypes.string.isRequired,
onFollow: PropTypes.func.isRequired, onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired,

View File

@ -27,7 +27,7 @@ const makeMapStateToProps = () => {
const getAccount = makeGetAccount(); const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId }) => ({ const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, Number(accountId)), account: getAccount(state, accountId),
me: state.getIn(['meta', 'me']), me: state.getIn(['meta', 'me']),
unfollowModal: state.getIn(['meta', 'unfollow_modal']), unfollowModal: state.getIn(['meta', 'unfollow_modal']),
}); });

View File

@ -13,9 +13,9 @@ import { List as ImmutableList } from 'immutable';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
statusIds: state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'items'], ImmutableList()), statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()),
isLoading: state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'isLoading']), isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']),
hasMore: !!state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'next']), hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']),
me: state.getIn(['meta', 'me']), me: state.getIn(['meta', 'me']),
}); });
@ -28,24 +28,24 @@ export default class AccountTimeline extends ImmutablePureComponent {
statusIds: ImmutablePropTypes.list, statusIds: ImmutablePropTypes.list,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
me: PropTypes.number.isRequired, me: PropTypes.string.isRequired,
}; };
componentWillMount () { componentWillMount () {
this.props.dispatch(fetchAccount(Number(this.props.params.accountId))); this.props.dispatch(fetchAccount(this.props.params.accountId));
this.props.dispatch(refreshAccountTimeline(Number(this.props.params.accountId))); this.props.dispatch(refreshAccountTimeline(this.props.params.accountId));
} }
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId))); this.props.dispatch(fetchAccount(nextProps.params.accountId));
this.props.dispatch(refreshAccountTimeline(Number(nextProps.params.accountId))); this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId));
} }
} }
handleScrollToBottom = () => { handleScrollToBottom = () => {
if (!this.props.isLoading && this.props.hasMore) { if (!this.props.isLoading && this.props.hasMore) {
this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId))); this.props.dispatch(expandAccountTimeline(this.props.params.accountId));
} }
} }

View File

@ -5,14 +5,13 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ReplyIndicatorContainer from '../containers/reply_indicator_container'; import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import AutosuggestTextarea from '../../../components/autosuggest_textarea'; import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import { debounce } from 'lodash';
import UploadButtonContainer from '../containers/upload_button_container'; import UploadButtonContainer from '../containers/upload_button_container';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import Collapsable from '../../../components/collapsable'; import Collapsable from '../../../components/collapsable';
import SpoilerButtonContainer from '../containers/spoiler_button_container'; import SpoilerButtonContainer from '../containers/spoiler_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import SensitiveButtonContainer from '../containers/sensitive_button_container'; import SensitiveButtonContainer from '../containers/sensitive_button_container';
import EmojiPickerDropdown from './emoji_picker_dropdown'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import UploadFormContainer from '../containers/upload_form_container'; import UploadFormContainer from '../containers/upload_form_container';
import WarningContainer from '../containers/warning_container'; import WarningContainer from '../containers/warning_container';
import { isMobile } from '../../../is_mobile'; import { isMobile } from '../../../is_mobile';
@ -42,7 +41,7 @@ export default class ComposeForm extends ImmutablePureComponent {
preselectDate: PropTypes.instanceOf(Date), preselectDate: PropTypes.instanceOf(Date),
is_submitting: PropTypes.bool, is_submitting: PropTypes.bool,
is_uploading: PropTypes.bool, is_uploading: PropTypes.bool,
me: PropTypes.number, me: PropTypes.string,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired,
onClearSuggestions: PropTypes.func.isRequired, onClearSuggestions: PropTypes.func.isRequired,
@ -82,9 +81,9 @@ export default class ComposeForm extends ImmutablePureComponent {
this.props.onClearSuggestions(); this.props.onClearSuggestions();
} }
onSuggestionsFetchRequested = debounce((token) => { onSuggestionsFetchRequested = (token) => {
this.props.onFetchSuggestions(token); this.props.onFetchSuggestions(token);
}, 500, { trailing: true }) }
onSuggestionSelected = (tokenStart, token, value) => { onSuggestionSelected = (tokenStart, token, value) => {
this._restoreCaret = null; this._restoreCaret = null;
@ -138,7 +137,7 @@ export default class ComposeForm extends ImmutablePureComponent {
handleEmojiPick = (data) => { handleEmojiPick = (data) => {
const position = this.autosuggestTextarea.textarea.selectionStart; const position = this.autosuggestTextarea.textarea.selectionStart;
const emojiChar = data.unicode.split('-').map(code => String.fromCodePoint(parseInt(code, 16))).join(''); const emojiChar = data.native;
this._restoreCaret = position + emojiChar.length + 1; this._restoreCaret = position + emojiChar.length + 1;
this.props.onPickEmoji(position, data); this.props.onPickEmoji(position, data);
} }

View File

@ -1,12 +1,20 @@
import React from 'react'; import React from 'react';
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
import Overlay from 'react-overlays/lib/Overlay';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import detectPassiveEvents from 'detect-passive-events';
import { buildCustomEmojis } from '../../emoji/emoji';
const messages = defineMessages({ const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' }, emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' },
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
people: { id: 'emoji_button.people', defaultMessage: 'People' }, people: { id: 'emoji_button.people', defaultMessage: 'People' },
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' }, nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' }, food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
@ -17,20 +25,270 @@ const messages = defineMessages({
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
}); });
const settings = { const assetHost = process.env.CDN_HOST || '';
imageType: 'png', let EmojiPicker, Emoji; // load asynchronously
sprites: false,
imagePathPNG: '/emoji/',
};
let EmojiPicker; // load asynchronously const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
const categoriesSort = [
'recent',
'custom',
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
];
class ModifierPickerMenu extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
onSelect: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
handleClick = e => {
this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
}
componentWillReceiveProps (nextProps) {
if (nextProps.active) {
this.attachListeners();
} else {
this.removeListeners();
}
}
componentWillUnmount () {
this.removeListeners();
}
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
attachListeners () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
removeListeners () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
render () {
const { active } = this.props;
return (
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
<button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
</div>
);
}
}
class ModifierPicker extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
modifier: PropTypes.number,
onChange: PropTypes.func,
onClose: PropTypes.func,
onOpen: PropTypes.func,
};
handleClick = () => {
if (this.props.active) {
this.props.onClose();
} else {
this.props.onOpen();
}
}
handleSelect = modifier => {
this.props.onChange(modifier);
this.props.onClose();
}
render () {
const { active, modifier } = this.props;
return (
<div className='emoji-picker-dropdown__modifiers'>
<Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
</div>
);
}
}
@injectIntl
class EmojiPickerMenu extends React.PureComponent {
static propTypes = {
custom_emojis: ImmutablePropTypes.list,
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
loading: PropTypes.bool,
onClose: PropTypes.func.isRequired,
onPick: PropTypes.func.isRequired,
style: PropTypes.object,
placement: PropTypes.string,
arrowOffsetLeft: PropTypes.string,
arrowOffsetTop: PropTypes.string,
intl: PropTypes.object.isRequired,
skinTone: PropTypes.number.isRequired,
onSkinTone: PropTypes.func.isRequired,
autoPlay: PropTypes.bool,
};
static defaultProps = {
style: {},
loading: true,
placement: 'bottom',
frequentlyUsedEmojis: [],
};
state = {
modifierOpen: false,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
getI18n = () => {
const { intl } = this.props;
return {
search: intl.formatMessage(messages.emoji_search),
notfound: intl.formatMessage(messages.emoji_not_found),
categories: {
search: intl.formatMessage(messages.search_results),
recent: intl.formatMessage(messages.recent),
people: intl.formatMessage(messages.people),
nature: intl.formatMessage(messages.nature),
foods: intl.formatMessage(messages.food),
activity: intl.formatMessage(messages.activity),
places: intl.formatMessage(messages.travel),
objects: intl.formatMessage(messages.objects),
symbols: intl.formatMessage(messages.symbols),
flags: intl.formatMessage(messages.flags),
custom: intl.formatMessage(messages.custom),
},
};
}
handleClick = emoji => {
if (!emoji.native) {
emoji.native = emoji.colons;
}
this.props.onClose();
this.props.onPick(emoji);
}
handleModifierOpen = () => {
this.setState({ modifierOpen: true });
}
handleModifierClose = () => {
this.setState({ modifierOpen: false });
}
handleModifierChange = modifier => {
this.props.onSkinTone(modifier);
}
render () {
const { loading, style, intl, custom_emojis, autoPlay, skinTone, frequentlyUsedEmojis } = this.props;
if (loading) {
return <div style={{ width: 299 }} />;
}
const title = intl.formatMessage(messages.emoji);
const { modifierOpen } = this.state;
return (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<EmojiPicker
perLine={8}
emojiSize={22}
sheetSize={32}
custom={buildCustomEmojis(custom_emojis, autoPlay)}
color=''
emoji=''
set='twitter'
title={title}
i18n={this.getI18n()}
onClick={this.handleClick}
include={categoriesSort}
recent={frequentlyUsedEmojis}
skin={skinTone}
showPreview={false}
backgroundImageFn={backgroundImageFn}
emojiTooltip
/>
<ModifierPicker
active={modifierOpen}
modifier={skinTone}
onOpen={this.handleModifierOpen}
onClose={this.handleModifierClose}
onChange={this.handleModifierChange}
/>
</div>
);
}
}
@injectIntl @injectIntl
export default class EmojiPickerDropdown extends React.PureComponent { export default class EmojiPickerDropdown extends React.PureComponent {
static propTypes = { static propTypes = {
custom_emojis: ImmutablePropTypes.list,
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
autoPlay: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
onPickEmoji: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
}; };
state = { state = {
@ -42,20 +300,18 @@ export default class EmojiPickerDropdown extends React.PureComponent {
this.dropdown = c; this.dropdown = c;
} }
handleChange = (data) => {
this.dropdown.hide();
this.props.onPickEmoji(data);
}
onShowDropdown = () => { onShowDropdown = () => {
this.setState({ active: true }); this.setState({ active: true });
if (!EmojiPicker) { if (!EmojiPicker) {
this.setState({ loading: true }); this.setState({ loading: true });
EmojiPickerAsync().then(TheEmojiPicker => {
EmojiPicker = TheEmojiPicker.default; EmojiPickerAsync().then(EmojiMart => {
EmojiPicker = EmojiMart.Picker;
Emoji = EmojiMart.Emoji;
this.setState({ loading: false }); this.setState({ loading: false });
}).catch(() => { }).catch(() => {
// TODO: show the user an error?
this.setState({ loading: false }); this.setState({ loading: false });
}); });
} }
@ -75,70 +331,48 @@ export default class EmojiPickerDropdown extends React.PureComponent {
} }
} }
onEmojiPickerKeyDown = (e) => { handleKeyDown = e => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
this.onHideDropdown(); this.onHideDropdown();
} }
} }
setTargetRef = c => {
this.target = c;
}
findTarget = () => {
return this.target;
}
render () { render () {
const { intl } = this.props; const { intl, onPickEmoji, autoPlay, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const categories = {
people: {
title: intl.formatMessage(messages.people),
emoji: 'smile',
},
nature: {
title: intl.formatMessage(messages.nature),
emoji: 'hamster',
},
food: {
title: intl.formatMessage(messages.food),
emoji: 'pizza',
},
activity: {
title: intl.formatMessage(messages.activity),
emoji: 'soccer',
},
travel: {
title: intl.formatMessage(messages.travel),
emoji: 'earth_americas',
},
objects: {
title: intl.formatMessage(messages.objects),
emoji: 'bulb',
},
symbols: {
title: intl.formatMessage(messages.symbols),
emoji: 'clock9',
},
flags: {
title: intl.formatMessage(messages.flags),
emoji: 'flag_gb',
},
};
const { active, loading } = this.state;
const title = intl.formatMessage(messages.emoji); const title = intl.formatMessage(messages.emoji);
const { active, loading } = this.state;
return ( return (
<Dropdown ref={this.setRef} className='emoji-picker__dropdown' active={active && !loading} onShow={this.onShowDropdown} onHide={this.onHideDropdown}> <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
<DropdownTrigger className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onKeyDown={this.onToggle} tabIndex={0} > <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
<img <img
className={`emojione ${active && loading ? 'pulse-loading' : ''}`} className={classNames('emojione', { 'pulse-loading': active && loading })}
alt='🙂' alt='🙂'
src='/emoji/1f602.svg' src={`${assetHost}/emoji/1f602.svg`}
/> />
</DropdownTrigger> </div>
<DropdownContent className='dropdown__left'> <Overlay show={active} placement='bottom' target={this.findTarget}>
{ <EmojiPickerMenu
this.state.active && !this.state.loading && custom_emojis={this.props.custom_emojis}
(<EmojiPicker emojione={settings} onChange={this.handleChange} searchPlaceholder={intl.formatMessage(messages.emoji_search)} onKeyDown={this.onEmojiPickerKeyDown} categories={categories} search />) loading={loading}
} onClose={this.onHideDropdown}
</DropdownContent> onPick={onPickEmoji}
</Dropdown> autoPlay={autoPlay}
onSkinTone={onSkinTone}
skinTone={skinTone}
frequentlyUsedEmojis={frequentlyUsedEmojis}
/>
</Overlay>
</div>
); );
} }

View File

@ -2,6 +2,11 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import IconButton from '../../../components/icon_button'; import IconButton from '../../../components/icon_button';
import Overlay from 'react-overlays/lib/Overlay';
import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import detectPassiveEvents from 'detect-passive-events';
import classNames from 'classnames';
const messages = defineMessages({ const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@ -15,10 +20,77 @@ const messages = defineMessages({
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
}); });
const iconStyle = { const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
height: null,
lineHeight: '27px', class PrivacyDropdownMenu extends React.PureComponent {
};
static propTypes = {
style: PropTypes.object,
items: PropTypes.array.isRequired,
value: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
handleClick = e => {
if (e.key === 'Escape') {
this.props.onClose();
} else if (!e.key || e.key === 'Enter') {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onClose();
this.props.onChange(value);
}
}
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
render () {
const { style, items, value } = this.props;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
{items.map(item =>
<div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}>
<div className='privacy-dropdown__option__icon'>
<i className={`fa fa-fw fa-${item.icon}`} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
</div>
)}
</div>
)}
</Motion>
);
}
}
@injectIntl @injectIntl
export default class PrivacyDropdown extends React.PureComponent { export default class PrivacyDropdown extends React.PureComponent {
@ -54,26 +126,30 @@ export default class PrivacyDropdown extends React.PureComponent {
handleModalActionClick = (e) => { handleModalActionClick = (e) => {
e.preventDefault(); e.preventDefault();
const { value } = this.options[e.currentTarget.getAttribute('data-index')]; const { value } = this.options[e.currentTarget.getAttribute('data-index')];
this.props.onModalClose(); this.props.onModalClose();
this.props.onChange(value); this.props.onChange(value);
} }
handleClick = (e) => { handleKeyDown = e => {
if (e.key === 'Escape') { switch(e.key) {
this.setState({ open: false }); case 'Enter':
} else if (!e.key || e.key === 'Enter') { this.handleToggle();
const value = e.currentTarget.getAttribute('data-index'); break;
e.preventDefault(); case 'Escape':
this.setState({ open: false }); this.handleClose();
this.props.onChange(value); break;
} }
} }
onGlobalClick = (e) => { handleClose = () => {
if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) { this.setState({ open: false });
this.setState({ open: false }); }
}
handleChange = value => {
this.props.onChange(value);
} }
componentWillMount () { componentWillMount () {
@ -87,20 +163,6 @@ export default class PrivacyDropdown extends React.PureComponent {
]; ];
} }
componentDidMount () {
window.addEventListener('click', this.onGlobalClick);
window.addEventListener('touchstart', this.onGlobalClick);
}
componentWillUnmount () {
window.removeEventListener('click', this.onGlobalClick);
window.removeEventListener('touchstart', this.onGlobalClick);
}
setRef = (c) => {
this.node = c;
}
render () { render () {
const { value, intl } = this.props; const { value, intl } = this.props;
const { open } = this.state; const { open } = this.state;
@ -108,19 +170,29 @@ export default class PrivacyDropdown extends React.PureComponent {
const valueOption = this.options.find(item => item.value === value); const valueOption = this.options.find(item => item.value === value);
return ( return (
<div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}> <div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
<div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} expanded={open} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div> <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
<div className='privacy-dropdown__dropdown'> <IconButton
{open && this.options.map(item => className='privacy-dropdown__value-icon'
<div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}> icon={valueOption.icon}
<div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div> title={intl.formatMessage(messages.change_privacy)}
<div className='privacy-dropdown__option__content'> size={18}
<strong>{item.text}</strong> expanded={open}
{item.meta} active={open}
</div> inverted
</div> onClick={this.handleToggle}
)} style={{ height: null, lineHeight: '27px' }}
/>
</div> </div>
<Overlay show={open} placement='bottom' target={this}>
<PrivacyDropdownMenu
items={this.options}
value={value}
onClose={this.handleClose}
onChange={this.handleChange}
/>
</Overlay>
</div> </div>
); );
} }

View File

@ -1,11 +1,47 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay';
import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
}); });
class SearchPopout extends React.PureComponent {
static propTypes = {
style: PropTypes.object,
};
render () {
const { style } = this.props;
return (
<div style={{ ...style, position: 'absolute', width: 285 }}>
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
<h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
<ul>
<li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
<li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
<li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
<li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
</ul>
<FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />
</div>
)}
</Motion>
</div>
);
}
}
@injectIntl @injectIntl
export default class Search extends React.PureComponent { export default class Search extends React.PureComponent {
@ -19,6 +55,10 @@ export default class Search extends React.PureComponent {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
state = {
expanded: false,
};
handleChange = (e) => { handleChange = (e) => {
this.props.onChange(e.target.value); this.props.onChange(e.target.value);
} }
@ -35,6 +75,8 @@ export default class Search extends React.PureComponent {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
this.props.onSubmit(); this.props.onSubmit();
} else if (e.key === 'Escape') {
document.querySelector('.ui').parentElement.focus();
} }
} }
@ -43,11 +85,17 @@ export default class Search extends React.PureComponent {
} }
handleFocus = () => { handleFocus = () => {
this.setState({ expanded: true });
this.props.onShow(); this.props.onShow();
} }
handleBlur = () => {
this.setState({ expanded: false });
}
render () { render () {
const { intl, value, submitted } = this.props; const { intl, value, submitted } = this.props;
const { expanded } = this.state;
const hasValue = value.length > 0 || submitted; const hasValue = value.length > 0 || submitted;
return ( return (
@ -62,6 +110,7 @@ export default class Search extends React.PureComponent {
onChange={this.handleChange} onChange={this.handleChange}
onKeyUp={this.handleKeyDown} onKeyUp={this.handleKeyDown}
onFocus={this.handleFocus} onFocus={this.handleFocus}
onBlur={this.handleBlur}
/> />
</label> </label>
@ -69,6 +118,10 @@ export default class Search extends React.PureComponent {
<i className={`fa fa-search ${hasValue ? '' : 'active'}`} /> <i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
<i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} /> <i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} />
</div> </div>
<Overlay show={expanded && !hasValue} placement='bottom' target={this}>
<SearchPopout />
</Overlay>
</div> </div>
); );
} }

View File

@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import AccountContainer from '../../../containers/account_container'; import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container'; import StatusContainer from '../../../containers/status_container';
import Link from 'react-router-dom/Link'; import { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
export default class SearchResults extends ImmutablePureComponent { export default class SearchResults extends ImmutablePureComponent {

View File

@ -0,0 +1,96 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from '../../../components/icon_button';
import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
const messages = defineMessages({
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
});
@injectIntl
export default class Upload extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onUndo: PropTypes.func.isRequired,
onDescriptionChange: PropTypes.func.isRequired,
};
state = {
hovered: false,
focused: false,
dirtyDescription: null,
};
handleUndoClick = () => {
this.props.onUndo(this.props.media.get('id'));
}
handleInputChange = e => {
this.setState({ dirtyDescription: e.target.value });
}
handleMouseEnter = () => {
this.setState({ hovered: true });
}
handleMouseLeave = () => {
this.setState({ hovered: false });
}
handleInputFocus = () => {
this.setState({ focused: true });
}
handleInputBlur = () => {
const { dirtyDescription } = this.state;
this.setState({ focused: false, dirtyDescription: null });
if (dirtyDescription !== null) {
this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
}
}
render () {
const { intl, media } = this.props;
const active = this.state.hovered || this.state.focused;
const description = this.state.dirtyDescription || media.get('description') || '';
return (
<div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => (
<div className='compose-form__upload-thumbnail' style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
<div className={classNames('compose-form__upload-description', { active })}>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
<input
placeholder={intl.formatMessage(messages.description)}
type='text'
value={description}
maxLength={420}
onFocus={this.handleInputFocus}
onChange={this.handleInputChange}
onBlur={this.handleInputBlur}
/>
</label>
</div>
</div>
)}
</Motion>
</div>
);
}
}

View File

@ -1,49 +1,27 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from '../../../components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import UploadProgressContainer from '../containers/upload_progress_container'; import UploadProgressContainer from '../containers/upload_progress_container';
import Motion from 'react-motion/lib/Motion'; import ImmutablePureComponent from 'react-immutable-pure-component';
import spring from 'react-motion/lib/spring'; import UploadContainer from '../containers/upload_container';
const messages = defineMessages({ export default class UploadForm extends ImmutablePureComponent {
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
});
@injectIntl
export default class UploadForm extends React.PureComponent {
static propTypes = { static propTypes = {
media: ImmutablePropTypes.list.isRequired, mediaIds: ImmutablePropTypes.list.isRequired,
onRemoveFile: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
}; };
onRemoveFile = (e) => {
const id = Number(e.currentTarget.parentElement.getAttribute('data-id'));
this.props.onRemoveFile(id);
}
render () { render () {
const { intl, media } = this.props; const { mediaIds } = this.props;
const uploads = media.map(attachment =>
<div className='compose-form__upload' key={attachment.get('id')}>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) =>
<div className='compose-form__upload-thumbnail' data-id={attachment.get('id')} style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${attachment.get('preview_url')})` }}>
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.onRemoveFile} />
</div>
}
</Motion>
</div>
);
return ( return (
<div className='compose-form__upload-wrapper'> <div className='compose-form__upload-wrapper'>
<UploadProgressContainer /> <UploadProgressContainer />
<div className='compose-form__uploads-wrapper'>{uploads}</div>
<div className='compose-form__uploads-wrapper'>
{mediaIds.map(id => (
<UploadContainer id={id} key={id} />
))}
</div>
</div> </div>
); );
} }

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Motion from 'react-motion/lib/Motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';

View File

@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
export default class Warning extends React.PureComponent { export default class Warning extends React.PureComponent {
@ -11,9 +13,13 @@ export default class Warning extends React.PureComponent {
const { message } = this.props; const { message } = this.props;
return ( return (
<div className='compose-form__warning'> <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{message} {({ opacity, scaleX, scaleY }) => (
</div> <div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
{message}
</div>
)}
</Motion>
); );
} }

View File

@ -1,15 +0,0 @@
import { connect } from 'react-redux';
import AutosuggestStatus from '../components/autosuggest_status';
import { makeGetStatus } from '../../../selectors';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, { id }) => ({
status: getStatus(state, id),
});
return mapStateToProps;
};
export default connect(makeMapStateToProps)(AutosuggestStatus);

View File

@ -0,0 +1,83 @@
import { connect } from 'react-redux';
import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
import { changeSetting } from '../../../actions/settings';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';
import { useEmoji } from '../../../actions/emojis';
const perLine = 8;
const lines = 2;
const DEFAULTS = [
'+1',
'grinning',
'kissing_heart',
'heart_eyes',
'laughing',
'stuck_out_tongue_winking_eye',
'sweat_smile',
'joy',
'yum',
'disappointed',
'thinking_face',
'weary',
'sob',
'sunglasses',
'heart',
'ok_hand',
];
const getFrequentlyUsedEmojis = createSelector([
state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
], emojiCounters => {
let emojis = emojiCounters
.keySeq()
.sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
.reverse()
.slice(0, perLine * lines)
.toArray();
if (emojis.length < DEFAULTS.length) {
emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length));
}
return emojis;
});
const getCustomEmojis = createSelector([
state => state.get('custom_emojis'),
], emojis => emojis.sort((a, b) => {
const aShort = a.get('shortcode').toLowerCase();
const bShort = b.get('shortcode').toLowerCase();
if (aShort < bShort) {
return -1;
} else if (aShort > bShort ) {
return 1;
} else {
return 0;
}
}));
const mapStateToProps = state => ({
custom_emojis: getCustomEmojis(state),
autoPlay: state.getIn(['meta', 'auto_play_gif']),
skinTone: state.getIn(['settings', 'skinTone']),
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
});
const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
onSkinTone: skinTone => {
dispatch(changeSetting(['skinTone'], skinTone));
},
onPickEmoji: emoji => {
dispatch(useEmoji(emoji));
if (onPickEmoji) {
onPickEmoji(emoji);
}
},
});
export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);

View File

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import IconButton from '../../../components/icon_button'; import IconButton from '../../../components/icon_button';
import { changeComposeSensitivity } from '../../../actions/compose'; import { changeComposeSensitivity } from '../../../actions/compose';
import Motion from 'react-motion/lib/Motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';

View File

@ -0,0 +1,21 @@
import { connect } from 'react-redux';
import Upload from '../components/upload';
import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
});
const mapDispatchToProps = dispatch => ({
onUndo: id => {
dispatch(undoUploadCompose(id));
},
onDescriptionChange: (id, description) => {
dispatch(changeUploadCompose(id, description));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Upload);

View File

@ -1,17 +1,8 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import UploadForm from '../components/upload_form'; import UploadForm from '../components/upload_form';
import { undoUploadCompose } from '../../../actions/compose';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
media: state.getIn(['compose', 'media_attachments']), mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
}); });
const mapDispatchToProps = dispatch => ({ export default connect(mapStateToProps)(UploadForm);
onRemoveFile (media_id) {
dispatch(undoUploadCompose(media_id));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(UploadForm);

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