Merge remote-tracking branch 'glitchsoc/master'

master
noiob 2019-05-21 01:52:19 +02:00
commit 6a6cb6b3db
297 changed files with 6593 additions and 4141 deletions

View File

@ -10,6 +10,7 @@ DB_NAME=postgres
DB_PASS=
DB_PORT=5432
# Optional ElasticSearch configuration
# You may also set ES_PREFIX to share the same cluster between multiple Mastodon servers (falls back to REDIS_NAMESPACE if not set)
# ES_ENABLED=true
# ES_HOST=es
# ES_PORT=9200
@ -156,6 +157,9 @@ STREAMING_CLUSTER_NUM=1
# Maximum allowed character count
# MAX_TOOT_CHARS=500
# Maximum number of pinned posts
# MAX_PINNED_TOOTS=5
# LDAP authentication (optional)
# LDAP_ENABLED=true
# LDAP_HOST=localhost

View File

@ -3,6 +3,57 @@ Changelog
All notable changes to this project will be documented in this file.
## [2.8.2] - 2019-05-05
### Added
- Add `SOURCE_TAG` environment variable ([ushitora-anqou](https://github.com/tootsuite/mastodon/pull/10698))
### Fixed
- Fix cropped hero image on frontpage ([BaptisteGelez](https://github.com/tootsuite/mastodon/pull/10702))
- Fix blurhash gem not compiling on some operating systems ([Gargron](https://github.com/tootsuite/mastodon/pull/10700))
- Fix unexpected CSS animations in some browsers ([ThibG](https://github.com/tootsuite/mastodon/pull/10699))
- Fix closing video modal scrolling timelines to top ([ThibG](https://github.com/tootsuite/mastodon/pull/10695))
## [2.8.1] - 2019-05-04
### Added
- Add link to existing domain block when trying to block an already-blocked domain ([ThibG](https://github.com/tootsuite/mastodon/pull/10663))
- Add button to view context to media modal when opened from account gallery in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10676))
- Add ability to create multiple-choice polls in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10603))
- Add `GITHUB_REPOSITORY` and `SOURCE_BASE_URL` environment variables ([rosylilly](https://github.com/tootsuite/mastodon/pull/10600))
- Add `/interact/` paths to `robots.txt` ([ThibG](https://github.com/tootsuite/mastodon/pull/10666))
- Add `blurhash` to the Attachment entity in the REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
### Changed
- Change hidden media to be shown as a blurhash-based colorful gradient instead of a black box in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
- Change rejected media to be shown as a blurhash-based gradient instead of a list of filenames in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
- Change e-mail whitelist/blacklist to not be checked when invited ([Gargron](https://github.com/tootsuite/mastodon/pull/10683))
- Change cache header of REST API results to no-cache ([ThibG](https://github.com/tootsuite/mastodon/pull/10655))
- Change the "mark media as sensitive" button to be more obvious in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10673), [Gargron](https://github.com/tootsuite/mastodon/pull/10682))
- Change account gallery in web UI to display 3 columns, open media modal ([Gargron](https://github.com/tootsuite/mastodon/pull/10667), [Gargron](https://github.com/tootsuite/mastodon/pull/10674))
### Fixed
- Fix LDAP/PAM/SAML/CAS users not being pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10621))
- Fix accounts created through tootctl not being always pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10684))
- Fix Sidekiq retrying ActivityPub processing jobs that fail validation ([ThibG](https://github.com/tootsuite/mastodon/pull/10614))
- Fix toots not being scrolled into view sometimes through keyboard selection ([ThibG](https://github.com/tootsuite/mastodon/pull/10593))
- Fix expired invite links being usable to bypass approval mode ([ThibG](https://github.com/tootsuite/mastodon/pull/10657))
- Fix not being able to save e-mail preference for new pending accounts ([Gargron](https://github.com/tootsuite/mastodon/pull/10622))
- Fix upload progressbar when image resizing is involved ([ThibG](https://github.com/tootsuite/mastodon/pull/10632))
- Fix block action not automatically cancelling pending follow request ([ThibG](https://github.com/tootsuite/mastodon/pull/10633))
- Fix stoplight logging to stderr separate from Rails logger ([Gargron](https://github.com/tootsuite/mastodon/pull/10624))
- Fix sign up button not saying sign up when invite is used ([Gargron](https://github.com/tootsuite/mastodon/pull/10623))
- Fix health checks in Docker Compose configuration ([fabianonline](https://github.com/tootsuite/mastodon/pull/10553))
- Fix modal items not being scrollable on touch devices ([kedamaDQ](https://github.com/tootsuite/mastodon/pull/10605))
- Fix Keybase configuration using wrong domain when a web domain is used ([BenLubar](https://github.com/tootsuite/mastodon/pull/10565))
- Fix avatar GIFs not being animated on-hover on public profiles ([hyenagirl64](https://github.com/tootsuite/mastodon/pull/10549))
- Fix OpenGraph parser not understanding some valid property meta tags ([da2x](https://github.com/tootsuite/mastodon/pull/10604))
- Fix wrong fonts being displayed when Roboto is installed on user's machine ([ThibG](https://github.com/tootsuite/mastodon/pull/10594))
- Fix confirmation modals being too narrow for a secondary action button ([ThibG](https://github.com/tootsuite/mastodon/pull/10586))
## [2.8.0] - 2019-04-10
### Added

View File

@ -52,9 +52,7 @@ Bug reports and feature suggestions can be submitted to [GitHub Issues](https://
## Translations
You can submit translations via [Weblate](https://weblate.joinmastodon.org/). They are periodically merged into the codebase.
[![Mastodon translation statistics by language](https://weblate.joinmastodon.org/widgets/mastodon/-/multi-auto.svg)](https://weblate.joinmastodon.org/)
You can submit translations via pull request.
## Pull requests

23
Gemfile
View File

@ -15,12 +15,13 @@ gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.2'
gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.36', require: false
gem 'aws-sdk-s3', '~> 1.39', require: false
gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0'
gem 'paperclip-av-transcoder', '~> 0.6'
gem 'streamio-ffmpeg', '~> 3.0'
gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.6'
@ -29,7 +30,7 @@ gem 'browser'
gem 'charlock_holmes', '~> 0.7.6'
gem 'iso-639'
gem 'chewy', '~> 5.0'
gem 'cld3', '~> 3.2.3'
gem 'cld3', '~> 3.2.4'
gem 'devise', '~> 4.6'
gem 'devise-two-factor', '~> 3.0'
@ -42,7 +43,7 @@ gem 'omniauth-cas', '~> 1.1'
gem 'omniauth-saml', '~> 1.10'
gem 'omniauth', '~> 1.9'
gem 'doorkeeper', '~> 5.0'
gem 'doorkeeper', '~> 5.1'
gem 'fast_blank', '~> 1.0'
gem 'fastimage'
gem 'goldfinger', '~> 2.1'
@ -66,7 +67,7 @@ gem 'ox', '~> 2.10'
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
gem 'pundit', '~> 2.0'
gem 'premailer-rails'
gem 'rack-attack', '~> 5.4'
gem 'rack-attack', '~> 6.0'
gem 'rack-cors', '~> 1.0', require: 'rack/cors'
gem 'rails-i18n', '~> 5.1'
gem 'rails-settings-cached', '~> 0.6'
@ -94,6 +95,8 @@ gem 'json-ld', '~> 3.0'
gem 'json-ld-preloaded', '~> 3.0'
gem 'rdf-normalize', '~> 0.3'
gem 'redcarpet', '~> 3.4'
group :development, :test do
gem 'fabrication', '~> 2.20'
gem 'fuubar', '~> 2.3'
@ -108,7 +111,7 @@ group :production, :test do
end
group :test do
gem 'capybara', '~> 3.16'
gem 'capybara', '~> 3.20'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 1.9'
gem 'microformats', '~> 4.1'
@ -116,7 +119,7 @@ group :test do
gem 'rspec-sidekiq', '~> 3.0'
gem 'simplecov', '~> 0.16', require: false
gem 'webmock', '~> 3.5'
gem 'parallel_tests', '~> 2.28'
gem 'parallel_tests', '~> 2.29'
end
group :development do
@ -124,14 +127,14 @@ group :development do
gem 'annotate', '~> 2.7'
gem 'better_errors', '~> 2.5'
gem 'binding_of_caller', '~> 0.7'
gem 'bullet', '~> 5.9'
gem 'bullet', '~> 6.0'
gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler'
gem 'rubocop', '~> 0.67', require: false
gem 'rubocop', '~> 0.69', require: false
gem 'brakeman', '~> 4.5', require: false
gem 'bundler-audit', '~> 0.6', require: false
gem 'scss_lint', '~> 0.57', require: false
gem 'scss_lint', '~> 0.58', require: false
gem 'capistrano', '~> 3.11'
gem 'capistrano-rails', '~> 1.4'
@ -143,7 +146,7 @@ group :development do
end
group :production do
gem 'lograge', '~> 0.10'
gem 'lograge', '~> 0.11'
gem 'redis-rails', '~> 5.0'
end

View File

@ -66,8 +66,8 @@ GEM
public_suffix (>= 2.0.2, < 4.0)
airbrussh (1.3.0)
sshkit (>= 1.6.1, != 1.7.0)
annotate (2.7.4)
activerecord (>= 3.2, < 6.0)
annotate (2.7.5)
activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 13.0)
arel (9.0.0)
ast (2.4.0)
@ -75,18 +75,18 @@ GEM
encryptor (~> 3.0.0)
av (0.9.0)
cocaine (~> 0.5.3)
aws-eventstream (1.0.2)
aws-partitions (1.147.0)
aws-sdk-core (3.48.3)
aws-eventstream (1.0.3)
aws-partitions (1.162.0)
aws-sdk-core (3.52.1)
aws-eventstream (~> 1.0, >= 1.0.2)
aws-partitions (~> 1.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.16.0)
aws-sdk-core (~> 3, >= 3.48.2)
aws-sdk-kms (1.20.0)
aws-sdk-core (~> 3, >= 3.52.1)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.36.0)
aws-sdk-core (~> 3, >= 3.48.2)
aws-sdk-s3 (1.39.0)
aws-sdk-core (~> 3, >= 3.52.1)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.0)
aws-sigv4 (1.1.0)
@ -99,12 +99,14 @@ GEM
rack (>= 0.9.0)
binding_of_caller (0.8.0)
debug_inspector (>= 0.0.1)
bootsnap (1.4.3)
blurhash (0.1.3)
ffi (~> 1.10.0)
bootsnap (1.4.4)
msgpack (~> 1.0)
brakeman (4.5.0)
brakeman (4.5.1)
browser (2.5.3)
builder (3.2.3)
bullet (5.9.0)
bullet (6.0.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundler-audit (0.6.1)
@ -127,13 +129,14 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (3.16.1)
capybara (3.20.0)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (~> 1.2)
uglifier
xpath (~> 3.2)
case_transform (0.2)
activesupport
@ -143,8 +146,8 @@ GEM
elasticsearch (>= 2.0.0)
elasticsearch-dsl
chunky_png (1.3.10)
cld3 (3.2.3)
ffi (>= 1.1.0, < 1.10.0)
cld3 (3.2.4)
ffi (>= 1.1.0, < 1.11.0)
climate_control (0.2.0)
cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0)
@ -184,8 +187,8 @@ GEM
docile (1.3.0)
domain_name (0.5.20180417)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.0.2)
railties (>= 4.2)
doorkeeper (5.1.0)
railties (>= 5)
dotenv (2.7.2)
dotenv-rails (2.7.2)
dotenv (= 2.7.2)
@ -205,14 +208,15 @@ GEM
et-orbi (1.1.6)
tzinfo
excon (0.62.0)
fabrication (2.20.1)
execjs (2.7.0)
fabrication (2.20.2)
faker (1.9.3)
i18n (>= 0.7)
faraday (0.15.0)
multipart-post (>= 1.2, < 3)
fast_blank (1.0.0)
fastimage (2.1.5)
ffi (1.9.25)
ffi (1.10.0)
fog-core (2.1.0)
builder
excon (~> 0.58)
@ -320,7 +324,7 @@ GEM
letter_opener (~> 1.0)
railties (>= 3.2)
link_header (0.0.8)
lograge (0.10.0)
lograge (0.11.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
@ -348,7 +352,7 @@ GEM
mini_mime (1.0.1)
mini_portile2 (2.4.0)
minitest (5.11.3)
msgpack (1.2.9)
msgpack (1.2.10)
multi_json (1.13.1)
multipart-post (2.0.0)
necromancer (0.4.0)
@ -357,7 +361,7 @@ GEM
net-ssh (>= 2.6.5)
net-ssh (5.0.2)
nio4r (2.3.1)
nokogiri (1.10.2)
nokogiri (1.10.3)
mini_portile2 (~> 2.4.0)
nokogumbo (2.0.0)
nokogiri (~> 1.8, >= 1.8.4)
@ -366,7 +370,7 @@ GEM
concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.7.11)
oj (3.7.12)
omniauth (1.9.0)
hashie (>= 3.4.6, < 3.7.0)
rack (>= 1.6.2, < 3)
@ -393,9 +397,9 @@ GEM
av (~> 0.9.0)
paperclip (>= 2.5.2)
parallel (1.17.0)
parallel_tests (2.28.0)
parallel_tests (2.29.0)
parallel
parser (2.6.2.0)
parser (2.6.3.0)
ast (~> 2.4.0)
pastel (0.7.2)
equatable (~> 0.5.0)
@ -420,14 +424,13 @@ GEM
pry (~> 0.10)
pry-rails (0.3.9)
pry (>= 0.10.4)
psych (3.1.0)
public_suffix (3.0.3)
puma (3.12.1)
pundit (2.0.1)
activesupport (>= 3.0.0)
raabro (1.1.6)
rack (2.0.7)
rack-attack (5.4.2)
rack-attack (6.0.0)
rack (>= 1.0, < 3)
rack-cors (1.0.3)
rack-protection (2.0.5)
@ -472,14 +475,15 @@ GEM
rainbow (3.0.0)
rake (12.3.2)
rb-fsevent (0.10.3)
rb-inotify (0.9.10)
ffi (>= 0.5.0, < 2)
rb-inotify (0.10.0)
ffi (~> 1.0)
rdf (3.0.9)
hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.3.3)
rdf (>= 2.2, < 4.0)
redis (4.1.0)
redcarpet (3.4.0)
redis (4.1.1)
redis-actionpack (5.0.2)
actionpack (>= 4.0, < 6)
redis-rack (>= 1, < 3)
@ -498,7 +502,7 @@ GEM
redis-store (>= 1.2, < 2)
redis-store (1.5.0)
redis (>= 2.2, < 5)
regexp_parser (1.3.0)
regexp_parser (1.5.0)
request_store (1.4.1)
rack (>= 1.4)
responders (2.4.1)
@ -528,14 +532,13 @@ GEM
rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0)
rspec-support (3.8.0)
rubocop (0.67.1)
rubocop (0.69.0)
jaro_winkler (~> 1.5.1)
parallel (~> 1.10)
parser (>= 2.5, != 2.5.1.1)
psych (>= 3.1.0)
parser (>= 2.6)
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 1.6)
unicode-display_width (>= 1.4.0, < 1.7)
ruby-progressbar (1.10.0)
ruby-saml (1.9.0)
nokogiri (>= 1.5.10)
@ -546,15 +549,15 @@ GEM
crass (~> 1.0.2)
nokogiri (>= 1.8.0)
nokogumbo (~> 2.0)
sass (3.6.0)
sass (3.7.4)
sass-listen (~> 4.0.0)
sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
scss_lint (0.57.1)
scss_lint (0.58.0)
rake (>= 0.9, < 13)
sass (~> 3.5, >= 3.5.5)
sidekiq (5.2.5)
sidekiq (5.2.7)
connection_pool (~> 2.2, >= 2.2.2)
rack (>= 1.5.0)
rack-protection (>= 1.5.0)
@ -566,7 +569,7 @@ GEM
rufus-scheduler (~> 3.2)
sidekiq (>= 3)
tilt (>= 1.4.0)
sidekiq-unique-jobs (6.0.12)
sidekiq-unique-jobs (6.0.13)
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 4.0, < 7.0)
thor (~> 0)
@ -627,10 +630,12 @@ GEM
thread_safe (~> 0.1)
tzinfo-data (1.2019.1)
tzinfo (>= 1.0.0)
uglifier (4.1.20)
execjs (>= 0.3.0, < 3)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.5)
unicode-display_width (1.5.0)
unicode-display_width (1.6.0)
uniform_notifier (1.12.1)
warden (1.2.8)
rack (>= 2.0.6)
@ -642,7 +647,7 @@ GEM
activesupport (>= 4.2)
rack-proxy (>= 0.6.1)
railties (>= 4.2)
webpush (0.3.7)
webpush (0.3.8)
hkdf (~> 0.2)
jwt (~> 2.0)
websocket-driver (0.7.0)
@ -660,29 +665,30 @@ DEPENDENCIES
active_record_query_trace (~> 1.6)
addressable (~> 2.6)
annotate (~> 2.7)
aws-sdk-s3 (~> 1.36)
aws-sdk-s3 (~> 1.39)
better_errors (~> 2.5)
binding_of_caller (~> 0.7)
blurhash (~> 0.1)
bootsnap (~> 1.4)
brakeman (~> 4.5)
browser
bullet (~> 5.9)
bullet (~> 6.0)
bundler-audit (~> 0.6)
capistrano (~> 3.11)
capistrano-rails (~> 1.4)
capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0)
capybara (~> 3.16)
capybara (~> 3.20)
charlock_holmes (~> 0.7.6)
chewy (~> 5.0)
cld3 (~> 3.2.3)
cld3 (~> 3.2.4)
climate_control (~> 0.2)
concurrent-ruby
derailed_benchmarks
devise (~> 4.6)
devise-two-factor (~> 3.0)
devise_pam_authenticatable2 (~> 9.2)
doorkeeper (~> 5.0)
doorkeeper (~> 5.1)
dotenv-rails (~> 2.7)
fabrication (~> 2.20)
faker (~> 1.9)
@ -709,7 +715,7 @@ DEPENDENCIES
letter_opener (~> 1.7)
letter_opener_web (~> 1.3)
link_header (~> 0.0)
lograge (~> 0.10)
lograge (~> 0.11)
makara (~> 0.4)
mario-redis-lock (~> 1.2)
memory_profiler
@ -726,7 +732,7 @@ DEPENDENCIES
ox (~> 2.10)
paperclip (~> 6.0)
paperclip-av-transcoder (~> 0.6)
parallel_tests (~> 2.28)
parallel_tests (~> 2.29)
pg (~> 1.1)
pghero (~> 2.2)
pkg-config (~> 1.3)
@ -737,22 +743,23 @@ DEPENDENCIES
pry-rails (~> 0.3)
puma (~> 3.12)
pundit (~> 2.0)
rack-attack (~> 5.4)
rack-attack (~> 6.0)
rack-cors (~> 1.0)
rails (~> 5.2.3)
rails-controller-testing (~> 1.0)
rails-i18n (~> 5.1)
rails-settings-cached (~> 0.6)
rdf-normalize (~> 0.3)
redcarpet (~> 3.4)
redis (~> 4.1)
redis-namespace (~> 1.5)
redis-rails (~> 5.0)
rqrcode (~> 0.10)
rspec-rails (~> 3.8)
rspec-sidekiq (~> 3.0)
rubocop (~> 0.67)
rubocop (~> 0.69)
sanitize (~> 5.0)
scss_lint (~> 0.57)
scss_lint (~> 0.58)
sidekiq (~> 5.2)
sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 3.0)

View File

@ -30,6 +30,7 @@ module Admin
@trending_hashtags = TrendingTags.get(7)
@profile_directory = Setting.profile_directory
@timeline_preview = Setting.timeline_preview
@keybase_integration = Setting.enable_keybase
end
private

View File

@ -13,13 +13,25 @@ module Admin
authorize :domain_block, :create?
@domain_block = DomainBlock.new(resource_params)
existing_domain_block = resource_params[:domain].present? ? DomainBlock.find_by(domain: resource_params[:domain]) : nil
if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id)
log_action :create, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
else
if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
@domain_block.save
flash[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
@domain_block.errors[:domain].clear
render :new
else
if existing_domain_block.present?
@domain_block = existing_domain_block
@domain_block.update(resource_params)
end
if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id)
log_action :create, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
else
render :new
end
end
end
@ -29,7 +41,7 @@ module Admin
def destroy
authorize @domain_block, :destroy?
UnblockDomainService.new.call(@domain_block, retroactive_unblock?)
UnblockDomainService.new.call(@domain_block)
log_action :destroy, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.destroyed_msg')
end
@ -41,11 +53,7 @@ module Admin
end
def resource_params
params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports, :retroactive)
end
def retroactive_unblock?
ActiveRecord::Type.lookup(:boolean).cast(resource_params[:retroactive])
params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports)
end
end
end

View File

@ -9,6 +9,8 @@ class Api::BaseController < ApplicationController
skip_before_action :store_current_location
skip_before_action :check_user_permissions
before_action :set_cache_headers
protect_from_forgery with: :null_session
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
@ -88,4 +90,8 @@ class Api::BaseController < ApplicationController
def authorize_if_got_token!(*scopes)
doorkeeper_authorize!(*scopes) if doorkeeper_token
end
def set_cache_headers
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
end
end

View File

@ -3,6 +3,8 @@
class Api::V1::CustomEmojisController < Api::BaseController
respond_to :json
skip_before_action :set_cache_headers
def index
render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do
ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer)

View File

@ -2,6 +2,7 @@
class Api::V1::Instances::ActivityController < Api::BaseController
before_action :require_enabled_api!
skip_before_action :set_cache_headers
respond_to :json

View File

@ -2,6 +2,7 @@
class Api::V1::Instances::PeersController < Api::BaseController
before_action :require_enabled_api!
skip_before_action :set_cache_headers
respond_to :json

View File

@ -2,6 +2,7 @@
class Api::V1::InstancesController < Api::BaseController
respond_to :json
skip_before_action :set_cache_headers
def show
render_cached_json('api:v1:instances', expires_in: 5.minutes) do

View File

@ -54,6 +54,7 @@ class Api::V1::StatusesController < Api::BaseController
scheduled_at: status_params[:scheduled_at],
application: doorkeeper_token.application,
poll: status_params[:poll],
content_type: status_params[:content_type],
idempotency: request.headers['Idempotency-Key'])
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
@ -65,7 +66,7 @@ class Api::V1::StatusesController < Api::BaseController
RemovalWorker.perform_async(@status.id)
render_empty
render json: @status, serializer: REST::StatusSerializer, source_requested: true
end
private
@ -85,6 +86,7 @@ class Api::V1::StatusesController < Api::BaseController
:spoiler_text,
:visibility,
:scheduled_at,
:content_type,
media_ids: [],
poll: [
:multiple,

View File

@ -96,7 +96,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
def set_invite
@invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
@invite = invite&.valid_for_use? ? invite : nil
end
def determine_layout

View File

@ -64,7 +64,7 @@ class HomeController < ApplicationController
if request.path.start_with?('/web')
new_user_session_path
elsif single_user_mode?
short_account_path(Account.local.where(suspended: false).first)
short_account_path(Account.local.without_suspended.first)
else
about_path
end

View File

@ -5,6 +5,7 @@ class Settings::IdentityProofsController < Settings::BaseController
before_action :authenticate_user!
before_action :check_required_params, only: :new
before_action :check_enabled, only: :new
def index
@proofs = AccountIdentityProof.where(account: current_account).order(provider: :asc, provider_username: :asc)
@ -41,6 +42,10 @@ class Settings::IdentityProofsController < Settings::BaseController
private
def check_enabled
not_found unless Setting.enable_keybase
end
def check_required_params
redirect_to settings_identity_proofs_path unless [:provider, :provider_username, :username, :token].all? { |k| params[k].present? }
end

View File

@ -21,7 +21,7 @@ class Settings::NotificationsController < Settings::BaseController
def user_settings_params
params.require(:user).permit(
notification_emails: %i(follow follow_request reblog favourite mention digest report),
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
interactions: %i(must_be_follower must_be_following must_be_following_dm must_be_one_day_old)
)
end

View File

@ -46,6 +46,7 @@ class Settings::PreferencesController < Settings::BaseController
:setting_hide_followers_count,
:setting_aggregate_reblogs,
:setting_show_application,
:setting_default_content_type,
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
interactions: %i(must_be_follower must_be_following must_be_following_dm must_be_one_day_old)
)

View File

@ -28,7 +28,11 @@ class StatusesController < ApplicationController
respond_to do |format|
format.html do
use_pack 'public'
mark_cacheable! unless user_signed_in?
unless user_signed_in?
skip_session!
expires_in 10.seconds, public: true
end
@body_classes = 'with-modals'

View File

@ -16,6 +16,12 @@ class StreamEntriesController < ApplicationController
respond_to do |format|
format.html do
use_pack 'public'
unless user_signed_in?
skip_session!
expires_in 5.minutes, public: true
end
redirect_to short_account_status_url(params[:account_username], @stream_entry.activity) if @type == 'status'
end

View File

@ -2,8 +2,16 @@
module WellKnown
class KeybaseProofConfigController < ActionController::Base
before_action :check_enabled
def show
render json: {}, serializer: ProofProvider::Keybase::ConfigSerializer
end
private
def check_enabled
head 404 unless Setting.enable_keybase
end
end
end

View File

@ -41,3 +41,26 @@ delegate(document, '.modal-button', 'click', e => {
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
});
const getProfileAvatarAnimationHandler = (swapTo) => {
//animate avatar gifs on the profile page when moused over
return ({ target }) => {
const swapSrc = target.getAttribute(swapTo);
//only change the img source if autoplay is off and the image src is actually different
if(target.getAttribute('data-autoplay') === 'false' && target.src !== swapSrc) {
target.src = swapSrc;
}
};
};
delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original'));
delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static'));
delegate(document, '#account_header', 'change', ({ target }) => {
const header = document.querySelector('.card .card__img img');
const [file] = target.files || [];
const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc;
header.src = url;
});

View File

@ -46,6 +46,7 @@ export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE';
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
@ -147,6 +148,7 @@ export function submitCompose(routerHistory) {
}
api(getState).post('/api/v1/statuses', {
status,
content_type: getState().getIn(['compose', 'content_type']),
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
media_ids: media.map(item => item.get('id')),
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
@ -227,8 +229,8 @@ export function uploadCompose(files) {
return function (dispatch, getState) {
const uploadLimit = 4;
const media = getState().getIn(['compose', 'media_attachments']);
const total = Array.from(files).reduce((a, v) => a + v.size, 0);
const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
if (files.length + media.size > uploadLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit));
@ -248,6 +250,8 @@ export function uploadCompose(files) {
resizeImage(f).then(file => {
const data = new FormData();
data.append('file', file);
// Account for disparity in size of original image and resized data
total += file.size - f.size;
return api(getState).post('/api/v1/media', data, {
onUploadProgress: function({ loaded }){
@ -405,7 +409,7 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
};
};
export function selectComposeSuggestion(position, token, suggestion) {
export function selectComposeSuggestion(position, token, suggestion, path) {
return (dispatch, getState) => {
let completion;
if (typeof suggestion === 'object' && suggestion.id) {
@ -422,6 +426,7 @@ export function selectComposeSuggestion(position, token, suggestion) {
position,
token,
completion,
path,
});
};
};
@ -514,6 +519,13 @@ export function changeComposeVisibility(value) {
};
};
export function changeComposeContentType(value) {
return {
type: COMPOSE_CONTENT_TYPE_CHANGE,
value,
};
};
export function insertEmojiCompose(position, emoji) {
return {
type: COMPOSE_EMOJI_INSERT,

View File

@ -71,10 +71,12 @@ export function fetchStatusFail(id, error, skipLoading) {
};
};
export function redraft(status) {
export function redraft(status, raw_text, content_type) {
return {
type: REDRAFT,
status,
raw_text,
content_type,
};
};
@ -88,12 +90,12 @@ export function deleteStatus(id, router, withRedraft = false) {
dispatch(deleteStatusRequest(id));
api(getState).delete(`/api/v1/statuses/${id}`).then(() => {
api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id));
if (withRedraft) {
dispatch(redraft(status));
dispatch(redraft(status, response.data.text, response.data.content_type));
if (!getState().getIn(['compose', 'mounted'])) {
router.push('/statuses/new');

View File

@ -0,0 +1,52 @@
import api from 'flavours/glitch/util/api';
import { importFetchedAccounts } from './importer';
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
export function fetchSuggestions() {
return (dispatch, getState) => {
dispatch(fetchSuggestionsRequest());
api(getState).get('/api/v1/suggestions').then(response => {
dispatch(importFetchedAccounts(response.data));
dispatch(fetchSuggestionsSuccess(response.data));
}).catch(error => dispatch(fetchSuggestionsFail(error)));
};
};
export function fetchSuggestionsRequest() {
return {
type: SUGGESTIONS_FETCH_REQUEST,
skipLoading: true,
};
};
export function fetchSuggestionsSuccess(accounts) {
return {
type: SUGGESTIONS_FETCH_SUCCESS,
accounts,
skipLoading: true,
};
};
export function fetchSuggestionsFail(error) {
return {
type: SUGGESTIONS_FETCH_FAIL,
error,
skipLoading: true,
skipAlert: true,
};
};
export const dismissSuggestion = accountId => (dispatch, getState) => {
dispatch({
type: SUGGESTIONS_DISMISS,
id: accountId,
});
api(getState).delete(`/api/v1/suggestions/${accountId}`);
};

View File

@ -97,7 +97,7 @@ export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done =
export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {

View File

@ -31,6 +31,9 @@ export default class Account extends ImmutablePureComponent {
intl: PropTypes.object.isRequired,
hidden: PropTypes.bool,
small: PropTypes.bool,
actionIcon: PropTypes.string,
actionTitle: PropTypes.string,
onActionClick: PropTypes.func,
};
handleFollow = () => {
@ -53,12 +56,19 @@ export default class Account extends ImmutablePureComponent {
this.props.onMuteNotifications(this.props.account, false);
}
handleAction = () => {
this.props.onActionClick(this.props.account);
}
render () {
const {
account,
hidden,
intl,
small,
onActionClick,
actionIcon,
actionTitle,
} = this.props;
if (!account) {
@ -76,7 +86,9 @@ export default class Account extends ImmutablePureComponent {
let buttons;
if (account.get('id') !== me && !small && account.get('relationship', null) !== null) {
if (onActionClick && actionIcon) {
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
} else if (account.get('id') !== me && !small && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);

View File

@ -0,0 +1,42 @@
import React from 'react';
import PropTypes from 'prop-types';
import unicodeMapping from 'flavours/glitch/util/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='emoji'>
<img
className='emojione'
src={url}
alt={emoji.native || emoji.colons}
/>
{emoji.colons}
</div>
);
}
}

View File

@ -0,0 +1,229 @@
import React from 'react';
import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { isRtl } from 'flavours/glitch/util/rtl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import classNames from 'classnames';
import { List as ImmutableList } from 'immutable';
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
let word;
let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
let right = str.slice(caretPosition).search(/[\s\u200B]/);
if (right < 0) {
word = str.slice(left);
} else {
word = str.slice(left, right + caretPosition);
}
if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) {
return [null, null];
}
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left, word];
} else {
return [null, null];
}
};
export default class AutosuggestInput extends ImmutablePureComponent {
static propTypes = {
value: PropTypes.string,
suggestions: ImmutablePropTypes.list,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
onSuggestionSelected: PropTypes.func.isRequired,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
autoFocus: PropTypes.bool,
className: PropTypes.string,
id: PropTypes.string,
searchTokens: PropTypes.list,
maxLength: PropTypes.number,
};
static defaultProps = {
autoFocus: true,
searchTokens: ImmutableList(['@', ':', '#']),
};
state = {
suggestionsHidden: true,
focused: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,
};
onChange = (e) => {
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens);
if (token !== null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
this.props.onSuggestionsFetchRequested(token);
} else if (token === null) {
this.setState({ lastToken: null });
this.props.onSuggestionsClearRequested();
}
this.props.onChange(e);
}
onKeyDown = (e) => {
const { suggestions, disabled } = this.props;
const { selectedSuggestion, suggestionsHidden } = this.state;
if (disabled) {
e.preventDefault();
return;
}
if (e.which === 229 || e.isComposing) {
// Ignore key events during text composition
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
return;
}
switch(e.key) {
case 'Escape':
if (suggestions.size === 0 || suggestionsHidden) {
document.querySelector('.ui').parentElement.focus();
} else {
e.preventDefault();
this.setState({ suggestionsHidden: true });
}
break;
case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
}
break;
case 'ArrowUp':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
}
break;
case 'Enter':
case 'Tab':
// Select suggestion
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
}
break;
}
if (e.defaultPrevented || !this.props.onKeyDown) {
return;
}
this.props.onKeyDown(e);
}
onBlur = () => {
this.setState({ suggestionsHidden: true, focused: false });
}
onFocus = () => {
this.setState({ focused: true });
}
onSuggestionClick = (e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.input.focus();
}
componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
this.setState({ suggestionsHidden: false });
}
}
setInput = (c) => {
this.input = c;
}
renderSuggestion = (suggestion, i) => {
const { selectedSuggestion } = this.state;
let inner, key;
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
} else if (suggestion[0] === '#') {
inner = suggestion;
key = suggestion;
} 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 () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props;
const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' };
if (isRtl(value)) {
style.direction = 'rtl';
}
return (
<div className='autosuggest-input'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<input
type='text'
ref={this.setInput}
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
style={style}
aria-autocomplete='list'
id={id}
className={className}
maxLength={maxLength}
/>
</label>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(this.renderSuggestion)}
</div>
</div>
);
}
}

View File

@ -0,0 +1,230 @@
import React from 'react';
import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { isRtl } from 'flavours/glitch/util/rtl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import classNames from 'classnames';
const textAtCursorMatchesToken = (str, caretPosition) => {
let word;
let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
let right = str.slice(caretPosition).search(/[\s\u200B]/);
if (right < 0) {
word = str.slice(left);
} else {
word = str.slice(left, right + caretPosition);
}
if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) {
return [null, null];
}
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left, word];
} else {
return [null, null];
}
};
export default class AutosuggestTextarea extends ImmutablePureComponent {
static propTypes = {
value: PropTypes.string,
suggestions: ImmutablePropTypes.list,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
onSuggestionSelected: PropTypes.func.isRequired,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired,
autoFocus: PropTypes.bool,
};
static defaultProps = {
autoFocus: true,
};
state = {
suggestionsHidden: true,
focused: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,
};
onChange = (e) => {
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
if (token !== null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
this.props.onSuggestionsFetchRequested(token);
} else if (token === null) {
this.setState({ lastToken: null });
this.props.onSuggestionsClearRequested();
}
this.props.onChange(e);
}
onKeyDown = (e) => {
const { suggestions, disabled } = this.props;
const { selectedSuggestion, suggestionsHidden } = this.state;
if (disabled) {
e.preventDefault();
return;
}
if (e.which === 229 || e.isComposing) {
// Ignore key events during text composition
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
return;
}
switch(e.key) {
case 'Escape':
if (suggestions.size === 0 || suggestionsHidden) {
document.querySelector('.ui').parentElement.focus();
} else {
e.preventDefault();
this.setState({ suggestionsHidden: true });
}
break;
case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
}
break;
case 'ArrowUp':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
}
break;
case 'Enter':
case 'Tab':
// Select suggestion
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
}
break;
}
if (e.defaultPrevented || !this.props.onKeyDown) {
return;
}
this.props.onKeyDown(e);
}
onBlur = () => {
this.setState({ suggestionsHidden: true, focused: false });
}
onFocus = () => {
this.setState({ focused: true });
}
onSuggestionClick = (e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.textarea.focus();
}
componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
this.setState({ suggestionsHidden: false });
}
}
setTextarea = (c) => {
this.textarea = c;
}
onPaste = (e) => {
if (e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files);
e.preventDefault();
}
}
renderSuggestion = (suggestion, i) => {
const { selectedSuggestion } = this.state;
let inner, key;
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
} else if (suggestion[0] === '#') {
inner = suggestion;
key = suggestion;
} 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 () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' };
if (isRtl(value)) {
style.direction = 'rtl';
}
return (
<div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<Textarea
inputRef={this.setTextarea}
className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
onPaste={this.onPaste}
style={style}
aria-autocomplete='list'
/>
</label>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(this.renderSuggestion)}
</div>
</div>
);
}
}

View File

@ -8,10 +8,15 @@ export default class ColumnBackButton extends React.PureComponent {
router: PropTypes.object,
};
handleClick = () => {
handleClick = (event) => {
// if history is exhausted, or we would leave mastodon, just go to root.
if (window.history.state) {
this.context.router.history.goBack();
const state = this.context.router.history.location.state;
if (event.shiftKey && state && state.mastodonBackSteps) {
this.context.router.history.go(-state.mastodonBackSteps);
} else {
this.context.router.history.goBack();
}
} else {
this.context.router.history.push('/');
}

View File

@ -8,10 +8,15 @@ export default class ColumnBackButtonSlim extends React.PureComponent {
router: PropTypes.object,
};
handleClick = () => {
handleClick = (event) => {
// if history is exhausted, or we would leave mastodon, just go to root.
if (window.history.state) {
this.context.router.history.goBack();
const state = this.context.router.history.location.state;
if (event.shiftKey && state && state.mastodonBackSteps) {
this.context.router.history.go(-state.mastodonBackSteps);
} else {
this.context.router.history.goBack();
}
} else {
this.context.router.history.push('/');
}

View File

@ -47,10 +47,15 @@ export default class ColumnHeader extends React.PureComponent {
animatingNCD: false,
};
historyBack = () => {
historyBack = (skip) => {
// if history is exhausted, or we would leave mastodon, just go to root.
if (window.history.state) {
this.context.router.history.goBack();
const state = this.context.router.history.location.state;
if (skip && state && state.mastodonBackSteps) {
this.context.router.history.go(-state.mastodonBackSteps);
} else {
this.context.router.history.goBack();
}
} else {
this.context.router.history.push('/');
}
@ -73,8 +78,8 @@ export default class ColumnHeader extends React.PureComponent {
this.props.onMove(1);
}
handleBackClick = () => {
this.historyBack();
handleBackClick = (event) => {
this.historyBack(event.shiftKey);
}
handleTransitionEnd = () => {

View File

@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from 'flavours/glitch/util/is_mobile';
import classNames from 'classnames';
import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state';
import { decode } from 'blurhash';
const messages = defineMessages({
hidden: {
@ -41,6 +42,7 @@ class Item extends React.PureComponent {
letterbox: PropTypes.bool,
onClick: PropTypes.func.isRequired,
displayWidth: PropTypes.number,
visible: PropTypes.bool.isRequired,
};
static defaultProps = {
@ -49,6 +51,10 @@ class Item extends React.PureComponent {
size: 1,
};
state = {
loaded: false,
};
handleMouseEnter = (e) => {
if (this.hoverToPlay()) {
e.target.play();
@ -82,13 +88,40 @@ class Item extends React.PureComponent {
e.stopPropagation();
}
handleMouseDown = (e) => {
e.preventDefault();
e.stopPropagation();
componentDidMount () {
if (this.props.attachment.get('blurhash')) {
this._decode();
}
}
componentDidUpdate (prevProps) {
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
this._decode();
}
}
_decode () {
const hash = this.props.attachment.get('blurhash');
const pixels = decode(hash, 32, 32);
if (pixels) {
const ctx = this.canvas.getContext('2d');
const imageData = new ImageData(pixels, 32, 32);
ctx.putImageData(imageData, 0, 0);
}
}
setCanvasRef = c => {
this.canvas = c;
}
handleImageLoad = () => {
this.setState({ loaded: true });
}
render () {
const { attachment, index, size, standalone, letterbox, displayWidth } = this.props;
const { attachment, index, size, standalone, letterbox, displayWidth, visible } = this.props;
let width = 50;
let height = 100;
@ -141,12 +174,20 @@ class Item extends React.PureComponent {
let thumbnail = '';
if (attachment.get('type') === 'image') {
if (attachment.get('type') === 'unknown') {
return (
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
</a>
</div>
);
} else if (attachment.get('type') === 'image') {
const previewUrl = attachment.get('preview_url');
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
@ -173,6 +214,7 @@ class Item extends React.PureComponent {
alt={attachment.get('description')}
title={attachment.get('description')}
style={{ objectPosition: letterbox ? null : `${x}% ${y}%` }}
onLoad={this.handleImageLoad}
/>
</a>
);
@ -190,7 +232,6 @@ class Item extends React.PureComponent {
onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
onMouseDown={this.handleMouseDown}
autoPlay={autoPlay}
loop
muted
@ -203,7 +244,8 @@ class Item extends React.PureComponent {
return (
<div className={classNames('media-gallery__item', { standalone, letterbox })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
{thumbnail}
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
{visible && thumbnail}
</div>
);
}
@ -263,6 +305,7 @@ export default class MediaGallery extends React.PureComponent {
this.node = node;
if (node && node.offsetWidth && node.offsetWidth != this.state.width) {
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
this.setState({
width: node.offsetWidth,
});
@ -281,7 +324,7 @@ export default class MediaGallery extends React.PureComponent {
const width = this.state.width || defaultWidth;
let children;
let children, spoilerButton;
const style = {};
@ -295,40 +338,32 @@ export default class MediaGallery extends React.PureComponent {
return (<div className={computedClass} ref={this.handleRef}></div>);
}
if (!visible) {
let warning = <FormattedMessage {...(sensitive ? messages.warning : messages.hidden)} />;
if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
} else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} visible={visible} />);
}
children = (
<button className='media-spoiler' type='button' onClick={this.handleOpen}>
<span className='media-spoiler__warning'>{warning}</span>
<span className='media-spoiler__trigger'><FormattedMessage {...messages.toggle} /></span>
if (visible) {
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
} else {
spoilerButton = (
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
</button>
);
} else {
if (this.isStandaloneEligible()) {
children = <Item standalone attachment={media.get(0)} onClick={this.handleClick} displayWidth={width} />;
} else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} />);
}
}
return (
<div className={computedClass} style={style} ref={this.handleRef}>
{visible ? (
<div className='sensitive-info'>
<IconButton
icon='eye'
onClick={this.handleOpen}
overlay
title={intl.formatMessage(messages.toggle_visible)}
/>
{sensitive ? (
<span className='sensitive-marker'>
<FormattedMessage {...messages.sensitive} />
</span>
) : null}
</div>
) : null}
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
{spoilerButton}
{visible && sensitive && (
<span className='sensitive-marker'>
<FormattedMessage {...messages.sensitive} />
</span>
)}
</div>
{children}
</div>

View File

@ -40,7 +40,7 @@ export default class ModalRoot extends React.PureComponent {
this.setState({ revealed: false });
}
if (!nextProps.children && !!this.props.children) {
this.activeElement.focus();
this.activeElement.focus({ preventScroll: true });
this.activeElement = null;
}
}

View File

@ -24,7 +24,9 @@ export default class Permalink extends React.PureComponent {
if (this.context.router) {
e.preventDefault();
this.context.router.history.push(this.props.to);
let state = {...this.context.router.history.location.state};
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
this.context.router.history.push(this.props.to, state);
}
}
}

View File

@ -53,6 +53,7 @@ export default class Status extends ImmutablePureComponent {
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onBookmark: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func,
@ -295,7 +296,11 @@ export default class Status extends ImmutablePureComponent {
else if (e.shiftKey) {
this.setCollapsed(true);
document.getSelection().removeAllRanges();
} else router.history.push(destination);
} else {
let state = {...router.history.location.state};
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
router.history.push(destination, state);
}
e.preventDefault();
}
}
@ -304,7 +309,9 @@ export default class Status extends ImmutablePureComponent {
if (this.context.router && e.button === 0) {
const id = e.currentTarget.getAttribute('data-id');
e.preventDefault();
this.context.router.history.push(`/accounts/${id}`);
let state = {...this.context.router.history.location.state};
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
this.context.router.history.push(`/accounts/${id}`, state);
}
}
@ -331,17 +338,25 @@ export default class Status extends ImmutablePureComponent {
this.props.onReblog(this.props.status, e);
}
handleHotkeyBookmark = e => {
this.props.onBookmark(this.props.status, e);
}
handleHotkeyMention = e => {
e.preventDefault();
this.props.onMention(this.props.status.get('account'), this.context.router.history);
}
handleHotkeyOpen = () => {
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
let state = {...this.context.router.history.location.state};
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state);
}
handleHotkeyOpenProfile = () => {
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
let state = {...this.context.router.history.location.state};
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
}
handleHotkeyMoveUp = e => {
@ -352,6 +367,14 @@ export default class Status extends ImmutablePureComponent {
this.props.onMoveDown(this.props.containerId || this.props.id, e.target.getAttribute('data-featured'));
}
handleHotkeyCollapse = e => {
if (!this.props.settings.getIn(['collapsed', 'enabled']))
return;
this.setCollapsed(!this.state.isCollapsed);
}
handleRef = c => {
this.node = c;
}
@ -456,6 +479,7 @@ export default class Status extends ImmutablePureComponent {
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
{Component => (<Component
preview={video.get('preview_url')}
blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
inline
@ -540,6 +564,8 @@ export default class Status extends ImmutablePureComponent {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleSpoiler: this.handleExpandedToggle,
bookmark: this.handleHotkeyBookmark,
toggleCollapse: this.handleHotkeyCollapse,
};
const computedClass = classNames('status', `status-${status.get('visibility')}`, {

View File

@ -150,7 +150,9 @@ export default class StatusActionBar extends ImmutablePureComponent {
}
handleOpen = () => {
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
let state = {...this.context.router.history.location.state};
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state);
}
handleEmbed = () => {

View File

@ -213,6 +213,7 @@ export default class StatusContent extends React.PureComponent {
style={directionStyle}
tabIndex={!hidden ? 0 : null}
dangerouslySetInnerHTML={content}
className='status__content__text'
lang={status.get('language')}
/>
{media}
@ -233,6 +234,7 @@ export default class StatusContent extends React.PureComponent {
ref={this.setRef}
dangerouslySetInnerHTML={content}
lang={status.get('language')}
className='status__content__text'
tabIndex='0'
/>
{media}
@ -245,7 +247,7 @@ export default class StatusContent extends React.PureComponent {
style={directionStyle}
tabIndex='0'
>
<div ref={this.setRef} dangerouslySetInnerHTML={content} lang={status.get('language')} tabIndex='0' />
<div ref={this.setRef} className='status__content__text' dangerouslySetInnerHTML={content} lang={status.get('language')} tabIndex='0' />
{media}
</div>
);

View File

@ -46,22 +46,28 @@ export default class StatusList extends ImmutablePureComponent {
handleMoveUp = (id, featured) => {
const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
this._selectChild(elementIndex);
this._selectChild(elementIndex, true);
}
handleMoveDown = (id, featured) => {
const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
this._selectChild(elementIndex);
this._selectChild(elementIndex, false);
}
handleLoadOlder = debounce(() => {
this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
}, 300, { leading: true })
_selectChild (index) {
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
_selectChild (index, align_top) {
const container = this.node.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (align_top && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
}

View File

@ -39,13 +39,6 @@ export default class Mastodon extends React.PureComponent {
window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
}
// Protocol handler
// Ask after 5 minutes
if (typeof navigator.registerProtocolHandler !== 'undefined') {
const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s';
window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000);
}
store.dispatch(showOnboardingOnce());
}

View File

@ -1,5 +1,5 @@
import { connect } from 'react-redux';
import Poll from 'mastodon/components/poll';
import Poll from 'flavours/glitch/components/poll';
const mapStateToProps = (state, { pollId }) => ({
poll: state.getIn(['polls', pollId]),

View File

@ -88,18 +88,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
},
onModalReblog (status) {
dispatch(reblog(status));
},
onReblog (status, e) {
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
if (e.shiftKey || !boostModal) {
this.onModalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
}
dispatch(reblog(status));
}
},
onReblog (status, e) {
if (e.shiftKey || !boostModal) {
this.onModalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
}
},

View File

@ -1,69 +1,148 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Permalink from 'flavours/glitch/components/permalink';
import { displayMedia } from 'flavours/glitch/util/initial_state';
import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state';
import classNames from 'classnames';
import { decode } from 'blurhash';
import { isIOS } from 'flavours/glitch/util/is_mobile';
export default class MediaItem extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
attachment: ImmutablePropTypes.map.isRequired,
displayWidth: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired,
};
state = {
visible: displayMedia !== 'hide_all' && !this.props.media.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
loaded: false,
};
handleClick = () => {
if (!this.state.visible) {
this.setState({ visible: true });
return true;
componentDidMount () {
if (this.props.attachment.get('blurhash')) {
this._decode();
}
}
return false;
componentDidUpdate (prevProps) {
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
this._decode();
}
}
_decode () {
const hash = this.props.attachment.get('blurhash');
const pixels = decode(hash, 32, 32);
if (pixels) {
const ctx = this.canvas.getContext('2d');
const imageData = new ImageData(pixels, 32, 32);
ctx.putImageData(imageData, 0, 0);
}
}
setCanvasRef = c => {
this.canvas = c;
}
handleImageLoad = () => {
this.setState({ loaded: true });
}
handleMouseEnter = e => {
if (this.hoverToPlay()) {
e.target.play();
}
}
handleMouseLeave = e => {
if (this.hoverToPlay()) {
e.target.pause();
e.target.currentTime = 0;
}
}
hoverToPlay () {
return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
}
handleClick = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (this.state.visible) {
this.props.onOpenMedia(this.props.attachment);
} else {
this.setState({ visible: true });
}
}
}
render () {
const { media } = this.props;
const { visible } = this.state;
const status = media.get('status');
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
const style = {};
const { attachment, displayWidth } = this.props;
const { visible, loaded } = this.state;
let label, icon, title;
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
const height = width;
const status = attachment.get('status');
const title = status.get('spoiler_text') || attachment.get('description');
if (media.get('type') === 'gifv') {
label = <span className='media-gallery__gifv__label'>GIF</span>;
}
let thumbnail = '';
if (visible) {
style.backgroundImage = `url(${media.get('preview_url')})`;
style.backgroundPosition = `${x}% ${y}%`;
title = media.get('description');
} else {
icon = (
<span className='account-gallery__item__icons'>
<i className='fa fa-eye-slash' />
</span>
if (attachment.get('type') === 'unknown') {
// Skip
} else if (attachment.get('type') === 'image') {
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
thumbnail = (
<img
src={attachment.get('preview_url')}
alt={attachment.get('description')}
title={attachment.get('description')}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={this.handleImageLoad}
/>
);
} else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) {
const autoPlay = !isIOS() && autoPlayGif;
thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
<video
className='media-gallery__item-gifv-thumbnail'
aria-label={attachment.get('description')}
title={attachment.get('description')}
role='application'
src={attachment.get('url')}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
autoPlay={autoPlay}
loop
muted
/>
<span className='media-gallery__gifv__label'>GIF</span>
</div>
);
title = status.get('spoiler_text') || media.get('description');
}
const icon = (
<span className='account-gallery__item__icons'>
<i className='fa fa-eye-slash' />
</span>
);
return (
<div className='account-gallery__item'>
<Permalink
to={`/statuses/${status.get('id')}`}
href={status.get('url')}
style={style}
title={title}
onInterceptClick={this.handleClick}
>
{icon}
{label}
</Permalink>
<div className='account-gallery__item' style={{ width, height }}>
<a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' onClick={this.handleClick} title={title}>
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
{visible ? thumbnail : icon}
</a>
</div>
);
}

View File

@ -14,12 +14,13 @@ import HeaderContainer from 'flavours/glitch/features/account_timeline/container
import { ScrollContainer } from 'react-router-scroll-4';
import LoadMore from 'flavours/glitch/components/load_more';
import MissingIndicator from 'flavours/glitch/components/missing_indicator';
import { openModal } from 'flavours/glitch/actions/modal';
const mapStateToProps = (state, props) => ({
isAccount: !!state.getIn(['accounts', props.params.accountId]),
medias: getAccountGallery(state, props.params.accountId),
attachments: getAccountGallery(state, props.params.accountId),
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
});
class LoadMoreMedia extends ImmutablePureComponent {
@ -50,12 +51,16 @@ export default class AccountGallery extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
medias: ImmutablePropTypes.list.isRequired,
attachments: ImmutablePropTypes.list.isRequired,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
isAccount: PropTypes.bool,
};
state = {
width: 323,
};
componentDidMount () {
this.props.dispatch(fetchAccount(this.props.params.accountId));
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
@ -74,11 +79,11 @@ export default class AccountGallery extends ImmutablePureComponent {
handleScrollToBottom = () => {
if (this.props.hasMore) {
this.handleLoadMore(this.props.medias.size > 0 ? this.props.medias.last().getIn(['status', 'id']) : undefined);
this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
}
}
handleScroll = (e) => {
handleScroll = e => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
const offset = scrollHeight - scrollTop - clientHeight;
@ -91,7 +96,7 @@ export default class AccountGallery extends ImmutablePureComponent {
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
};
handleLoadOlder = (e) => {
handleLoadOlder = e => {
e.preventDefault();
this.handleScrollToBottom();
}
@ -101,12 +106,30 @@ export default class AccountGallery extends ImmutablePureComponent {
return !(location.state && location.state.mastodonModalOpen);
}
setRef = c => {
setColumnRef = c => {
this.column = c;
}
handleOpenMedia = attachment => {
if (attachment.get('type') === 'video') {
this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') }));
} else {
const media = attachment.getIn(['status', 'media_attachments']);
const index = media.findIndex(x => x.get('id') === attachment.get('id'));
this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') }));
}
}
handleRef = c => {
if (c) {
this.setState({ width: c.offsetWidth });
}
}
render () {
const { medias, isLoading, hasMore, isAccount } = this.props;
const { attachments, isLoading, hasMore, isAccount } = this.props;
const { width } = this.state;
if (!isAccount) {
return (
@ -116,9 +139,7 @@ export default class AccountGallery extends ImmutablePureComponent {
);
}
let loadOlder = null;
if (!medias && isLoading) {
if (!attachments && isLoading) {
return (
<Column>
<LoadingIndicator />
@ -126,35 +147,31 @@ export default class AccountGallery extends ImmutablePureComponent {
);
}
if (hasMore && !(isLoading && medias.size === 0)) {
let loadOlder = null;
if (hasMore && !(isLoading && attachments.size === 0)) {
loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
}
return (
<Column ref={this.setRef}>
<Column ref={this.setColumnRef}>
<ProfileColumnHeader onClick={this.handleHeaderClick} />
<ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}>
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
<HeaderContainer accountId={this.props.params.accountId} />
<div role='feed' className='account-gallery__container'>
{medias.map((media, index) => media === null ? (
<LoadMoreMedia
key={'more:' + medias.getIn(index + 1, 'id')}
maxId={index > 0 ? medias.getIn(index - 1, 'id') : null}
onLoadMore={this.handleLoadMore}
/>
<div role='feed' className='account-gallery__container' ref={this.handleRef}>
{attachments.map((attachment, index) => attachment === null ? (
<LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
) : (
<MediaItem
key={media.get('id')}
media={media}
/>
<MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
))}
{loadOlder}
</div>
{isLoading && medias.size === 0 && (
{isLoading && attachments.size === 0 && (
<div className='scrollable__append'>
<LoadingIndicator />
</div>

View File

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

View File

@ -1,5 +1,6 @@
import { connect } from 'react-redux';
import ColumnSettings from '../components/column_settings';
import { changeColumnParams } from 'flavours/glitch/actions/columns';
import { changeSetting } from 'flavours/glitch/actions/settings';
const mapStateToProps = (state, { columnId }) => {

View File

@ -0,0 +1,24 @@
import React from 'react';
import Avatar from 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
export default class AutosuggestAccount extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
render () {
const { account } = this.props;
return (
<div className='account small' title={account.get('acct')}>
<div className='account__avatar-wrapper'><Avatar account={account} size={24} /></div>
<DisplayName account={account} inline />
</div>
);
}
}

View File

@ -0,0 +1,378 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import AutosuggestInput from '../../../components/autosuggest_input';
import { defineMessages, injectIntl } from 'react-intl';
import EmojiPicker from 'flavours/glitch/features/emoji_picker';
import PollFormContainer from '../containers/poll_form_container';
import UploadFormContainer from '../containers/upload_form_container';
import WarningContainer from '../containers/warning_container';
import { isMobile } from 'flavours/glitch/util/is_mobile';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { countableText } from 'flavours/glitch/util/counter';
import OptionsContainer from '../containers/options_container';
import Publisher from './publisher';
import TextareaIcons from './textarea_icons';
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
missingDescriptionMessage: { id: 'confirmations.missing_media_description.message',
defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' },
missingDescriptionConfirm: { id: 'confirmations.missing_media_description.confirm',
defaultMessage: 'Send anyway' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
});
export default @injectIntl
class ComposeForm extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
intl: PropTypes.object.isRequired,
text: PropTypes.string,
suggestions: ImmutablePropTypes.list,
spoiler: PropTypes.bool,
privacy: PropTypes.string,
spoilerText: PropTypes.string,
focusDate: PropTypes.instanceOf(Date),
caretPosition: PropTypes.number,
preselectDate: PropTypes.instanceOf(Date),
isSubmitting: PropTypes.bool,
isChangingUpload: PropTypes.bool,
isUploading: PropTypes.bool,
onChange: PropTypes.func,
onSubmit: PropTypes.func,
onClearSuggestions: PropTypes.func,
onFetchSuggestions: PropTypes.func,
onSuggestionSelected: PropTypes.func,
onChangeSpoilerText: PropTypes.func,
onPaste: PropTypes.func,
onPickEmoji: PropTypes.func,
showSearch: PropTypes.bool,
anyMedia: PropTypes.bool,
advancedOptions: ImmutablePropTypes.map,
layout: PropTypes.string,
media: ImmutablePropTypes.list,
sideArm: PropTypes.string,
sensitive: PropTypes.bool,
spoilersAlwaysOn: PropTypes.bool,
mediaDescriptionConfirmation: PropTypes.bool,
preselectOnReply: PropTypes.bool,
onChangeSpoilerness: PropTypes.func,
onChangeVisibility: PropTypes.func,
onMount: PropTypes.func,
onUnmount: PropTypes.func,
onPaste: PropTypes.func,
onMediaDescriptionConfirm: PropTypes.func,
};
static defaultProps = {
showSearch: false,
};
handleChange = (e) => {
this.props.onChange(e.target.value);
}
handleKeyDown = ({ ctrlKey, keyCode, metaKey, altKey }) => {
// We submit the status on control/meta + enter.
if (keyCode === 13 && (ctrlKey || metaKey)) {
this.handleSubmit();
}
// Submit the status with secondary visibility on alt + enter.
if (keyCode === 13 && altKey) {
this.handleSecondarySubmit();
}
}
handleSubmit = () => {
const { textarea: { value }, uploadForm } = this;
const {
onChange,
onSubmit,
isSubmitting,
isChangingUpload,
isUploading,
media,
anyMedia,
text,
mediaDescriptionConfirmation,
onMediaDescriptionConfirm,
} = this.props;
// If something changes inside the textarea, then we update the
// state before submitting.
if (onChange && text !== value) {
onChange(value);
}
// Submit disabled:
if (isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia)) {
return;
}
// Submit unless there are media with missing descriptions
if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) {
const firstWithoutDescription = media.findIndex(item => !item.get('description'));
if (uploadForm) {
const inputs = uploadForm.querySelectorAll('.composer--upload_form--item input');
if (inputs.length == media.size && firstWithoutDescription !== -1) {
inputs[firstWithoutDescription].focus();
}
}
onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null);
} else if (onSubmit) {
onSubmit(this.context.router ? this.context.router.history : null);
}
}
// Changes the text value of the spoiler.
handleChangeSpoiler = ({ target: { value } }) => {
const { onChangeSpoilerText } = this.props;
if (onChangeSpoilerText) {
onChangeSpoilerText(value);
}
}
// Inserts an emoji at the caret.
handleEmoji = (data) => {
const { textarea: { selectionStart } } = this;
const { onPickEmoji } = this.props;
if (onPickEmoji) {
onPickEmoji(selectionStart, data);
}
}
// Handles the secondary submit button.
handleSecondarySubmit = () => {
const {
onChangeVisibility,
sideArm,
} = this.props;
if (sideArm !== 'none' && onChangeVisibility) {
onChangeVisibility(sideArm);
}
this.handleSubmit();
}
// Selects a suggestion from the autofill.
onSuggestionSelected = (tokenStart, token, value) => {
this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
}
onSpoilerSuggestionSelected = (tokenStart, token, value) => {
this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
}
// When the escape key is released, we focus the UI.
handleKeyUp = ({ key }) => {
if (key === 'Escape') {
document.querySelector('.ui').parentElement.focus();
}
}
// Sets a reference to the textarea.
setAutosuggestTextarea = (textareaComponent) => {
if (textareaComponent) {
this.textarea = textareaComponent.textarea;
}
}
// Sets a reference to the CW field.
handleRefSpoilerText = (spoilerComponent) => {
if (spoilerComponent) {
this.spoilerText = spoilerComponent.input;
}
}
// Tells our state the composer has been mounted.
componentDidMount () {
const { onMount } = this.props;
if (onMount) {
onMount();
}
}
// Tells our state the composer has been unmounted.
componentWillUnmount () {
const { onUnmount } = this.props;
if (onUnmount) {
onUnmount();
}
}
// This statement does several things:
// - If we're beginning a reply, and,
// - Replying to zero or one users, places the cursor at the end
// of the textbox.
// - Replying to more than one user, selects any usernames past
// the first; this provides a convenient shortcut to drop
// everyone else from the conversation.
componentDidUpdate (prevProps) {
const {
textarea,
spoilerText,
} = this;
const {
focusDate,
caretPosition,
isSubmitting,
preselectDate,
text,
preselectOnReply,
} = this.props;
let selectionEnd, selectionStart;
// Caret/selection handling.
if (focusDate !== prevProps.focusDate) {
switch (true) {
case preselectDate !== prevProps.preselectDate && preselectOnReply:
selectionStart = text.search(/\s/) + 1;
selectionEnd = text.length;
break;
case !isNaN(caretPosition) && caretPosition !== null:
selectionStart = selectionEnd = caretPosition;
break;
default:
selectionStart = selectionEnd = text.length;
}
if (textarea) {
textarea.setSelectionRange(selectionStart, selectionEnd);
textarea.focus();
textarea.scrollIntoView();
}
// Refocuses the textarea after submitting.
} else if (textarea && prevProps.isSubmitting && !isSubmitting) {
textarea.focus();
} else if (this.props.spoiler !== prevProps.spoiler) {
if (this.props.spoiler) {
if (spoilerText) {
spoilerText.focus();
}
} else {
if (textarea) {
textarea.focus();
}
}
}
}
render () {
const {
handleEmoji,
handleSecondarySubmit,
handleSelect,
handleSubmit,
handleRefTextarea,
} = this;
const {
advancedOptions,
anyMedia,
intl,
isSubmitting,
isChangingUpload,
isUploading,
layout,
media,
onChangeSpoilerness,
onChangeVisibility,
onClearSuggestions,
onFetchSuggestions,
onPaste,
privacy,
sensitive,
showSearch,
sideArm,
spoiler,
spoilerText,
suggestions,
text,
spoilersAlwaysOn,
} = this.props;
let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia);
return (
<div className='composer'>
<WarningContainer />
<ReplyIndicatorContainer />
<div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`}>
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={spoilerText}
onChange={this.handleChangeSpoiler}
onKeyDown={this.handleKeyDown}
onKeyUp={this.handleKeyUp}
disabled={!spoiler}
ref={this.handleRefSpoilerText}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={onFetchSuggestions}
onSuggestionsClearRequested={onClearSuggestions}
onSuggestionSelected={this.onSpoilerSuggestionSelected}
searchTokens={[':']}
id='glitch.composer.spoiler.input'
className='spoiler-input__input'
/>
</div>
<div className='composer--textarea'>
<TextareaIcons advancedOptions={advancedOptions} />
<AutosuggestTextarea
ref={this.setAutosuggestTextarea}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={isSubmitting}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={onFetchSuggestions}
onSuggestionsClearRequested={onClearSuggestions}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
/>
<EmojiPicker onPickEmoji={handleEmoji} />
</div>
<div className='compose-form__modifiers'>
<UploadFormContainer />
<PollFormContainer />
</div>
<OptionsContainer
advancedOptions={advancedOptions}
disabled={isSubmitting}
onChangeVisibility={onChangeVisibility}
onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
onUpload={onPaste}
privacy={privacy}
sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
/>
<Publisher
countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`}
disabled={disabledButton}
onSecondarySubmit={handleSecondarySubmit}
onSubmit={handleSubmit}
privacy={privacy}
sideArm={sideArm}
/>
</div>
);
}
}

View File

@ -6,7 +6,7 @@ import Overlay from 'react-overlays/lib/Overlay';
// Components.
import IconButton from 'flavours/glitch/components/icon_button';
import ComposerOptionsDropdownContent from './content';
import DropdownMenu from './dropdown_menu';
// Utils.
import { isUserTouching } from 'flavours/glitch/util/is_mobile';
@ -196,7 +196,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
show={open}
target={this}
>
<ComposerOptionsDropdownContent
<DropdownMenu
items={items}
onChange={onChange}
onClose={handleClose}

View File

@ -0,0 +1,219 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import spring from 'react-motion/lib/spring';
import Toggle from 'react-toggle';
import ImmutablePureComponent from 'react-immutable-pure-component';
import classNames from 'classnames';
// Components.
import Icon from 'flavours/glitch/components/icon';
// Utils.
import { withPassive } from 'flavours/glitch/util/dom_helpers';
import Motion from 'flavours/glitch/util/optional_motion';
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
class ComposerOptionsDropdownContentItem extends ImmutablePureComponent {
static propTypes = {
active: PropTypes.bool,
name: PropTypes.string,
onChange: PropTypes.func,
onClose: PropTypes.func,
options: PropTypes.shape({
icon: PropTypes.string,
meta: PropTypes.node,
on: PropTypes.bool,
text: PropTypes.node,
}),
};
handleActivate = (e) => {
const {
name,
onChange,
onClose,
options: { on },
} = this.props;
// If the escape key was pressed, we close the dropdown.
if (e.key === 'Escape' && onClose) {
onClose();
// Otherwise, we both close the dropdown and change the value.
} else if (onChange && (!e.key || e.key === 'Enter')) {
e.preventDefault(); // Prevents change in focus on click
if ((on === null || typeof on === 'undefined') && onClose) {
onClose();
}
onChange(name);
}
}
// Rendering.
render () {
const {
active,
options: {
icon,
meta,
on,
text,
},
} = this.props;
const computedClass = classNames('composer--options--dropdown--content--item', {
active,
lengthy: meta,
'toggled-off': !on && on !== null && typeof on !== 'undefined',
'toggled-on': on,
'with-icon': icon,
});
let prefix = null;
if (on !== null && typeof on !== 'undefined') {
prefix = <Toggle checked={on} onChange={this.handleActivate} />;
} else if (icon) {
prefix = <Icon className='icon' fullwidth icon={icon} />
}
// The result.
return (
<div
className={computedClass}
onClick={this.handleActivate}
onKeyDown={this.handleActivate}
role='button'
tabIndex='0'
>
{prefix}
<div className='content'>
<strong>{text}</strong>
{meta}
</div>
</div>
);
}
};
// The spring to use with our motion.
const springMotion = spring(1, {
damping: 35,
stiffness: 400,
});
// The component.
export default class ComposerOptionsDropdownContent extends React.PureComponent {
static propTypes = {
items: PropTypes.arrayOf(PropTypes.shape({
icon: PropTypes.string,
meta: PropTypes.node,
name: PropTypes.string.isRequired,
on: PropTypes.bool,
text: PropTypes.node,
})),
onChange: PropTypes.func,
onClose: PropTypes.func,
style: PropTypes.object,
value: PropTypes.string,
};
static defaultProps = {
style: {},
};
state = {
mounted: false,
};
// When the document is clicked elsewhere, we close the dropdown.
handleDocumentClick = ({ target }) => {
const { node } = this;
const { onClose } = this.props;
if (onClose && node && !node.contains(target)) {
onClose();
}
}
// Stores our node in `this.node`.
handleRef = (node) => {
this.node = node;
}
// On mounting, we add our listeners.
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, withPassive);
this.setState({ mounted: true });
}
// On unmounting, we remove our listeners.
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, withPassive);
}
// Rendering.
render () {
const { mounted } = this.state;
const {
items,
onChange,
onClose,
style,
value,
} = this.props;
// The result.
return (
<Motion
defaultStyle={{
opacity: 0,
scaleX: 0.85,
scaleY: 0.75,
}}
style={{
opacity: springMotion,
scaleX: springMotion,
scaleY: springMotion,
}}
>
{({ opacity, scaleX, scaleY }) => (
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div
className='composer--options--dropdown--content'
ref={this.handleRef}
style={{
...style,
opacity: opacity,
transform: mounted ? `scale(${scaleX}, ${scaleY})` : null,
}}
>
{items ? items.map(
({
name,
...rest
}) => (
<ComposerOptionsDropdownContentItem
active={name === value}
key={name}
name={name}
onChange={onChange}
onClose={onClose}
options={rest}
/>
)
) : null}
</div>
)}
</Motion>
);
}
}

View File

@ -0,0 +1,124 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Components.
import Icon from 'flavours/glitch/components/icon';
// Utils.
import { conditionalRender } from 'flavours/glitch/util/react_helpers';
import { signOutLink } from 'flavours/glitch/util/backend_links';
// Messages.
const messages = defineMessages({
community: {
defaultMessage: 'Local timeline',
id: 'navigation_bar.community_timeline',
},
home_timeline: {
defaultMessage: 'Home',
id: 'tabs_bar.home',
},
logout: {
defaultMessage: 'Logout',
id: 'navigation_bar.logout',
},
notifications: {
defaultMessage: 'Notifications',
id: 'tabs_bar.notifications',
},
public: {
defaultMessage: 'Federated timeline',
id: 'navigation_bar.public_timeline',
},
settings: {
defaultMessage: 'App settings',
id: 'navigation_bar.app_settings',
},
start: {
defaultMessage: 'Getting started',
id: 'getting_started.heading',
},
});
export default @injectIntl
class Header extends ImmutablePureComponent {
static propTypes = {
columns: ImmutablePropTypes.list,
unreadNotifications: PropTypes.number,
showNotificationsBadge: PropTypes.bool,
intl: PropTypes.object,
onSettingsClick: PropTypes.func,
};
render () {
const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props;
// Only renders the component if the column isn't being shown.
const renderForColumn = conditionalRender.bind(null,
columnId => !columns || !columns.some(
column => column.get('id') === columnId
)
);
// The result.
return (
<nav className='drawer--header'>
<Link
aria-label={intl.formatMessage(messages.start)}
title={intl.formatMessage(messages.start)}
to='/getting-started'
><Icon icon='asterisk' /></Link>
{renderForColumn('HOME', (
<Link
aria-label={intl.formatMessage(messages.home_timeline)}
title={intl.formatMessage(messages.home_timeline)}
to='/timelines/home'
><Icon icon='home' /></Link>
))}
{renderForColumn('NOTIFICATIONS', (
<Link
aria-label={intl.formatMessage(messages.notifications)}
title={intl.formatMessage(messages.notifications)}
to='/notifications'
>
<span className='icon-badge-wrapper'>
<Icon icon='bell' />
{ showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />}
</span>
</Link>
))}
{renderForColumn('COMMUNITY', (
<Link
aria-label={intl.formatMessage(messages.community)}
title={intl.formatMessage(messages.community)}
to='/timelines/public/local'
><Icon icon='users' /></Link>
))}
{renderForColumn('PUBLIC', (
<Link
aria-label={intl.formatMessage(messages.public)}
title={intl.formatMessage(messages.public)}
to='/timelines/public'
><Icon icon='globe' /></Link>
))}
<a
aria-label={intl.formatMessage(messages.settings)}
onClick={onSettingsClick}
href='#'
title={intl.formatMessage(messages.settings)}
><Icon icon='cogs' /></a>
<a
aria-label={intl.formatMessage(messages.logout)}
data-method='delete'
href={ signOutLink }
title={intl.formatMessage(messages.logout)}
><Icon icon='sign-out' /></a>
</nav>
);
};
}

View File

@ -0,0 +1,36 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from 'flavours/glitch/components/avatar';
import Permalink from 'flavours/glitch/components/permalink';
import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { profileLink } from 'flavours/glitch/util/backend_links';
export default class NavigationBar extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
render () {
return (
<div className='drawer--account'>
<Permalink className='avatar' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
<Avatar account={this.props.account} size={40} />
</Permalink>
<Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<strong>@{this.props.account.get('acct')}</strong>
</Permalink>
{ profileLink !== undefined && (
<a
className='edit'
href={ profileLink }
><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
)}
</div>
);
};
}

View File

@ -2,23 +2,17 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import {
FormattedMessage,
defineMessages,
} from 'react-intl';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import spring from 'react-motion/lib/spring';
// Components.
import IconButton from 'flavours/glitch/components/icon_button';
import TextIconButton from 'flavours/glitch/components/text_icon_button';
import Dropdown from './dropdown';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Utils.
import Motion from 'flavours/glitch/util/optional_motion';
import {
assignHandlers,
hiddenComponent,
} from 'flavours/glitch/util/react_helpers';
import { pollLimits } from 'flavours/glitch/util/initial_state';
// Messages.
@ -35,6 +29,10 @@ const messages = defineMessages({
defaultMessage: 'Adjust status privacy',
id: 'privacy.change',
},
content_type: {
defaultMessage: 'Content type',
id: 'content-type.change',
},
direct_long: {
defaultMessage: 'Post to mentioned users only',
id: 'privacy.direct.long',
@ -47,6 +45,10 @@ const messages = defineMessages({
defaultMessage: 'Draw something',
id: 'compose.attach.doodle',
},
html: {
defaultMessage: 'HTML',
id: 'compose.content-type.html',
},
local_only_long: {
defaultMessage: 'Do not post to other instances',
id: 'advanced_options.local-only.long',
@ -55,6 +57,14 @@ const messages = defineMessages({
defaultMessage: 'Local-only',
id: 'advanced_options.local-only.short',
},
markdown: {
defaultMessage: 'Markdown',
id: 'compose.content-type.markdown',
},
plain: {
defaultMessage: 'Plain text',
id: 'compose.content-type.plain',
},
private_long: {
defaultMessage: 'Post to followers only',
id: 'privacy.private.long',
@ -71,10 +81,6 @@ const messages = defineMessages({
defaultMessage: 'Public',
id: 'privacy.public.short',
},
sensitive: {
defaultMessage: 'Mark media as sensitive',
id: 'compose_form.sensitive',
},
spoiler: {
defaultMessage: 'Hide text behind warning',
id: 'compose_form.spoiler',
@ -109,19 +115,44 @@ const messages = defineMessages({
},
});
// Handlers.
const handlers = {
export default @injectIntl
class ComposerOptions extends ImmutablePureComponent {
static propTypes = {
acceptContentTypes: PropTypes.string,
advancedOptions: ImmutablePropTypes.map,
disabled: PropTypes.bool,
allowMedia: PropTypes.bool,
hasMedia: PropTypes.bool,
allowPoll: PropTypes.bool,
hasPoll: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChangeAdvancedOption: PropTypes.func,
onChangeVisibility: PropTypes.func,
onChangeContentType: PropTypes.func,
onTogglePoll: PropTypes.func,
onDoodleOpen: PropTypes.func,
onModalClose: PropTypes.func,
onModalOpen: PropTypes.func,
onToggleSpoiler: PropTypes.func,
onUpload: PropTypes.func,
privacy: PropTypes.string,
contentType: PropTypes.string,
resetFileKey: PropTypes.number,
spoiler: PropTypes.bool,
showContentTypeChoice: PropTypes.bool,
};
// Handles file selection.
handleChangeFiles ({ target: { files } }) {
handleChangeFiles = ({ target: { files } }) => {
const { onUpload } = this.props;
if (files.length && onUpload) {
onUpload(files);
}
},
}
// Handles attachment clicks.
handleClickAttach (name) {
handleClickAttach = (name) => {
const { fileElement } = this;
const { onDoodleOpen } = this.props;
@ -138,36 +169,19 @@ const handlers = {
}
return;
}
},
}
// Handles a ref to the file input.
handleRefFileElement (fileElement) {
handleRefFileElement = (fileElement) => {
this.fileElement = fileElement;
},
};
// The component.
export default class ComposerOptions extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
// Instance variables.
this.fileElement = null;
}
// Rendering.
render () {
const {
handleChangeFiles,
handleClickAttach,
handleRefFileElement,
} = this.handlers;
const {
acceptContentTypes,
advancedOptions,
contentType,
disabled,
allowMedia,
hasMedia,
@ -175,7 +189,7 @@ export default class ComposerOptions extends React.PureComponent {
hasPoll,
intl,
onChangeAdvancedOption,
onChangeSensitivity,
onChangeContentType,
onChangeVisibility,
onTogglePoll,
onModalClose,
@ -183,8 +197,8 @@ export default class ComposerOptions extends React.PureComponent {
onToggleSpoiler,
privacy,
resetFileKey,
sensitive,
spoiler,
showContentTypeChoice,
} = this.props;
// We predefine our privacy items so that we can easily pick the
@ -216,6 +230,24 @@ export default class ComposerOptions extends React.PureComponent {
},
};
const contentTypeItems = {
plain: {
icon: 'align-left',
name: 'text/plain',
text: <FormattedMessage {...messages.plain} />,
},
html: {
icon: 'code',
name: 'text/html',
text: <FormattedMessage {...messages.html} />,
},
markdown: {
icon: 'arrow-circle-down',
name: 'text/markdown',
text: <FormattedMessage {...messages.markdown} />,
},
};
// The result.
return (
<div className='composer--options'>
@ -223,11 +255,11 @@ export default class ComposerOptions extends React.PureComponent {
accept={acceptContentTypes}
disabled={disabled || !allowMedia}
key={resetFileKey}
onChange={handleChangeFiles}
ref={handleRefFileElement}
onChange={this.handleChangeFiles}
ref={this.handleRefFileElement}
type='file'
multiple
{...hiddenComponent}
style={{ display: 'none' }}
/>
<Dropdown
disabled={disabled || !allowMedia}
@ -244,7 +276,7 @@ export default class ComposerOptions extends React.PureComponent {
text: <FormattedMessage {...messages.doodle} />,
},
]}
onChange={handleClickAttach}
onChange={this.handleClickAttach}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
title={intl.formatMessage(messages.attach)}
@ -264,39 +296,6 @@ export default class ComposerOptions extends React.PureComponent {
title={intl.formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)}
/>
)}
<Motion
defaultStyle={{ scale: 0.87 }}
style={{
scale: spring(hasMedia ? 1 : 0.87, {
stiffness: 200,
damping: 3,
}),
}}
>
{({ scale }) => (
<div
style={{
display: hasMedia ? null : 'none',
transform: `scale(${scale})`,
}}
>
<IconButton
active={sensitive}
className='sensitive'
disabled={spoiler}
icon={sensitive ? 'eye-slash' : 'eye'}
inverted
onClick={onChangeSensitivity}
size={18}
style={{
height: null,
lineHeight: null,
}}
title={intl.formatMessage(messages.sensitive)}
/>
</div>
)}
</Motion>
<hr />
<Dropdown
disabled={disabled}
@ -313,6 +312,22 @@ export default class ComposerOptions extends React.PureComponent {
title={intl.formatMessage(messages.change_privacy)}
value={privacy}
/>
{showContentTypeChoice && (
<Dropdown
disabled={disabled}
icon={(contentTypeItems[contentType.split('/')[1]] || {}).icon}
items={[
contentTypeItems.plain,
contentTypeItems.html,
contentTypeItems.markdown,
]}
onChange={onChangeContentType}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
title={intl.formatMessage(messages.content_type)}
value={contentType}
/>
)}
{onToggleSpoiler && (
<TextIconButton
active={spoiler}
@ -350,28 +365,3 @@ export default class ComposerOptions extends React.PureComponent {
}
}
// Props.
ComposerOptions.propTypes = {
acceptContentTypes: PropTypes.string,
advancedOptions: ImmutablePropTypes.map,
disabled: PropTypes.bool,
allowMedia: PropTypes.bool,
hasMedia: PropTypes.bool,
allowPoll: PropTypes.bool,
hasPoll: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChangeAdvancedOption: PropTypes.func,
onChangeSensitivity: PropTypes.func,
onChangeVisibility: PropTypes.func,
onTogglePoll: PropTypes.func,
onDoodleOpen: PropTypes.func,
onModalClose: PropTypes.func,
onModalOpen: PropTypes.func,
onToggleSpoiler: PropTypes.func,
onUpload: PropTypes.func,
privacy: PropTypes.string,
resetFileKey: PropTypes.number,
sensitive: PropTypes.bool,
spoiler: PropTypes.bool,
};

View File

@ -5,6 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from 'flavours/glitch/components/icon_button';
import Icon from 'flavours/glitch/components/icon';
import AutosuggestInput from 'flavours/glitch/components/autosuggest_input';
import classNames from 'classnames';
import { pollLimits } from 'flavours/glitch/util/initial_state';
@ -29,6 +30,10 @@ class Option extends React.PureComponent {
isPollMultiple: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@ -40,6 +45,18 @@ class Option extends React.PureComponent {
this.props.onRemove(this.props.index);
};
onSuggestionsClearRequested = () => {
this.props.onClearSuggestions();
}
onSuggestionsFetchRequested = (token) => {
this.props.onFetchSuggestions(token);
}
onSuggestionSelected = (tokenStart, token, value) => {
this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]);
}
render () {
const { isPollMultiple, title, index, intl } = this.props;
@ -48,12 +65,16 @@ class Option extends React.PureComponent {
<label className='poll__text editable'>
<span className={classNames('poll__input', { checkbox: isPollMultiple })} />
<input
type='text'
<AutosuggestInput
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
maxLength={pollLimits.max_option_chars}
value={title}
onChange={this.handleOptionTitleChange}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
searchTokens={[':']}
/>
</label>
@ -78,6 +99,10 @@ class PollForm extends ImmutablePureComponent {
onAddOption: PropTypes.func.isRequired,
onRemoveOption: PropTypes.func.isRequired,
onChangeSettings: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@ -94,7 +119,7 @@ class PollForm extends ImmutablePureComponent {
};
render () {
const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl } = this.props;
const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
if (!options) {
return null;
@ -103,7 +128,7 @@ class PollForm extends ImmutablePureComponent {
return (
<div className='compose-form__poll-wrapper'>
<ul>
{options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} />)}
{options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} {...other} />)}
{options.size < pollLimits.max_options && (
<label className='poll__text editable'>
<span className={classNames('poll__input')} style={{ opacity: 0 }} />

View File

@ -0,0 +1,115 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import { length } from 'stringz';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Components.
import Button from 'flavours/glitch/components/button';
import Icon from 'flavours/glitch/components/icon';
// Utils.
import { maxChars } from 'flavours/glitch/util/initial_state';
// Messages.
const messages = defineMessages({
publish: {
defaultMessage: 'Toot',
id: 'compose_form.publish',
},
publishLoud: {
defaultMessage: '{publish}!',
id: 'compose_form.publish_loud',
},
});
export default @injectIntl
class Publisher extends ImmutablePureComponent {
static propTypes = {
countText: PropTypes.string,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onSecondarySubmit: PropTypes.func,
onSubmit: PropTypes.func,
privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
};
render () {
const { countText, disabled, intl, onSecondarySubmit, onSubmit, privacy, sideArm } = this.props;
const diff = maxChars - length(countText || '');
const computedClass = classNames('composer--publisher', {
disabled: disabled || diff < 0,
over: diff < 0,
});
return (
<div className={computedClass}>
<span className='count'>{diff}</span>
{sideArm && sideArm !== 'none' ? (
<Button
className='side_arm'
disabled={disabled || diff < 0}
onClick={onSecondarySubmit}
style={{ padding: null }}
text={
<span>
<Icon
icon={{
public: 'globe',
unlisted: 'unlock',
private: 'lock',
direct: 'envelope',
}[sideArm]}
/>
</span>
}
title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
/>
) : null}
<Button
className='primary'
text={function () {
switch (true) {
case !!sideArm && sideArm !== 'none':
case privacy === 'direct':
case privacy === 'private':
return (
<span>
<Icon
icon={{
direct: 'envelope',
private: 'lock',
public: 'globe',
unlisted: 'unlock',
}[privacy]}
/>
{' '}
<FormattedMessage {...messages.publish} />
</span>
);
case privacy === 'public':
return (
<span>
<FormattedMessage
{...messages.publishLoud}
values={{ publish: <FormattedMessage {...messages.publish} /> }}
/>
</span>
);
default:
return <span><FormattedMessage {...messages.publish} /></span>;
}
}()}
title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
onClick={onSubmit}
disabled={disabled || diff < 0}
/>
</div>
);
};
}

View File

@ -2,7 +2,8 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Components.
import AccountContainer from 'flavours/glitch/containers/account_container';
@ -10,7 +11,6 @@ import IconButton from 'flavours/glitch/components/icon_button';
import AttachmentList from 'flavours/glitch/components/attachment_list';
// Utils.
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
import { isRtl } from 'flavours/glitch/util/rtl';
// Messages.
@ -21,34 +21,30 @@ const messages = defineMessages({
},
});
// Handlers.
const handlers = {
// Handles a click on the "close" button.
handleClick () {
export default @injectIntl
class ReplyIndicator extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onCancel: PropTypes.func,
};
handleClick = () => {
const { onCancel } = this.props;
if (onCancel) {
onCancel();
}
},
};
// The component.
export default class ComposerReply extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
}
// Rendering.
render () {
const { handleClick } = this.handlers;
const {
status,
intl,
} = this.props;
const { status, intl } = this.props;
if (!status) {
return null;
}
const account = status.get('account');
const content = status.get('content');
@ -61,7 +57,7 @@ export default class ComposerReply extends React.PureComponent {
<IconButton
className='cancel'
icon='times'
onClick={handleClick}
onClick={this.handleClick}
title={intl.formatMessage(messages.cancel)}
inverted
/>
@ -88,9 +84,3 @@ export default class ComposerReply extends React.PureComponent {
}
}
ComposerReply.propTypes = {
status: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onCancel: PropTypes.func,
};

View File

@ -0,0 +1,158 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import spring from 'react-motion/lib/spring';
import {
injectIntl,
FormattedMessage,
defineMessages,
} from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay';
// Components.
import Icon from 'flavours/glitch/components/icon';
// Utils.
import { focusRoot } from 'flavours/glitch/util/dom_helpers';
import { searchEnabled } from 'flavours/glitch/util/initial_state';
import Motion from 'flavours/glitch/util/optional_motion';
const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
});
class SearchPopout extends React.PureComponent {
static propTypes = {
style: PropTypes.object,
};
render () {
const { style } = this.props;
const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />;
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='drawer--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>
{extraInformation}
</div>
)}
</Motion>
</div>
);
}
}
// The component.
export default @injectIntl
class Search extends React.PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
submitted: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onShow: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
expanded: false,
};
handleChange = (e) => {
const { onChange } = this.props;
if (onChange) {
onChange(e.target.value);
}
}
handleClear = (e) => {
const {
onClear,
submitted,
value,
} = this.props;
e.preventDefault(); // Prevents focus change ??
if (onClear && (submitted || value && value.length)) {
onClear();
}
}
handleBlur = () => {
this.setState({ expanded: false });
}
handleFocus = () => {
const { onShow } = this.props;
this.setState({ expanded: true });
if (onShow) {
onShow();
}
}
handleKeyUp = (e) => {
const { onSubmit } = this.props;
switch (e.key) {
case 'Enter':
if (onSubmit) {
onSubmit();
}
break;
case 'Escape':
focusRoot();
}
}
render () {
const { intl, value, submitted } = this.props;
const { expanded } = this.state;
const active = value.length > 0 || submitted;
const computedClass = classNames('drawer--search', { active });
return (
<div className={computedClass}>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
<input
type='text'
placeholder={intl.formatMessage(messages.placeholder)}
value={value || ''}
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
/>
</label>
<div
aria-label={intl.formatMessage(messages.placeholder)}
className='icon'
onClick={this.handleClear}
role='button'
tabIndex='0'
>
<Icon icon='search' />
<Icon icon='times-circle' />
</div>
<Overlay show={expanded && !active} placement='bottom' target={this}>
<SearchPopout />
</Overlay>
</div>
);
}
}

View File

@ -0,0 +1,106 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import AccountContainer from 'flavours/glitch/containers/account_container';
import StatusContainer from 'flavours/glitch/containers/status_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Hashtag from 'flavours/glitch/components/hashtag';
import Icon from 'flavours/glitch/components/icon';
const messages = defineMessages({
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
});
export default @injectIntl
class SearchResults extends ImmutablePureComponent {
static propTypes = {
results: ImmutablePropTypes.map.isRequired,
suggestions: ImmutablePropTypes.list.isRequired,
fetchSuggestions: PropTypes.func.isRequired,
dismissSuggestion: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
componentDidMount () {
this.props.fetchSuggestions();
}
render() {
const { intl, results, suggestions, dismissSuggestion } = this.props;
if (results.isEmpty() && !suggestions.isEmpty()) {
return (
<div className='drawer--results'>
<div className='trends'>
<div className='trends__header'>
<i className='fa fa-user-plus fa-fw' />
<FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' />
</div>
{suggestions && suggestions.map(accountId => (
<AccountContainer
key={accountId}
id={accountId}
actionIcon='times'
actionTitle={intl.formatMessage(messages.dismissSuggestion)}
onActionClick={dismissSuggestion}
/>
))}
</div>
</div>
);
}
let accounts, statuses, hashtags;
let count = 0;
if (results.get('accounts') && results.get('accounts').size > 0) {
count += results.get('accounts').size;
accounts = (
<section>
<h5><Icon icon='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
{results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)}
</section>
);
}
if (results.get('statuses') && results.get('statuses').size > 0) {
count += results.get('statuses').size;
statuses = (
<section>
<h5><Icon icon='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
{results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId}/>)}
</section>
);
}
if (results.get('hashtags') && results.get('hashtags').size > 0) {
count += results.get('hashtags').size;
hashtags = (
<section>
<h5><Icon icon='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
{results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
</section>
);
}
// The result.
return (
<div className='drawer--results'>
<header className='search-results__header'>
<Icon icon='search' fixedWidth />
<FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
</header>
{accounts}
{statuses}
{hashtags}
</div>
);
};
}

View File

@ -0,0 +1,59 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Components.
import Icon from 'flavours/glitch/components/icon';
// Messages.
const messages = defineMessages({
localOnly: {
defaultMessage: 'This post is local-only',
id: 'advanced_options.local-only.tooltip',
},
threadedMode: {
defaultMessage: 'Threaded mode enabled',
id: 'advanced_options.threaded_mode.tooltip',
},
});
// We use an array of tuples here instead of an object because it
// preserves order.
const iconMap = [
['do_not_federate', 'home', messages.localOnly],
['threaded_mode', 'comments', messages.threadedMode],
];
export default @injectIntl
class TextareaIcons extends ImmutablePureComponent {
static propTypes = {
advancedOptions: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
};
render () {
const { advancedOptions, intl } = this.props;
return (
<div className='composer--textarea--icons'>
{advancedOptions ? iconMap.map(
([key, icon, message]) => advancedOptions.get(key) ? (
<span
className='textarea_icon'
key={key}
title={intl.formatMessage(message)}
>
<Icon
fullwidth
icon={icon}
/>
</span>
) : null
) : null}
</div>
);
}
}

View File

@ -0,0 +1,131 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Icon from 'flavours/glitch/components/icon';
import { isUserTouching } from 'flavours/glitch/util/is_mobile';
const messages = defineMessages({
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
});
// The component.
export default @injectIntl
class Upload extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onUndo: PropTypes.func.isRequired,
onDescriptionChange: PropTypes.func.isRequired,
onOpenFocalPoint: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
state = {
hovered: false,
focused: false,
dirtyDescription: null,
};
handleKeyDown = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
}
}
handleSubmit = () => {
this.handleInputBlur();
this.props.onSubmit(this.context.router.history);
}
handleUndoClick = e => {
e.stopPropagation();
this.props.onUndo(this.props.media.get('id'));
}
handleFocalPointClick = e => {
e.stopPropagation();
this.props.onOpenFocalPoint(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 });
}
handleClick = () => {
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 || isUserTouching();
const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
const computedClass = classNames('composer--upload_form--item', { active });
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
return (
<div className={computedClass} tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12, }) }}>
{({ scale }) => (
<div style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
<div className={classNames('composer--upload_form--actions', { active })}>
<button className='icon-button' onClick={this.handleUndoClick}><Icon icon='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
{media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
</div>
<div className={classNames('composer--upload_form--description', { active })}>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
<textarea
placeholder={intl.formatMessage(messages.description)}
value={description}
maxLength={420}
onFocus={this.handleInputFocus}
onChange={this.handleInputChange}
onBlur={this.handleInputBlur}
onKeyDown={this.handleKeyDown}
/>
</label>
</div>
</div>
)}
</Motion>
</div>
);
}
}

View File

@ -0,0 +1,33 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import UploadProgressContainer from '../containers/upload_progress_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import UploadContainer from '../containers/upload_container';
import SensitiveButtonContainer from '../containers/sensitive_button_container';
export default class UploadForm extends ImmutablePureComponent {
static propTypes = {
mediaIds: ImmutablePropTypes.list.isRequired,
};
render () {
const { mediaIds } = this.props;
return (
<div className='composer--upload_form'>
<UploadProgressContainer />
{mediaIds.size > 0 && (
<div className='content'>
{mediaIds.map(id => (
<UploadContainer id={id} key={id} />
))}
</div>
)}
{!mediaIds.isEmpty() && <SensitiveButtonContainer />}
</div>
);
}
}

View File

@ -0,0 +1,42 @@
import React from 'react';
import PropTypes from 'prop-types';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { FormattedMessage } from 'react-intl';
import Icon from 'flavours/glitch/components/icon';
export default class UploadProgress extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
progress: PropTypes.number,
};
render () {
const { active, progress } = this.props;
if (!active) {
return null;
}
return (
<div className='composer--upload_form--progress'>
<Icon icon='upload' />
<div className='message'>
<FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' />
<div className='backdrop'>
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
{({ width }) =>
(<div className='tracker' style={{ width: `${width}%` }}
/>)
}
</Motion>
</div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
export default class Warning extends React.PureComponent {
static propTypes = {
message: PropTypes.node.isRequired,
};
render () {
const { message } = 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='composer--warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
{message}
</div>
)}
</Motion>
);
}
}

View File

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import AutosuggestAccount from '../components/autosuggest_account';
import { makeGetAccount } from 'flavours/glitch/selectors';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { id }) => ({
account: getAccount(state, id),
});
return mapStateToProps;
};
export default connect(makeMapStateToProps)(AutosuggestAccount);

View File

@ -0,0 +1,136 @@
import { connect } from 'react-redux';
import { defineMessages, injectIntl } from 'react-intl';
import ComposeForm from '../components/compose_form';
import {
changeCompose,
changeComposeSpoilerText,
changeComposeSpoilerness,
changeComposeVisibility,
clearComposeSuggestions,
fetchComposeSuggestions,
insertEmojiCompose,
mountCompose,
selectComposeSuggestion,
submitCompose,
unmountCompose,
uploadCompose,
} from 'flavours/glitch/actions/compose';
import {
openModal,
} from 'flavours/glitch/actions/modal';
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
import { privacyPreference } from 'flavours/glitch/util/privacy_preference';
const messages = defineMessages({
missingDescriptionMessage: { id: 'confirmations.missing_media_description.message',
defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' },
missingDescriptionConfirm: { id: 'confirmations.missing_media_description.confirm',
defaultMessage: 'Send anyway' },
});
// State mapping.
function mapStateToProps (state) {
const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
const inReplyTo = state.getIn(['compose', 'in_reply_to']);
const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null;
const sideArmBasePrivacy = state.getIn(['local_settings', 'side_arm']);
const sideArmRestrictedPrivacy = replyPrivacy ? privacyPreference(replyPrivacy, sideArmBasePrivacy) : null;
let sideArmPrivacy = null;
switch (state.getIn(['local_settings', 'side_arm_reply_mode'])) {
case 'copy':
sideArmPrivacy = replyPrivacy;
break;
case 'restrict':
sideArmPrivacy = sideArmRestrictedPrivacy;
break;
}
sideArmPrivacy = sideArmPrivacy || sideArmBasePrivacy;
return {
advancedOptions: state.getIn(['compose', 'advanced_options']),
focusDate: state.getIn(['compose', 'focusDate']),
caretPosition: state.getIn(['compose', 'caretPosition']),
isSubmitting: state.getIn(['compose', 'is_submitting']),
isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
isUploading: state.getIn(['compose', 'is_uploading']),
layout: state.getIn(['local_settings', 'layout']),
media: state.getIn(['compose', 'media_attachments']),
preselectDate: state.getIn(['compose', 'preselectDate']),
privacy: state.getIn(['compose', 'privacy']),
sideArm: sideArmPrivacy,
sensitive: state.getIn(['compose', 'sensitive']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
spoiler: spoilersAlwaysOn || state.getIn(['compose', 'spoiler']),
spoilerText: state.getIn(['compose', 'spoiler_text']),
suggestions: state.getIn(['compose', 'suggestions']),
text: state.getIn(['compose', 'text']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
spoilersAlwaysOn: spoilersAlwaysOn,
mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
};
};
// Dispatch mapping.
const mapDispatchToProps = (dispatch, { intl }) => ({
onChange(text) {
dispatch(changeCompose(text));
},
onSubmit(routerHistory) {
dispatch(submitCompose(routerHistory));
},
onClearSuggestions() {
dispatch(clearComposeSuggestions());
},
onFetchSuggestions(token) {
dispatch(fetchComposeSuggestions(token));
},
onSuggestionSelected(position, token, suggestion, path) {
dispatch(selectComposeSuggestion(position, token, suggestion, path));
},
onChangeSpoilerText(text) {
dispatch(changeComposeSpoilerText(text));
},
onPaste(files) {
dispatch(uploadCompose(files));
},
onPickEmoji(position, emoji) {
dispatch(insertEmojiCompose(position, emoji));
},
onChangeSpoilerness() {
dispatch(changeComposeSpoilerness());
},
onChangeVisibility(value) {
dispatch(changeComposeVisibility(value));
},
onMount() {
dispatch(mountCompose());
},
onUnmount() {
dispatch(unmountCompose());
},
onMediaDescriptionConfirm(routerHistory) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.missingDescriptionMessage),
confirm: intl.formatMessage(messages.missingDescriptionConfirm),
onConfirm: () => dispatch(submitCompose(routerHistory)),
onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)),
}));
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ComposeForm));

View File

@ -0,0 +1,21 @@
import { openModal } from 'flavours/glitch/actions/modal';
import { connect } from 'react-redux';
import Header from '../components/header';
const mapStateToProps = state => {
return {
columns: state.getIn(['settings', 'columns']),
unreadNotifications: state.getIn(['notifications', 'unread']),
showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']),
};
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onSettingsClick (e) {
e.preventDefault();
e.stopPropagation();
dispatch(openModal('SETTINGS', {}));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Header);

View File

@ -0,0 +1,11 @@
import { connect } from 'react-redux';
import NavigationBar from '../components/navigation_bar';
import { me } from 'flavours/glitch/util/initial_state';
const mapStateToProps = state => {
return {
account: state.getIn(['accounts', me]),
};
};
export default connect(mapStateToProps)(NavigationBar);

View File

@ -0,0 +1,60 @@
import { connect } from 'react-redux';
import Options from '../components/options';
import {
changeComposeAdvancedOption,
changeComposeContentType,
addPoll,
removePoll,
} from 'flavours/glitch/actions/compose';
import { closeModal, openModal } from 'flavours/glitch/actions/modal';
function mapStateToProps (state) {
const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
const poll = state.getIn(['compose', 'poll']);
const media = state.getIn(['compose', 'media_attachments']);
return {
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
resetFileKey: state.getIn(['compose', 'resetFileKey']),
hasPoll: !!poll,
allowMedia: !poll && (media ? media.size < 4 && !media.some(item => item.get('type') === 'video') : true),
hasMedia: media && !!media.size,
allowPoll: !(media && !!media.size),
showContentTypeChoice: state.getIn(['local_settings', 'show_content_type_choice']),
contentType: state.getIn(['compose', 'content_type']),
};
};
const mapDispatchToProps = (dispatch) => ({
onChangeAdvancedOption(option, value) {
dispatch(changeComposeAdvancedOption(option, value));
},
onChangeContentType(value) {
dispatch(changeComposeContentType(value));
},
onTogglePoll() {
dispatch((_, getState) => {
if (getState().getIn(['compose', 'poll'])) {
dispatch(removePoll());
} else {
dispatch(addPoll());
}
});
},
onDoodleOpen() {
dispatch(openModal('DOODLE', { noEsc: true }));
},
onModalClose() {
dispatch(closeModal());
},
onModalOpen(props) {
dispatch(openModal('ACTIONS', props));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Options);

View File

@ -1,8 +1,14 @@
import { connect } from 'react-redux';
import PollForm from './components/poll_form';
import { addPollOption, removePollOption, changePollOption, changePollSettings } from '../../../actions/compose';
import PollForm from '../components/poll_form';
import { addPollOption, removePollOption, changePollOption, changePollSettings } from 'flavours/glitch/actions/compose';
import {
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
} from 'flavours/glitch/actions/compose';
const mapStateToProps = state => ({
suggestions: state.getIn(['compose', 'suggestions']),
options: state.getIn(['compose', 'poll', 'options']),
expiresIn: state.getIn(['compose', 'poll', 'expires_in']),
isMultiple: state.getIn(['compose', 'poll', 'multiple']),
@ -24,6 +30,19 @@ const mapDispatchToProps = dispatch => ({
onChangeSettings(expiresIn, isMultiple) {
dispatch(changePollSettings(expiresIn, isMultiple));
},
onClearSuggestions () {
dispatch(clearComposeSuggestions());
},
onFetchSuggestions (token) {
dispatch(fetchComposeSuggestions(token));
},
onSuggestionSelected (position, token, accountId, path) {
dispatch(selectComposeSuggestion(position, token, accountId, path));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(PollForm);

View File

@ -0,0 +1,22 @@
import { connect } from 'react-redux';
import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
import { makeGetStatus } from 'flavours/glitch/selectors';
import ReplyIndicator from '../components/reply_indicator';
function makeMapStateToProps (state) {
const inReplyTo = state.getIn(['compose', 'in_reply_to']);
return {
status: inReplyTo ? state.getIn(['statuses', inReplyTo]) : null,
};
};
const mapDispatchToProps = dispatch => ({
onCancel () {
dispatch(cancelReplyCompose());
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);

View File

@ -0,0 +1,35 @@
import { connect } from 'react-redux';
import {
changeSearch,
clearSearch,
submitSearch,
showSearch,
} from 'flavours/glitch/actions/search';
import Search from '../components/search';
const mapStateToProps = state => ({
value: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted']),
});
const mapDispatchToProps = dispatch => ({
onChange (value) {
dispatch(changeSearch(value));
},
onClear () {
dispatch(clearSearch());
},
onSubmit () {
dispatch(submitSearch());
},
onShow () {
dispatch(showSearch());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Search);

View File

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import SearchResults from '../components/search_results';
import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestions';
const mapStateToProps = state => ({
results: state.getIn(['search', 'results']),
suggestions: state.getIn(['suggestions', 'items']),
});
const mapDispatchToProps = dispatch => ({
fetchSuggestions: () => dispatch(fetchSuggestions()),
dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))),
});
export default connect(mapStateToProps, mapDispatchToProps)(SearchResults);

View File

@ -0,0 +1,63 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
const messages = defineMessages({
marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' },
});
const mapStateToProps = state => {
const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
const spoilerText = state.getIn(['compose', 'spoiler_text']);
return {
active: state.getIn(['compose', 'sensitive']) || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0),
disabled: state.getIn(['compose', 'spoiler']),
};
};
const mapDispatchToProps = dispatch => ({
onClick () {
dispatch(changeComposeSensitivity());
},
});
class SensitiveButton extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
disabled: PropTypes.bool,
onClick: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
render () {
const { active, disabled, onClick, intl } = this.props;
return (
<div className='compose-form__sensitive-button'>
<label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}>
<input
name='mark-sensitive'
type='checkbox'
checked={active}
onChange={onClick}
disabled={disabled}
/>
<span className={classNames('checkbox', { active })} />
<FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
</label>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));

View File

@ -0,0 +1,31 @@
import { connect } from 'react-redux';
import Upload from '../components/upload';
import { undoUploadCompose, changeUploadCompose } from 'flavours/glitch/actions/compose';
import { openModal } from 'flavours/glitch/actions/modal';
import { submitCompose } from 'flavours/glitch/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 }));
},
onOpenFocalPoint: id => {
dispatch(openModal('FOCAL_POINT', { id }));
},
onSubmit (router) {
dispatch(submitCompose(router));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Upload);

View File

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

View File

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import UploadProgress from '../components/upload_progress';
const mapStateToProps = state => ({
active: state.getIn(['compose', 'is_uploading']),
progress: state.getIn(['compose', 'progress']),
});
export default connect(mapStateToProps)(UploadProgress);

View File

@ -0,0 +1,44 @@
import React from 'react';
import { connect } from 'react-redux';
import Warning from '../components/warning';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { me } from 'flavours/glitch/util/initial_state';
const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i;
const mapStateToProps = state => ({
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
});
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
if (needsLockWarning) {
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
}
if (hashtagWarning) {
return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag." />} />;
}
if (directMessageWarning) {
const message = (
<span>
<FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>
</span>
);
return <Warning message={message} />;
}
return null;
};
WarningWrapper.propTypes = {
needsLockWarning: PropTypes.bool,
hashtagWarning: PropTypes.bool,
directMessageWarning: PropTypes.bool,
};
export default connect(mapStateToProps)(WarningWrapper);

View File

@ -0,0 +1,84 @@
import React from 'react';
import ComposeFormContainer from './containers/compose_form_container';
import NavigationContainer from './containers/navigation_container';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import SearchContainer from './containers/search_container';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import SearchResultsContainer from './containers/search_results_container';
import { me, mascot } from 'flavours/glitch/util/initial_state';
import { cycleElefriendCompose } from 'flavours/glitch/actions/compose';
import HeaderContainer from './containers/header_container';
const messages = defineMessages({
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' },
});
const mapStateToProps = (state, ownProps) => ({
elefriend: state.getIn(['compose', 'elefriend']),
showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : ownProps.isSearchPage,
});
const mapDispatchToProps = (dispatch, { intl }) => ({
onClickElefriend () {
dispatch(cycleElefriendCompose());
},
});
export default @connect(mapStateToProps)
@injectIntl
class Compose extends React.PureComponent {
static propTypes = {
multiColumn: PropTypes.bool,
showSearch: PropTypes.bool,
isSearchPage: PropTypes.bool,
elefriend: PropTypes.number,
onClickElefriend: PropTypes.func,
intl: PropTypes.object.isRequired,
};
render () {
const {
elefriend,
intl,
multiColumn,
onClickElefriend,
isSearchPage,
showSearch,
} = this.props;
const computedClass = classNames('drawer', `mbstobon-${elefriend}`);
return (
<div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}>
{multiColumn && <HeaderContainer />}
{(multiColumn || isSearchPage) && <SearchContainer />}
<div className='drawer__pager'>
{!isSearchPage && <div className='drawer__inner'>
<NavigationContainer />
<ComposeFormContainer />
{multiColumn && (
<div className='drawer__inner__mastodon'>
{mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
</div>
)}
</div>}
<Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) => (
<div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
<SearchResultsContainer />
</div>
)}
</Motion>
</div>
</div>
);
}
}

View File

@ -1,55 +0,0 @@
import React from 'react';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { defineMessages, FormattedMessage } from 'react-intl';
import { termsLink} from 'flavours/glitch/util/backend_links';
// This is the spring used with our motion.
const motionSpring = spring(1, { damping: 35, stiffness: 400 });
// Messages.
const messages = defineMessages({
disclaimer: {
defaultMessage: 'This toot will only be sent to all the mentioned users.',
id: 'compose_form.direct_message_warning',
},
learn_more: {
defaultMessage: 'Learn more',
id: 'compose_form.direct_message_warning_learn_more'
}
});
// The component.
export default function ComposerDirectWarning () {
return (
<Motion
defaultStyle={{
opacity: 0,
scaleX: 0.85,
scaleY: 0.75,
}}
style={{
opacity: motionSpring,
scaleX: motionSpring,
scaleY: motionSpring,
}}
>
{({ opacity, scaleX, scaleY }) => (
<div
className='composer--warning'
style={{
opacity: opacity,
transform: `scale(${scaleX}, ${scaleY})`,
}}
>
<span>
<FormattedMessage {...messages.disclaimer} />
{ termsLink !== undefined && <a href={termsLink} target='_blank'><FormattedMessage {...messages.learn_more} /></a> }
</span>
</div>
)}
</Motion>
);
}
ComposerDirectWarning.propTypes = {};

View File

@ -1,49 +0,0 @@
import React from 'react';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { defineMessages, FormattedMessage } from 'react-intl';
// This is the spring used with our motion.
const motionSpring = spring(1, { damping: 35, stiffness: 400 });
// Messages.
const messages = defineMessages({
disclaimer: {
defaultMessage: 'This toot won\'t be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.',
id: 'compose_form.hashtag_warning',
},
});
// The component.
export default function ComposerHashtagWarning () {
return (
<Motion
defaultStyle={{
opacity: 0,
scaleX: 0.85,
scaleY: 0.75,
}}
style={{
opacity: motionSpring,
scaleX: motionSpring,
scaleY: motionSpring,
}}
>
{({ opacity, scaleX, scaleY }) => (
<div
className='composer--warning'
style={{
opacity: opacity,
transform: `scale(${scaleX}, ${scaleY})`,
}}
>
<FormattedMessage
{...messages.disclaimer}
/>
</div>
)}
</Motion>
);
}
ComposerHashtagWarning.propTypes = {};

View File

@ -1,600 +0,0 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages } from 'react-intl';
const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\S+)/i;
// Actions.
import {
cancelReplyCompose,
changeCompose,
changeComposeAdvancedOption,
changeComposeSensitivity,
changeComposeSpoilerText,
changeComposeSpoilerness,
changeComposeVisibility,
changeUploadCompose,
clearComposeSuggestions,
fetchComposeSuggestions,
insertEmojiCompose,
mountCompose,
selectComposeSuggestion,
submitCompose,
undoUploadCompose,
unmountCompose,
uploadCompose,
} from 'flavours/glitch/actions/compose';
import {
closeModal,
openModal,
} from 'flavours/glitch/actions/modal';
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
import { addPoll, removePoll } from 'flavours/glitch/actions/compose';
// Components.
import ComposerOptions from './options';
import ComposerPublisher from './publisher';
import ComposerReply from './reply';
import ComposerSpoiler from './spoiler';
import ComposerTextarea from './textarea';
import ComposerUploadForm from './upload_form';
import ComposerPollForm from './poll_form';
import ComposerWarning from './warning';
import ComposerHashtagWarning from './hashtag_warning';
import ComposerDirectWarning from './direct_warning';
// Utils.
import { countableText } from 'flavours/glitch/util/counter';
import { me } from 'flavours/glitch/util/initial_state';
import { isMobile } from 'flavours/glitch/util/is_mobile';
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
import { wrap } from 'flavours/glitch/util/redux_helpers';
import { privacyPreference } from 'flavours/glitch/util/privacy_preference';
const messages = defineMessages({
missingDescriptionMessage: { id: 'confirmations.missing_media_description.message',
defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' },
missingDescriptionConfirm: { id: 'confirmations.missing_media_description.confirm',
defaultMessage: 'Send anyway' },
});
// State mapping.
function mapStateToProps (state) {
const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
const inReplyTo = state.getIn(['compose', 'in_reply_to']);
const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null;
const sideArmBasePrivacy = state.getIn(['local_settings', 'side_arm']);
const sideArmRestrictedPrivacy = replyPrivacy ? privacyPreference(replyPrivacy, sideArmBasePrivacy) : null;
let sideArmPrivacy = null;
switch (state.getIn(['local_settings', 'side_arm_reply_mode'])) {
case 'copy':
sideArmPrivacy = replyPrivacy;
break;
case 'restrict':
sideArmPrivacy = sideArmRestrictedPrivacy;
break;
}
sideArmPrivacy = sideArmPrivacy || sideArmBasePrivacy;
return {
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
advancedOptions: state.getIn(['compose', 'advanced_options']),
amUnlocked: !state.getIn(['accounts', me, 'locked']),
focusDate: state.getIn(['compose', 'focusDate']),
caretPosition: state.getIn(['compose', 'caretPosition']),
isSubmitting: state.getIn(['compose', 'is_submitting']),
isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
isUploading: state.getIn(['compose', 'is_uploading']),
layout: state.getIn(['local_settings', 'layout']),
media: state.getIn(['compose', 'media_attachments']),
preselectDate: state.getIn(['compose', 'preselectDate']),
privacy: state.getIn(['compose', 'privacy']),
progress: state.getIn(['compose', 'progress']),
inReplyTo: inReplyTo ? state.getIn(['statuses', inReplyTo]) : null,
replyAccount: inReplyTo ? state.getIn(['statuses', inReplyTo, 'account']) : null,
replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null,
resetFileKey: state.getIn(['compose', 'resetFileKey']),
sideArm: sideArmPrivacy,
sensitive: state.getIn(['compose', 'sensitive']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
spoiler: spoilersAlwaysOn || state.getIn(['compose', 'spoiler']),
spoilerText: state.getIn(['compose', 'spoiler_text']),
suggestionToken: state.getIn(['compose', 'suggestion_token']),
suggestions: state.getIn(['compose', 'suggestions']),
text: state.getIn(['compose', 'text']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
poll: state.getIn(['compose', 'poll']),
spoilersAlwaysOn: spoilersAlwaysOn,
mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
};
};
// Dispatch mapping.
const mapDispatchToProps = (dispatch, { intl }) => ({
onCancelReply() {
dispatch(cancelReplyCompose());
},
onChangeAdvancedOption(option, value) {
dispatch(changeComposeAdvancedOption(option, value));
},
onChangeDescription(id, description) {
dispatch(changeUploadCompose(id, { description }));
},
onChangeSensitivity() {
dispatch(changeComposeSensitivity());
},
onChangeSpoilerText(text) {
dispatch(changeComposeSpoilerText(text));
},
onChangeSpoilerness() {
dispatch(changeComposeSpoilerness());
},
onChangeText(text) {
dispatch(changeCompose(text));
},
onChangeVisibility(value) {
dispatch(changeComposeVisibility(value));
},
onTogglePoll() {
dispatch((_, getState) => {
if (getState().getIn(['compose', 'poll'])) {
dispatch(removePoll());
} else {
dispatch(addPoll());
}
});
},
onClearSuggestions() {
dispatch(clearComposeSuggestions());
},
onCloseModal() {
dispatch(closeModal());
},
onFetchSuggestions(token) {
dispatch(fetchComposeSuggestions(token));
},
onInsertEmoji(position, emoji) {
dispatch(insertEmojiCompose(position, emoji));
},
onMount() {
dispatch(mountCompose());
},
onOpenActionsModal(props) {
dispatch(openModal('ACTIONS', props));
},
onOpenDoodleModal() {
dispatch(openModal('DOODLE', { noEsc: true }));
},
onOpenFocalPointModal(id) {
dispatch(openModal('FOCAL_POINT', { id }));
},
onSelectSuggestion(position, token, suggestion) {
dispatch(selectComposeSuggestion(position, token, suggestion));
},
onMediaDescriptionConfirm(routerHistory) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.missingDescriptionMessage),
confirm: intl.formatMessage(messages.missingDescriptionConfirm),
onConfirm: () => dispatch(submitCompose(routerHistory)),
onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)),
}));
},
onSubmit(routerHistory) {
dispatch(submitCompose(routerHistory));
},
onUndoUpload(id) {
dispatch(undoUploadCompose(id));
},
onUnmount() {
dispatch(unmountCompose());
},
onUpload(files) {
dispatch(uploadCompose(files));
},
});
// Handlers.
const handlers = {
// Changes the text value of the spoiler.
handleChangeSpoiler ({ target: { value } }) {
const { onChangeSpoilerText } = this.props;
if (onChangeSpoilerText) {
onChangeSpoilerText(value);
}
},
// Inserts an emoji at the caret.
handleEmoji (data) {
const { textarea: { selectionStart } } = this;
const { onInsertEmoji } = this.props;
if (onInsertEmoji) {
onInsertEmoji(selectionStart, data);
}
},
// Handles the secondary submit button.
handleSecondarySubmit () {
const { handleSubmit } = this.handlers;
const {
onChangeVisibility,
sideArm,
} = this.props;
if (sideArm !== 'none' && onChangeVisibility) {
onChangeVisibility(sideArm);
}
handleSubmit();
},
// Selects a suggestion from the autofill.
handleSelect (tokenStart, token, value) {
const { onSelectSuggestion } = this.props;
if (onSelectSuggestion) {
onSelectSuggestion(tokenStart, token, value);
}
},
// Submits the status.
handleSubmit () {
const { textarea: { value }, uploadForm } = this;
const {
onChangeText,
onSubmit,
isSubmitting,
isChangingUpload,
isUploading,
media,
anyMedia,
text,
mediaDescriptionConfirmation,
onMediaDescriptionConfirm,
} = this.props;
// If something changes inside the textarea, then we update the
// state before submitting.
if (onChangeText && text !== value) {
onChangeText(value);
}
// Submit disabled:
if (isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia)) {
return;
}
// Submit unless there are media with missing descriptions
if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) {
const firstWithoutDescription = media.findIndex(item => !item.get('description'));
if (uploadForm) {
const inputs = uploadForm.querySelectorAll('.composer--upload_form--item input');
if (inputs.length == media.size && firstWithoutDescription !== -1) {
inputs[firstWithoutDescription].focus();
}
}
onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null);
} else if (onSubmit) {
onSubmit(this.context.router ? this.context.router.history : null);
}
},
// Sets a reference to the upload form.
handleRefUploadForm (uploadFormComponent) {
this.uploadForm = uploadFormComponent;
},
// Sets a reference to the textarea.
handleRefTextarea (textareaComponent) {
if (textareaComponent) {
this.textarea = textareaComponent.textarea;
}
},
// Sets a reference to the CW field.
handleRefSpoilerText (spoilerComponent) {
if (spoilerComponent) {
this.spoilerText = spoilerComponent.spoilerText;
}
}
};
// The component.
class Composer extends React.Component {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
// Instance variables.
this.textarea = null;
this.spoilerText = null;
}
// Tells our state the composer has been mounted.
componentDidMount () {
const { onMount } = this.props;
if (onMount) {
onMount();
}
}
// Tells our state the composer has been unmounted.
componentWillUnmount () {
const { onUnmount } = this.props;
if (onUnmount) {
onUnmount();
}
}
// This statement does several things:
// - If we're beginning a reply, and,
// - Replying to zero or one users, places the cursor at the end
// of the textbox.
// - Replying to more than one user, selects any usernames past
// the first; this provides a convenient shortcut to drop
// everyone else from the conversation.
componentDidUpdate (prevProps) {
const {
textarea,
spoilerText,
} = this;
const {
focusDate,
caretPosition,
isSubmitting,
preselectDate,
text,
preselectOnReply,
} = this.props;
let selectionEnd, selectionStart;
// Caret/selection handling.
if (focusDate !== prevProps.focusDate) {
switch (true) {
case preselectDate !== prevProps.preselectDate && preselectOnReply:
selectionStart = text.search(/\s/) + 1;
selectionEnd = text.length;
break;
case !isNaN(caretPosition) && caretPosition !== null:
selectionStart = selectionEnd = caretPosition;
break;
default:
selectionStart = selectionEnd = text.length;
}
if (textarea) {
textarea.setSelectionRange(selectionStart, selectionEnd);
textarea.focus();
textarea.scrollIntoView();
}
// Refocuses the textarea after submitting.
} else if (textarea && prevProps.isSubmitting && !isSubmitting) {
textarea.focus();
} else if (this.props.spoiler !== prevProps.spoiler) {
if (this.props.spoiler) {
if (spoilerText) {
spoilerText.focus();
}
} else {
if (textarea) {
textarea.focus();
}
}
}
}
render () {
const {
handleChangeSpoiler,
handleEmoji,
handleSecondarySubmit,
handleSelect,
handleSubmit,
handleRefUploadForm,
handleRefTextarea,
handleRefSpoilerText,
} = this.handlers;
const {
acceptContentTypes,
advancedOptions,
amUnlocked,
anyMedia,
intl,
isSubmitting,
isChangingUpload,
isUploading,
layout,
media,
poll,
onCancelReply,
onChangeAdvancedOption,
onChangeDescription,
onChangeSensitivity,
onChangeSpoilerness,
onChangeText,
onChangeVisibility,
onTogglePoll,
onClearSuggestions,
onCloseModal,
onFetchSuggestions,
onOpenActionsModal,
onOpenDoodleModal,
onOpenFocalPointModal,
onUndoUpload,
onUpload,
privacy,
progress,
inReplyTo,
resetFileKey,
sensitive,
showSearch,
sideArm,
spoiler,
spoilerText,
suggestions,
text,
spoilersAlwaysOn,
} = this.props;
let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia);
return (
<div className='composer'>
{privacy === 'direct' ? <ComposerDirectWarning /> : null}
{privacy === 'private' && amUnlocked ? <ComposerWarning /> : null}
{privacy !== 'public' && APPROX_HASHTAG_RE.test(text) ? <ComposerHashtagWarning /> : null}
{inReplyTo && (
<ComposerReply
status={inReplyTo}
intl={intl}
onCancel={onCancelReply}
/>
)}
<ComposerSpoiler
hidden={!spoiler}
intl={intl}
onChange={handleChangeSpoiler}
onSubmit={handleSubmit}
onSecondarySubmit={handleSecondarySubmit}
text={spoilerText}
ref={handleRefSpoilerText}
/>
<ComposerTextarea
advancedOptions={advancedOptions}
autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
disabled={isSubmitting}
intl={intl}
onChange={onChangeText}
onPaste={onUpload}
onPickEmoji={handleEmoji}
onSubmit={handleSubmit}
onSecondarySubmit={handleSecondarySubmit}
onSuggestionsClearRequested={onClearSuggestions}
onSuggestionsFetchRequested={onFetchSuggestions}
onSuggestionSelected={handleSelect}
ref={handleRefTextarea}
suggestions={suggestions}
value={text}
/>
<div className='compose-form__modifiers'>
{isUploading || media && media.size ? (
<ComposerUploadForm
intl={intl}
media={media}
onChangeDescription={onChangeDescription}
onOpenFocalPointModal={onOpenFocalPointModal}
onRemove={onUndoUpload}
progress={progress}
uploading={isUploading}
handleRef={handleRefUploadForm}
/>
) : null}
{!!poll && (
<ComposerPollForm />
)}
</div>
<ComposerOptions
acceptContentTypes={acceptContentTypes}
advancedOptions={advancedOptions}
disabled={isSubmitting}
allowMedia={!poll && (media ? media.size < 4 && !media.some(
item => item.get('type') === 'video'
) : true)}
hasMedia={media && !!media.size}
allowPoll={!(media && !!media.size)}
hasPoll={!!poll}
intl={intl}
onChangeAdvancedOption={onChangeAdvancedOption}
onChangeSensitivity={onChangeSensitivity}
onChangeVisibility={onChangeVisibility}
onTogglePoll={onTogglePoll}
onDoodleOpen={onOpenDoodleModal}
onModalClose={onCloseModal}
onModalOpen={onOpenActionsModal}
onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
onUpload={onUpload}
privacy={privacy}
resetFileKey={resetFileKey}
sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
/>
<ComposerPublisher
countText={spoilerText ? '∞' : `${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 🐺': ''}`}
disabled={disabledButton}
intl={intl}
onSecondarySubmit={handleSecondarySubmit}
onSubmit={handleSubmit}
privacy={privacy}
sideArm={sideArm}
/>
</div>
);
}
}
// Props.
Composer.propTypes = {
intl: PropTypes.object.isRequired,
// State props.
acceptContentTypes: PropTypes.string,
advancedOptions: ImmutablePropTypes.map,
amUnlocked: PropTypes.bool,
focusDate: PropTypes.instanceOf(Date),
caretPosition: PropTypes.number,
isSubmitting: PropTypes.bool,
isChangingUpload: PropTypes.bool,
isUploading: PropTypes.bool,
layout: PropTypes.string,
media: ImmutablePropTypes.list,
preselectDate: PropTypes.instanceOf(Date),
privacy: PropTypes.string,
progress: PropTypes.number,
inReplyTo: ImmutablePropTypes.map,
resetFileKey: PropTypes.number,
sideArm: PropTypes.string,
sensitive: PropTypes.bool,
showSearch: PropTypes.bool,
spoiler: PropTypes.bool,
spoilerText: PropTypes.string,
suggestionToken: PropTypes.string,
suggestions: ImmutablePropTypes.list,
text: PropTypes.string,
anyMedia: PropTypes.bool,
spoilersAlwaysOn: PropTypes.bool,
mediaDescriptionConfirmation: PropTypes.bool,
preselectOnReply: PropTypes.bool,
// Dispatch props.
onCancelReply: PropTypes.func,
onChangeAdvancedOption: PropTypes.func,
onChangeDescription: PropTypes.func,
onChangeSensitivity: PropTypes.func,
onChangeSpoilerText: PropTypes.func,
onChangeSpoilerness: PropTypes.func,
onChangeText: PropTypes.func,
onChangeVisibility: PropTypes.func,
onClearSuggestions: PropTypes.func,
onCloseModal: PropTypes.func,
onFetchSuggestions: PropTypes.func,
onInsertEmoji: PropTypes.func,
onMount: PropTypes.func,
onOpenActionsModal: PropTypes.func,
onOpenDoodleModal: PropTypes.func,
onSelectSuggestion: PropTypes.func,
onSubmit: PropTypes.func,
onUndoUpload: PropTypes.func,
onUnmount: PropTypes.func,
onUpload: PropTypes.func,
onMediaDescriptionConfirm: PropTypes.func,
};
Composer.contextTypes = {
router: PropTypes.object,
};
// Connecting and export.
export { Composer as WrappedComponent };
export default wrap(Composer, mapStateToProps, mapDispatchToProps, true);

View File

@ -1,146 +0,0 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import spring from 'react-motion/lib/spring';
// Components.
import ComposerOptionsDropdownContentItem from './item';
// Utils.
import { withPassive } from 'flavours/glitch/util/dom_helpers';
import Motion from 'flavours/glitch/util/optional_motion';
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
// Handlers.
const handlers = {
// When the document is clicked elsewhere, we close the dropdown.
handleDocumentClick ({ target }) {
const { node } = this;
const { onClose } = this.props;
if (onClose && node && !node.contains(target)) {
onClose();
}
},
// Stores our node in `this.node`.
handleRef (node) {
this.node = node;
},
};
// The spring to use with our motion.
const springMotion = spring(1, {
damping: 35,
stiffness: 400,
});
// The component.
export default class ComposerOptionsDropdownContent extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
// Instance variables.
this.node = null;
this.state = {
mounted: false,
};
}
// On mounting, we add our listeners.
componentDidMount () {
const { handleDocumentClick } = this.handlers;
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('touchend', handleDocumentClick, withPassive);
this.setState({ mounted: true });
}
// On unmounting, we remove our listeners.
componentWillUnmount () {
const { handleDocumentClick } = this.handlers;
document.removeEventListener('click', handleDocumentClick, false);
document.removeEventListener('touchend', handleDocumentClick, withPassive);
}
// Rendering.
render () {
const { mounted } = this.state;
const { handleRef } = this.handlers;
const {
items,
onChange,
onClose,
style,
value,
} = this.props;
// The result.
return (
<Motion
defaultStyle={{
opacity: 0,
scaleX: 0.85,
scaleY: 0.75,
}}
style={{
opacity: springMotion,
scaleX: springMotion,
scaleY: springMotion,
}}
>
{({ opacity, scaleX, scaleY }) => (
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div
className='composer--options--dropdown--content'
ref={handleRef}
style={{
...style,
opacity: opacity,
transform: mounted ? `scale(${scaleX}, ${scaleY})` : null,
}}
>
{items ? items.map(
({
name,
...rest
}) => (
<ComposerOptionsDropdownContentItem
active={name === value}
key={name}
name={name}
onChange={onChange}
onClose={onClose}
options={rest}
/>
)
) : null}
</div>
)}
</Motion>
);
}
}
// Props.
ComposerOptionsDropdownContent.propTypes = {
items: PropTypes.arrayOf(PropTypes.shape({
icon: PropTypes.string,
meta: PropTypes.node,
name: PropTypes.string.isRequired,
on: PropTypes.bool,
text: PropTypes.node,
})),
onChange: PropTypes.func,
onClose: PropTypes.func,
style: PropTypes.object,
value: PropTypes.string,
};
// Default props.
ComposerOptionsDropdownContent.defaultProps = { style: {} };

View File

@ -1,129 +0,0 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Toggle from 'react-toggle';
// Components.
import Icon from 'flavours/glitch/components/icon';
// Utils.
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
// Handlers.
const handlers = {
// This function activates the dropdown item.
handleActivate (e) {
const {
name,
onChange,
onClose,
options: { on },
} = this.props;
// If the escape key was pressed, we close the dropdown.
if (e.key === 'Escape' && onClose) {
onClose();
// Otherwise, we both close the dropdown and change the value.
} else if (onChange && (!e.key || e.key === 'Enter')) {
e.preventDefault(); // Prevents change in focus on click
if ((on === null || typeof on === 'undefined') && onClose) {
onClose();
}
onChange(name);
}
},
};
// The component.
export default class ComposerOptionsDropdownContentItem extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
}
// Rendering.
render () {
const { handleActivate } = this.handlers;
const {
active,
options: {
icon,
meta,
on,
text,
},
} = this.props;
const computedClass = classNames('composer--options--dropdown--content--item', {
active,
lengthy: meta,
'toggled-off': !on && on !== null && typeof on !== 'undefined',
'toggled-on': on,
'with-icon': icon,
});
// The result.
return (
<div
className={computedClass}
onClick={handleActivate}
onKeyDown={handleActivate}
role='button'
tabIndex='0'
>
{function () {
// We render a `<Toggle>` if we were provided an `on`
// property, and otherwise show an `<Icon>` if available.
switch (true) {
case on !== null && typeof on !== 'undefined':
return (
<Toggle
checked={on}
onChange={handleActivate}
/>
);
case !!icon:
return (
<Icon
className='icon'
fullwidth
icon={icon}
/>
);
default:
return null;
}
}()}
{meta ? (
<div className='content'>
<strong>{text}</strong>
{meta}
</div>
) :
<div className='content'>
<strong>{text}</strong>
</div>}
</div>
);
}
};
// Props.
ComposerOptionsDropdownContentItem.propTypes = {
active: PropTypes.bool,
name: PropTypes.string,
onChange: PropTypes.func,
onClose: PropTypes.func,
options: PropTypes.shape({
icon: PropTypes.string,
meta: PropTypes.node,
on: PropTypes.bool,
text: PropTypes.node,
}),
};

View File

@ -1,122 +0,0 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import {
defineMessages,
FormattedMessage,
} from 'react-intl';
import { length } from 'stringz';
// Components.
import Button from 'flavours/glitch/components/button';
import Icon from 'flavours/glitch/components/icon';
// Utils.
import { maxChars } from 'flavours/glitch/util/initial_state';
// Messages.
const messages = defineMessages({
publish: {
defaultMessage: 'Toot',
id: 'compose_form.publish',
},
publishLoud: {
defaultMessage: '{publish}!',
id: 'compose_form.publish_loud',
},
});
// The component.
export default function ComposerPublisher ({
countText,
disabled,
intl,
onSecondarySubmit,
onSubmit,
privacy,
sideArm,
}) {
const diff = maxChars - length(countText || '');
const computedClass = classNames('composer--publisher', {
disabled: disabled || diff < 0,
over: diff < 0,
});
// The result.
return (
<div className={computedClass}>
<span className='count'>{countText === '∞' ? '∞' : diff}</span>
{sideArm && sideArm !== 'none' ? (
<Button
className='side_arm'
disabled={disabled || diff < 0}
onClick={onSecondarySubmit}
style={{ padding: null }}
text={
<span>
<Icon
icon={{
public: 'globe',
unlisted: 'unlock',
private: 'lock',
direct: 'envelope',
}[sideArm]}
/>
</span>
}
title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
/>
) : null}
<Button
className='primary'
text={function () {
switch (true) {
case !!sideArm && sideArm !== 'none':
case privacy === 'direct':
case privacy === 'private':
return (
<span>
<Icon
icon={{
direct: 'envelope',
private: 'lock',
public: 'globe',
unlisted: 'unlock',
}[privacy]}
/>
{' '}
<FormattedMessage {...messages.publish} />
</span>
);
case privacy === 'public':
return (
<span>
<FormattedMessage
{...messages.publishLoud}
values={{ publish: <FormattedMessage {...messages.publish} /> }}
/>
</span>
);
default:
return <span><FormattedMessage {...messages.publish} /></span>;
}
}()}
title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
onClick={onSubmit}
disabled={disabled || diff < 0}
/>
</div>
);
}
// Props.
ComposerPublisher.propTypes = {
countText: PropTypes.string,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onSecondarySubmit: PropTypes.func,
onSubmit: PropTypes.func,
privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
};

View File

@ -1,107 +0,0 @@
// Package imports.
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, FormattedMessage } from 'react-intl';
// Utils.
import {
assignHandlers,
hiddenComponent,
} from 'flavours/glitch/util/react_helpers';
// Messages.
const messages = defineMessages({
placeholder: {
defaultMessage: 'Write your warning here',
id: 'compose_form.spoiler_placeholder',
},
});
// Handlers.
const handlers = {
// Handles a keypress.
handleKeyDown ({
ctrlKey,
keyCode,
metaKey,
altKey,
}) {
const { onSubmit, onSecondarySubmit } = this.props;
// We submit the status on control/meta + enter.
if (onSubmit && keyCode === 13 && (ctrlKey || metaKey)) {
onSubmit();
}
// Submit the status with secondary visibility on alt + enter.
if (onSecondarySubmit && keyCode === 13 && altKey) {
onSecondarySubmit();
}
},
handleRefSpoilerText (spoilerText) {
this.spoilerText = spoilerText;
},
// When the escape key is released, we focus the UI.
handleKeyUp ({ key }) {
if (key === 'Escape') {
document.querySelector('.ui').parentElement.focus();
}
},
};
// The component.
export default class ComposerSpoiler extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
}
// Rendering.
render () {
const { handleKeyDown, handleKeyUp, handleRefSpoilerText } = this.handlers;
const {
hidden,
intl,
onChange,
text,
} = this.props;
// The result.
return (
<div className={`composer--spoiler ${hidden ? '' : 'composer--spoiler--visible'}`}>
<label>
<span {...hiddenComponent}>
<FormattedMessage {...messages.placeholder} />
</span>
<input
id='glitch.composer.spoiler.input'
onChange={onChange}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
placeholder={intl.formatMessage(messages.placeholder)}
type='text'
value={text}
ref={handleRefSpoilerText}
disabled={hidden}
/>
</label>
</div>
);
}
}
// Props.
ComposerSpoiler.propTypes = {
hidden: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func,
onSubmit: PropTypes.func,
onSecondarySubmit: PropTypes.func,
text: PropTypes.string,
};

View File

@ -1,60 +0,0 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages } from 'react-intl';
// Components.
import Icon from 'flavours/glitch/components/icon';
// Messages.
const messages = defineMessages({
localOnly: {
defaultMessage: 'This post is local-only',
id: 'advanced_options.local-only.tooltip',
},
threadedMode: {
defaultMessage: 'Threaded mode enabled',
id: 'advanced_options.threaded_mode.tooltip',
},
});
// We use an array of tuples here instead of an object because it
// preserves order.
const iconMap = [
['do_not_federate', 'home', messages.localOnly],
['threaded_mode', 'comments', messages.threadedMode],
];
// The component.
export default function ComposerTextareaIcons ({
advancedOptions,
intl,
}) {
// The result. We just map every active option to its icon.
return (
<div className='composer--textarea--icons'>
{advancedOptions ? iconMap.map(
([key, icon, message]) => advancedOptions.get(key) ? (
<span
className='textarea_icon'
key={key}
title={intl.formatMessage(message)}
>
<Icon
fullwidth
icon={icon}
/>
</span>
) : null
) : null}
</div>
);
}
// Props.
ComposerTextareaIcons.propTypes = {
advancedOptions: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
};

View File

@ -1,312 +0,0 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import {
defineMessages,
FormattedMessage,
} from 'react-intl';
import Textarea from 'react-textarea-autosize';
// Components.
import EmojiPicker from 'flavours/glitch/features/emoji_picker';
import ComposerTextareaIcons from './icons';
import ComposerTextareaSuggestions from './suggestions';
// Utils.
import { isRtl } from 'flavours/glitch/util/rtl';
import {
assignHandlers,
hiddenComponent,
} from 'flavours/glitch/util/react_helpers';
// Messages.
const messages = defineMessages({
placeholder: {
defaultMessage: 'What is on your mind?',
id: 'compose_form.placeholder',
},
});
// Handlers.
const handlers = {
// When blurring the textarea, suggestions are hidden.
handleBlur () {
this.setState({ suggestionsHidden: true });
},
// When the contents of the textarea change, we have to pull up new
// autosuggest suggestions if applicable, and also change the value
// of the textarea in our store.
handleChange ({
target: {
selectionStart,
value,
},
}) {
const {
onChange,
onSuggestionsFetchRequested,
onSuggestionsClearRequested,
} = this.props;
const { lastToken } = this.state;
// This gets the token at the caret location, if it begins with an
// `@` (mentions) or `:` (shortcodes).
const left = value.slice(0, selectionStart).search(/[^\s\u200B]+$/);
const right = value.slice(selectionStart).search(/[\s\u200B]/);
const token = function () {
switch (true) {
case left < 0 || !/[@:#]/.test(value[left]):
return null;
case right < 0:
return value.slice(left);
default:
return value.slice(left, right + selectionStart).trim().toLowerCase();
}
}();
// We only request suggestions for tokens which are at least 3
// characters long.
if (onSuggestionsFetchRequested && token && token.length >= 3) {
if (lastToken !== token) {
this.setState({
lastToken: token,
selectedSuggestion: 0,
tokenStart: left,
});
onSuggestionsFetchRequested(token);
}
} else {
this.setState({ lastToken: null });
if (onSuggestionsClearRequested) {
onSuggestionsClearRequested();
}
}
// Updates the value of the textarea.
if (onChange) {
onChange(value);
}
},
// Handles a click on an autosuggestion.
handleClickSuggestion (index) {
const { textarea } = this;
const {
onSuggestionSelected,
suggestions,
} = this.props;
const {
lastToken,
tokenStart,
} = this.state;
onSuggestionSelected(tokenStart, lastToken, suggestions.get(index));
textarea.focus();
},
// Handles a keypress. If the autosuggestions are visible, we need
// to allow keypresses to navigate and sleect them.
handleKeyDown (e) {
const {
disabled,
onSubmit,
onSecondarySubmit,
onSuggestionSelected,
suggestions,
} = this.props;
const {
lastToken,
suggestionsHidden,
selectedSuggestion,
tokenStart,
} = this.state;
// Keypresses do nothing if the composer is disabled.
if (disabled) {
e.preventDefault();
return;
}
// We submit the status on control/meta + enter.
if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
onSubmit();
}
// Submit the status with secondary visibility on alt + enter.
if (onSecondarySubmit && e.keyCode === 13 && e.altKey) {
onSecondarySubmit();
}
// Switches over the pressed key.
switch(e.key) {
// On arrow down, we pick the next suggestion.
case 'ArrowDown':
if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
}
return;
// On arrow up, we pick the previous suggestion.
case 'ArrowUp':
if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
}
return;
// On enter or tab, we select the suggestion.
case 'Enter':
case 'Tab':
if (onSuggestionSelected && lastToken !== null && suggestions && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
onSuggestionSelected(tokenStart, lastToken, suggestions.get(selectedSuggestion));
}
return;
}
},
// When the escape key is released, we either close the suggestions
// window or focus the UI.
handleKeyUp ({ key }) {
const { suggestionsHidden } = this.state;
if (key === 'Escape') {
if (!suggestionsHidden) {
this.setState({ suggestionsHidden: true });
} else {
document.querySelector('.ui').parentElement.focus();
}
}
},
// Handles the pasting of images into the composer.
handlePaste (e) {
const { onPaste } = this.props;
let d;
if (onPaste && (d = e.clipboardData) && (d = d.files).length === 1) {
onPaste(d);
e.preventDefault();
}
},
// Saves a reference to the textarea.
handleRefTextarea (textarea) {
this.textarea = textarea;
},
};
// The component.
export default class ComposerTextarea extends React.Component {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
this.state = {
suggestionsHidden: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,
};
// Instance variables.
this.textarea = null;
}
// When we receive new suggestions, we unhide the suggestions window
// if we didn't have any suggestions before.
componentWillReceiveProps (nextProps) {
const { suggestions } = this.props;
const { suggestionsHidden } = this.state;
if (nextProps.suggestions && nextProps.suggestions !== suggestions && nextProps.suggestions.size > 0 && suggestionsHidden) {
this.setState({ suggestionsHidden: false });
}
}
// Rendering.
render () {
const {
handleBlur,
handleChange,
handleClickSuggestion,
handleKeyDown,
handleKeyUp,
handlePaste,
handleRefTextarea,
} = this.handlers;
const {
advancedOptions,
autoFocus,
disabled,
intl,
onPickEmoji,
suggestions,
value,
} = this.props;
const {
selectedSuggestion,
suggestionsHidden,
} = this.state;
// The result.
return (
<div className='composer--textarea'>
<label>
<span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span>
<ComposerTextareaIcons
advancedOptions={advancedOptions}
intl={intl}
/>
<Textarea
aria-autocomplete='list'
autoFocus={autoFocus}
className='textarea'
disabled={disabled}
inputRef={handleRefTextarea}
onBlur={handleBlur}
onChange={handleChange}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPaste={handlePaste}
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
style={{ direction: isRtl(value) ? 'rtl' : 'ltr' }}
/>
</label>
<EmojiPicker onPickEmoji={onPickEmoji} />
<ComposerTextareaSuggestions
hidden={suggestionsHidden}
onSuggestionClick={handleClickSuggestion}
suggestions={suggestions}
value={selectedSuggestion}
/>
</div>
);
}
}
// Props.
ComposerTextarea.propTypes = {
advancedOptions: ImmutablePropTypes.map,
autoFocus: PropTypes.bool,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func,
onPaste: PropTypes.func,
onPickEmoji: PropTypes.func,
onSubmit: PropTypes.func,
onSecondarySubmit: PropTypes.func,
onSuggestionsClearRequested: PropTypes.func,
onSuggestionsFetchRequested: PropTypes.func,
onSuggestionSelected: PropTypes.func,
suggestions: ImmutablePropTypes.list,
value: PropTypes.string,
};
// Default props.
ComposerTextarea.defaultProps = { autoFocus: true };

View File

@ -1,43 +0,0 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Components.
import ComposerTextareaSuggestionsItem from './item';
// The component.
export default function ComposerTextareaSuggestions ({
hidden,
onSuggestionClick,
suggestions,
value,
}) {
// The result.
return (
<div
className='composer--textarea--suggestions'
hidden={hidden || !suggestions || suggestions.isEmpty()}
>
{!hidden && suggestions ? suggestions.map(
(suggestion, index) => (
<ComposerTextareaSuggestionsItem
index={index}
key={typeof suggestion === 'object' ? suggestion.id : suggestion}
onClick={onSuggestionClick}
selected={index === value}
suggestion={suggestion}
/>
)
) : null}
</div>
);
}
ComposerTextareaSuggestions.propTypes = {
hidden: PropTypes.bool,
onSuggestionClick: PropTypes.func,
suggestions: ImmutablePropTypes.list,
value: PropTypes.number,
};

View File

@ -1,118 +0,0 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
// Components.
import AccountContainer from 'flavours/glitch/containers/account_container';
// Utils.
import { unicodeMapping } from 'flavours/glitch/util/emoji';
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
// Gets our asset host from the environment, if available.
const assetHost = process.env.CDN_HOST || '';
// Handlers.
const handlers = {
// Handles a click on a suggestion.
handleClick (e) {
const {
index,
onClick,
} = this.props;
if (onClick) {
e.preventDefault();
e.stopPropagation(); // Prevents following account links
onClick(index);
}
},
// This prevents the focus from changing, which would mess with
// our suggestion code.
handleMouseDown (e) {
e.preventDefault();
},
};
// The component.
export default class ComposerTextareaSuggestionsItem extends React.Component {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
}
// Rendering.
render () {
const {
handleMouseDown,
handleClick,
} = this.handlers;
const {
selected,
suggestion,
} = this.props;
const computedClass = classNames('composer--textarea--suggestions--item', { selected });
// If the suggestion is an object, then we render an emoji.
// Otherwise, we render a hashtag if it starts with #, or an account.
let inner;
if (typeof suggestion === 'object') {
let url;
if (suggestion.custom) {
url = suggestion.imageUrl;
} else {
const mapping = unicodeMapping[suggestion.native] || unicodeMapping[suggestion.native.replace(/\uFE0F$/, '')];
if (mapping) {
url = `${assetHost}/emoji/${mapping.filename}.svg`;
}
}
if (url) {
inner = (
<div className='emoji'>
<img
alt={suggestion.native || suggestion.colons}
className='emojione'
src={url}
/>
{suggestion.colons}
</div>
);
}
} else if (suggestion[0] === '#') {
inner = suggestion;
} else {
inner = (
<AccountContainer
id={suggestion}
small
/>
);
}
// The result.
return (
<div
className={computedClass}
onMouseDown={handleMouseDown}
onClickCapture={handleClick} // Jumps in front of contents
role='button'
tabIndex='0'
>
{ inner }
</div>
);
}
}
// Props.
ComposerTextareaSuggestionsItem.propTypes = {
index: PropTypes.number,
onClick: PropTypes.func,
selected: PropTypes.bool,
suggestion: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
};

View File

@ -1,60 +0,0 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Components.
import ComposerUploadFormItem from './item';
import ComposerUploadFormProgress from './progress';
// The component.
export default function ComposerUploadForm ({
intl,
media,
onChangeDescription,
onOpenFocalPointModal,
onRemove,
progress,
uploading,
handleRef,
}) {
const computedClass = classNames('composer--upload_form', { uploading });
// The result.
return (
<div className={computedClass} ref={handleRef}>
{uploading ? <ComposerUploadFormProgress progress={progress} /> : null}
{media ? (
<div className='content'>
{media.map(item => (
<ComposerUploadFormItem
description={item.get('description')}
key={item.get('id')}
id={item.get('id')}
intl={intl}
focusX={item.getIn(['meta', 'focus', 'x'])}
focusY={item.getIn(['meta', 'focus', 'y'])}
mediaType={item.get('type')}
preview={item.get('preview_url')}
onChangeDescription={onChangeDescription}
onOpenFocalPointModal={onOpenFocalPointModal}
onRemove={onRemove}
/>
))}
</div>
) : null}
</div>
);
}
// Props.
ComposerUploadForm.propTypes = {
intl: PropTypes.object.isRequired,
media: ImmutablePropTypes.list,
onChangeDescription: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
progress: PropTypes.number,
uploading: PropTypes.bool,
handleRef: PropTypes.func,
};

View File

@ -1,202 +0,0 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import {
FormattedMessage,
defineMessages,
} from 'react-intl';
import spring from 'react-motion/lib/spring';
// Components.
import IconButton from 'flavours/glitch/components/icon_button';
// Utils.
import Motion from 'flavours/glitch/util/optional_motion';
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
import { isUserTouching } from 'flavours/glitch/util/is_mobile';
// Messages.
const messages = defineMessages({
undo: {
defaultMessage: 'Undo',
id: 'upload_form.undo',
},
description: {
defaultMessage: 'Describe for the visually impaired',
id: 'upload_form.description',
},
crop: {
defaultMessage: 'Crop',
id: 'upload_form.focus',
},
});
// Handlers.
const handlers = {
// On blur, we save the description for the media item.
handleBlur () {
const {
id,
onChangeDescription,
} = this.props;
const { dirtyDescription } = this.state;
this.setState({ dirtyDescription: null, focused: false });
if (id && onChangeDescription && dirtyDescription !== null) {
onChangeDescription(id, dirtyDescription);
}
},
// When the value of our description changes, we store it in the
// temp value `dirtyDescription` in our state.
handleChange ({ target: { value } }) {
this.setState({ dirtyDescription: value });
},
// Records focus on the media item.
handleFocus () {
this.setState({ focused: true });
},
// Records the start of a hover over the media item.
handleMouseEnter () {
this.setState({ hovered: true });
},
// Records the end of a hover over the media item.
handleMouseLeave () {
this.setState({ hovered: false });
},
// Removes the media item.
handleRemove () {
const {
id,
onRemove,
} = this.props;
if (id && onRemove) {
onRemove(id);
}
},
// Opens the focal point modal.
handleFocalPointClick () {
const {
id,
onOpenFocalPointModal,
} = this.props;
if (id && onOpenFocalPointModal) {
onOpenFocalPointModal(id);
}
},
};
// The component.
export default class ComposerUploadFormItem extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
this.state = {
hovered: false,
focused: false,
dirtyDescription: null,
};
}
// Rendering.
render () {
const {
handleBlur,
handleChange,
handleFocus,
handleMouseEnter,
handleMouseLeave,
handleRemove,
handleFocalPointClick,
} = this.handlers;
const {
intl,
preview,
focusX,
focusY,
mediaType,
} = this.props;
const {
focused,
hovered,
dirtyDescription,
} = this.state;
const active = hovered || focused || isUserTouching();
const computedClass = classNames('composer--upload_form--item', { active });
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
const description = dirtyDescription || (dirtyDescription !== '' && this.props.description) || '';
// The result.
return (
<div
className={computedClass}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Motion
defaultStyle={{ scale: 0.8 }}
style={{
scale: spring(1, {
stiffness: 180,
damping: 12,
}),
}}
>
{({ scale }) => (
<div
style={{
transform: `scale(${scale})`,
backgroundImage: preview ? `url(${preview})` : null,
backgroundPosition: `${x}% ${y}%`
}}
>
<div className={classNames('composer--upload_form--actions', { active })}>
<button className='icon-button' onClick={handleRemove}>
<i className='fa fa-times' /> <FormattedMessage {...messages.undo} />
</button>
{mediaType === 'image' && <button className='icon-button' onClick={handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage {...messages.crop} /></button>}
</div>
<label>
<span style={{ display: 'none' }}><FormattedMessage {...messages.description} /></span>
<textarea
maxLength={420}
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
placeholder={intl.formatMessage(messages.description)}
value={description}
/>
</label>
</div>
)}
</Motion>
</div>
);
}
}
// Props.
ComposerUploadFormItem.propTypes = {
description: PropTypes.string,
id: PropTypes.string,
intl: PropTypes.object.isRequired,
onChangeDescription: PropTypes.func.isRequired,
onOpenFocalPointModal: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
focusX: PropTypes.number,
focusY: PropTypes.number,
mediaType: PropTypes.string,
preview: PropTypes.string,
};

View File

@ -1,52 +0,0 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import {
defineMessages,
FormattedMessage,
} from 'react-intl';
import spring from 'react-motion/lib/spring';
// Components.
import Icon from 'flavours/glitch/components/icon';
// Utils.
import Motion from 'flavours/glitch/util/optional_motion';
// Messages.
const messages = defineMessages({
upload: {
defaultMessage: 'Uploading...',
id: 'upload_progress.label',
},
});
// The component.
export default function ComposerUploadFormProgress ({ progress }) {
// The result.
return (
<div className='composer--upload_form--progress'>
<Icon icon='upload' />
<div className='message'>
<FormattedMessage {...messages.upload} />
<div className='backdrop'>
<Motion
defaultStyle={{ width: 0 }}
style={{ width: spring(progress) }}
>
{({ width }) =>
(<div
className='tracker'
style={{ width: `${width}%` }}
/>)
}
</Motion>
</div>
</div>
</div>
);
}
// Props.
ComposerUploadFormProgress.propTypes = { progress: PropTypes.number };

View File

@ -1,59 +0,0 @@
import React from 'react';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { defineMessages, FormattedMessage } from 'react-intl';
import { profileLink } from 'flavours/glitch/util/backend_links';
// This is the spring used with our motion.
const motionSpring = spring(1, { damping: 35, stiffness: 400 });
// Messages.
const messages = defineMessages({
disclaimer: {
defaultMessage: 'Your account is not {locked}. Anyone can follow you to view your follower-only posts.',
id: 'compose_form.lock_disclaimer',
},
locked: {
defaultMessage: 'locked',
id: 'compose_form.lock_disclaimer.lock',
},
});
// The component.
export default function ComposerWarning () {
let lockedLink = <FormattedMessage {...messages.locked} />;
if (profileLink !== undefined) {
lockedLink = <a href={profileLink}>{lockedLink}</a>;
}
return (
<Motion
defaultStyle={{
opacity: 0,
scaleX: 0.85,
scaleY: 0.75,
}}
style={{
opacity: motionSpring,
scaleX: motionSpring,
scaleY: motionSpring,
}}
>
{({ opacity, scaleX, scaleY }) => (
<div
className='composer--warning'
style={{
opacity: opacity,
transform: `scale(${scaleX}, ${scaleY})`,
}}
>
<FormattedMessage
{...messages.disclaimer}
values={{ locked: lockedLink }}
/>
</div>
)}
</Motion>
);
}
ComposerWarning.propTypes = {};

View File

@ -1,76 +0,0 @@
// Package imports.
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import {
FormattedMessage,
defineMessages,
} from 'react-intl';
// Components.
import Avatar from 'flavours/glitch/components/avatar';
import Permalink from 'flavours/glitch/components/permalink';
// Utils.
import { hiddenComponent } from 'flavours/glitch/util/react_helpers';
import { profileLink } from 'flavours/glitch/util/backend_links';
// Messages.
const messages = defineMessages({
edit: {
defaultMessage: 'Edit profile',
id: 'navigation_bar.edit_profile',
},
});
// The component.
export default function DrawerAccount ({ account }) {
// We need an account to render.
if (!account) {
return (
<div className='drawer--account'>
{ profileLink !== undefined && (
<a
className='edit'
href={ profileLink }
>
<FormattedMessage {...messages.edit} />
</a>
)}
</div>
);
}
// The result.
return (
<div className='drawer--account'>
<Permalink
className='avatar'
href={account.get('url')}
to={`/accounts/${account.get('id')}`}
>
<span {...hiddenComponent}>{account.get('acct')}</span>
<Avatar
account={account}
size={40}
/>
</Permalink>
<Permalink
className='acct'
href={account.get('url')}
to={`/accounts/${account.get('id')}`}
>
<strong>@{account.get('acct')}</strong>
</Permalink>
{ profileLink !== undefined && (
<a
className='edit'
href={ profileLink }
><FormattedMessage {...messages.edit} /></a>
)}
</div>
);
}
// Props.
DrawerAccount.propTypes = { account: ImmutablePropTypes.map };

View File

@ -1,127 +0,0 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
// Components.
import Icon from 'flavours/glitch/components/icon';
// Utils.
import { conditionalRender } from 'flavours/glitch/util/react_helpers';
import { signOutLink } from 'flavours/glitch/util/backend_links';
// Messages.
const messages = defineMessages({
community: {
defaultMessage: 'Local timeline',
id: 'navigation_bar.community_timeline',
},
home_timeline: {
defaultMessage: 'Home',
id: 'tabs_bar.home',
},
logout: {
defaultMessage: 'Logout',
id: 'navigation_bar.logout',
},
notifications: {
defaultMessage: 'Notifications',
id: 'tabs_bar.notifications',
},
public: {
defaultMessage: 'Federated timeline',
id: 'navigation_bar.public_timeline',
},
settings: {
defaultMessage: 'App settings',
id: 'navigation_bar.app_settings',
},
start: {
defaultMessage: 'Getting started',
id: 'getting_started.heading',
},
});
// The component.
export default function DrawerHeader ({
columns,
unreadNotifications,
showNotificationsBadge,
intl,
onSettingsClick,
}) {
// Only renders the component if the column isn't being shown.
const renderForColumn = conditionalRender.bind(null,
columnId => !columns || !columns.some(
column => column.get('id') === columnId
)
);
// The result.
return (
<nav className='drawer--header'>
<Link
aria-label={intl.formatMessage(messages.start)}
title={intl.formatMessage(messages.start)}
to='/getting-started'
><Icon icon='asterisk' /></Link>
{renderForColumn('HOME', (
<Link
aria-label={intl.formatMessage(messages.home_timeline)}
title={intl.formatMessage(messages.home_timeline)}
to='/timelines/home'
><Icon icon='home' /></Link>
))}
{renderForColumn('NOTIFICATIONS', (
<Link
aria-label={intl.formatMessage(messages.notifications)}
title={intl.formatMessage(messages.notifications)}
to='/notifications'
>
<span className='icon-badge-wrapper'>
<Icon icon='bell' />
{ showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />}
</span>
</Link>
))}
{renderForColumn('COMMUNITY', (
<Link
aria-label={intl.formatMessage(messages.community)}
title={intl.formatMessage(messages.community)}
to='/timelines/public/local'
><Icon icon='users' /></Link>
))}
{renderForColumn('PUBLIC', (
<Link
aria-label={intl.formatMessage(messages.public)}
title={intl.formatMessage(messages.public)}
to='/timelines/public'
><Icon icon='globe' /></Link>
))}
<a
aria-label={intl.formatMessage(messages.settings)}
onClick={onSettingsClick}
href='#'
title={intl.formatMessage(messages.settings)}
><Icon icon='cogs' /></a>
<a
aria-label={intl.formatMessage(messages.logout)}
data-method='delete'
href={ signOutLink }
title={intl.formatMessage(messages.logout)}
><Icon icon='sign-out' /></a>
</nav>
);
}
// Props.
DrawerHeader.propTypes = {
columns: ImmutablePropTypes.list,
unreadNotifications: PropTypes.number,
showNotificationsBadge: PropTypes.bool,
intl: PropTypes.object,
onSettingsClick: PropTypes.func,
};

View File

@ -1,175 +0,0 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages } from 'react-intl';
import classNames from 'classnames';
// Actions.
import { openModal } from 'flavours/glitch/actions/modal';
import {
changeSearch,
clearSearch,
showSearch,
submitSearch,
} from 'flavours/glitch/actions/search';
import { cycleElefriendCompose } from 'flavours/glitch/actions/compose';
// Components.
import Composer from 'flavours/glitch/features/composer';
import DrawerAccount from './account';
import DrawerHeader from './header';
import DrawerResults from './results';
import DrawerSearch from './search';
// Utils.
import { me, mascot } from 'flavours/glitch/util/initial_state';
import { wrap } from 'flavours/glitch/util/redux_helpers';
// Messages.
const messages = defineMessages({
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' },
});
// State mapping.
const mapStateToProps = state => ({
account: state.getIn(['accounts', me]),
columns: state.getIn(['settings', 'columns']),
elefriend: state.getIn(['compose', 'elefriend']),
results: state.getIn(['search', 'results']),
searchHidden: state.getIn(['search', 'hidden']),
searchValue: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted']),
unreadNotifications: state.getIn(['notifications', 'unread']),
showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']),
});
// Dispatch mapping.
const mapDispatchToProps = (dispatch, { intl }) => ({
onChange (value) {
dispatch(changeSearch(value));
},
onClear () {
dispatch(clearSearch());
},
onClickElefriend () {
dispatch(cycleElefriendCompose());
},
onShow () {
dispatch(showSearch());
},
onSubmit () {
dispatch(submitSearch());
},
onOpenSettings (e) {
e.preventDefault();
e.stopPropagation();
dispatch(openModal('SETTINGS', {}));
},
});
// The component.
class Drawer extends React.Component {
// Constructor.
constructor (props) {
super(props);
}
// Rendering.
render () {
const {
account,
columns,
elefriend,
intl,
multiColumn,
onChange,
onClear,
onClickElefriend,
onOpenSettings,
onShow,
onSubmit,
results,
searchHidden,
searchValue,
submitted,
isSearchPage,
unreadNotifications,
showNotificationsBadge,
} = this.props;
const computedClass = classNames('drawer', `mbstobon-${elefriend}`);
// The result.
return (
<div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}>
{multiColumn ? (
<DrawerHeader
columns={columns}
unreadNotifications={unreadNotifications}
showNotificationsBadge={showNotificationsBadge}
intl={intl}
onSettingsClick={onOpenSettings}
/>
) : null}
{(multiColumn || isSearchPage) && <DrawerSearch
intl={intl}
onChange={onChange}
onClear={onClear}
onShow={onShow}
onSubmit={onSubmit}
submitted={submitted}
value={searchValue}
/> }
<div className='drawer__pager'>
{!isSearchPage && <div className='drawer__inner'>
<DrawerAccount account={account} />
<Composer />
{multiColumn && (
<div className='drawer__inner__mastodon'>
{mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
</div>
)}
</div>}
{(multiColumn || isSearchPage) &&
<DrawerResults
results={results}
visible={submitted && !searchHidden}
/>}
</div>
</div>
);
}
}
// Props.
Drawer.propTypes = {
intl: PropTypes.object.isRequired,
isSearchPage: PropTypes.bool,
multiColumn: PropTypes.bool,
// State props.
account: ImmutablePropTypes.map,
columns: ImmutablePropTypes.list,
results: ImmutablePropTypes.map,
elefriend: PropTypes.number,
searchHidden: PropTypes.bool,
searchValue: PropTypes.string,
submitted: PropTypes.bool,
unreadNotifications: PropTypes.number,
showNotificationsBadge: PropTypes.bool,
// Dispatch props.
onChange: PropTypes.func,
onClear: PropTypes.func,
onClickElefriend: PropTypes.func,
onShow: PropTypes.func,
onSubmit: PropTypes.func,
onOpenSettings: PropTypes.func,
};
// Connecting and export.
export { Drawer as WrappedComponent };
export default wrap(Drawer, mapStateToProps, mapDispatchToProps, true);

View File

@ -1,117 +0,0 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import {
FormattedMessage,
defineMessages,
} from 'react-intl';
import spring from 'react-motion/lib/spring';
import { Link } from 'react-router-dom';
// Components.
import Icon from 'flavours/glitch/components/icon';
import AccountContainer from 'flavours/glitch/containers/account_container';
import StatusContainer from 'flavours/glitch/containers/status_container';
import Hashtag from 'flavours/glitch/components/hashtag';
// Utils.
import Motion from 'flavours/glitch/util/optional_motion';
// Messages.
const messages = defineMessages({
total: {
defaultMessage: '{count, number} {count, plural, one {result} other {results}}',
id: 'search_results.total',
},
});
// The component.
export default function DrawerResults ({
results,
visible,
}) {
const accounts = results ? results.get('accounts') : null;
const statuses = results ? results.get('statuses') : null;
const hashtags = results ? results.get('hashtags') : null;
// This gets the total number of items.
const count = [accounts, statuses, hashtags].reduce(function (size, item) {
if (item && item.size) {
return size + item.size;
}
return size;
}, 0);
// The result.
return (
<Motion
defaultStyle={{ x: -100 }}
style={{
x: spring(visible ? 0 : -100, {
stiffness: 210,
damping: 20,
}),
}}
>
{({ x }) => (
<div
className='drawer--results'
style={{
transform: `translateX(${x}%)`,
visibility: x === -100 ? 'hidden' : 'visible',
}}
>
<header>
<Icon icon='search' fixedWidth />
<FormattedMessage
{...messages.total}
values={{ count }}
/>
</header>
{accounts && accounts.size ? (
<section>
<h5><Icon icon='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
{accounts.map(
accountId => (
<AccountContainer
id={accountId}
key={accountId}
/>
)
)}
</section>
) : null}
{statuses && statuses.size ? (
<section>
<h5><Icon icon='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
{statuses.map(
statusId => (
<StatusContainer
id={statusId}
key={statusId}
/>
)
)}
</section>
) : null}
{hashtags && hashtags.size ? (
<section>
<h5><Icon icon='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
{hashtags.map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
</section>
) : null}
</div>
)}
</Motion>
);
}
// Props.
DrawerResults.propTypes = {
results: ImmutablePropTypes.map,
visible: PropTypes.bool,
};

View File

@ -1,152 +0,0 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import {
FormattedMessage,
defineMessages,
} from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay';
// Components.
import Icon from 'flavours/glitch/components/icon';
import DrawerSearchPopout from './popout';
// Utils.
import { focusRoot } from 'flavours/glitch/util/dom_helpers';
import {
assignHandlers,
hiddenComponent,
} from 'flavours/glitch/util/react_helpers';
// Messages.
const messages = defineMessages({
placeholder: {
defaultMessage: 'Search',
id: 'search.placeholder',
},
});
// Handlers.
const handlers = {
handleBlur () {
this.setState({ expanded: false });
},
handleChange ({ target: { value } }) {
const { onChange } = this.props;
if (onChange) {
onChange(value);
}
},
handleClear (e) {
const {
onClear,
submitted,
value,
} = this.props;
e.preventDefault(); // Prevents focus change ??
if (onClear && (submitted || value && value.length)) {
onClear();
}
},
handleFocus () {
const { onShow } = this.props;
this.setState({ expanded: true });
if (onShow) {
onShow();
}
},
handleKeyUp (e) {
const { onSubmit } = this.props;
switch (e.key) {
case 'Enter':
if (onSubmit) {
onSubmit();
}
break;
case 'Escape':
focusRoot();
}
},
};
// The component.
export default class DrawerSearch extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
this.state = { expanded: false };
}
// Rendering.
render () {
const {
handleBlur,
handleChange,
handleClear,
handleFocus,
handleKeyUp,
} = this.handlers;
const {
intl,
submitted,
value,
} = this.props;
const { expanded } = this.state;
const active = value && value.length || submitted;
const computedClass = classNames('drawer--search', { active });
return (
<div className={computedClass}>
<label>
<span {...hiddenComponent}>
<FormattedMessage {...messages.placeholder} />
</span>
<input
type='text'
placeholder={intl.formatMessage(messages.placeholder)}
value={value || ''}
onChange={handleChange}
onKeyUp={handleKeyUp}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</label>
<div
aria-label={intl.formatMessage(messages.placeholder)}
className='icon'
onClick={handleClear}
role='button'
tabIndex='0'
>
<Icon icon='search' />
<Icon icon='times-circle' />
</div>
<Overlay
placement='bottom'
show={expanded && !active}
target={this}
><DrawerSearchPopout /></Overlay>
</div>
);
}
}
// Props.
DrawerSearch.propTypes = {
value: PropTypes.string,
submitted: PropTypes.bool,
onChange: PropTypes.func,
onSubmit: PropTypes.func,
onClear: PropTypes.func,
onShow: PropTypes.func,
intl: PropTypes.object,
};

View File

@ -1,109 +0,0 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import {
FormattedMessage,
defineMessages,
} from 'react-intl';
import spring from 'react-motion/lib/spring';
// Utils.
import Motion from 'flavours/glitch/util/optional_motion';
import { searchEnabled } from 'flavours/glitch/util/initial_state';
// Messages.
const messages = defineMessages({
format: {
defaultMessage: 'Advanced search format',
id: 'search_popout.search_format',
},
hashtag: {
defaultMessage: 'hashtag',
id: 'search_popout.tips.hashtag',
},
status: {
defaultMessage: 'status',
id: 'search_popout.tips.status',
},
text: {
defaultMessage: 'Simple text returns matching display names, usernames and hashtags',
id: 'search_popout.tips.text',
},
full_text: {
defaultMessage: 'Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.',
id: 'search_popout.tips.full_text',
},
user: {
defaultMessage: 'user',
id: 'search_popout.tips.user',
},
});
// The spring used by our motion.
const motionSpring = spring(1, { damping: 35, stiffness: 400 });
// The component.
export default function DrawerSearchPopout ({ style }) {
// The result.
return (
<div
className='drawer--search--popout'
style={{
...style,
position: 'absolute',
width: 285,
}}
>
<Motion
defaultStyle={{
opacity: 0,
scaleX: 0.85,
scaleY: 0.75,
}}
style={{
opacity: motionSpring,
scaleX: motionSpring,
scaleY: motionSpring,
}}
>
{({ opacity, scaleX, scaleY }) => (
<div
style={{
opacity: opacity,
transform: `scale(${scaleX}, ${scaleY})`,
}}
>
<h4><FormattedMessage {...messages.format} /></h4>
<ul>
<li>
<em>#example</em>
{' '}
<FormattedMessage {...messages.hashtag} />
</li>
<li>
<em>@username@domain</em>
{' '}
<FormattedMessage {...messages.user} />
</li>
<li>
<em>URL</em>
{' '}
<FormattedMessage {...messages.user} />
</li>
<li>
<em>URL</em>
{' '}
<FormattedMessage {...messages.status} />
</li>
</ul>
{ searchEnabled ? <FormattedMessage {...messages.full_text} /> : <FormattedMessage {...messages.text} /> }
</div>
)}
</Motion>
</div>
);
}
// Props.
DrawerSearchPopout.propTypes = { style: PropTypes.object };

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