Compare commits
31 Commits
2373a6101c
...
deb56f0c8a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deb56f0c8a | ||
|
|
fff7926498 | ||
|
|
aeb49bcba0 | ||
|
|
04ec9163d1 | ||
|
|
d44ca8e5c2 | ||
|
|
fb4a6f3382 | ||
|
|
24e6f14ac8 | ||
|
|
8f958d6ac7 | ||
|
|
3c00c65a19 | ||
|
|
1a722f5dd0 | ||
|
|
73eee37115 | ||
|
|
93add68b0d | ||
|
|
e87a7669df | ||
|
|
9cbd80d29b | ||
|
|
1b65423543 | ||
|
|
b207eecefa | ||
|
|
c754efef32 | ||
|
|
160fca7661 | ||
|
|
523708fdad | ||
|
|
c10078f719 | ||
|
|
ce4a9873db | ||
|
|
ed60d9ce51 | ||
|
|
4396ad28ef | ||
|
|
24cfaab748 | ||
|
|
ac128c0a50 | ||
|
|
553d862ae4 | ||
|
|
3d910d7cf0 | ||
|
|
be9c9396b0 | ||
|
|
bcb66beb6a | ||
|
|
3a252178ca | ||
|
|
53c5096f66 |
@ -70,7 +70,7 @@ services:
|
|||||||
hard: -1
|
hard: -1
|
||||||
|
|
||||||
libretranslate:
|
libretranslate:
|
||||||
image: libretranslate/libretranslate:v1.5.5
|
image: libretranslate/libretranslate:v1.5.4
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- lt-data:/home/libretranslate/.local
|
- lt-data:/home/libretranslate/.local
|
||||||
|
|||||||
@ -165,7 +165,7 @@ module.exports = defineConfig({
|
|||||||
// },
|
// },
|
||||||
// ],
|
// ],
|
||||||
'jsx-a11y/no-noninteractive-tabindex': 'off',
|
'jsx-a11y/no-noninteractive-tabindex': 'off',
|
||||||
'jsx-a11y/no-onchange': 'off',
|
'jsx-a11y/no-onchange': 'warn',
|
||||||
// recommended is full 'error'
|
// recommended is full 'error'
|
||||||
'jsx-a11y/no-static-element-interactions': [
|
'jsx-a11y/no-static-element-interactions': [
|
||||||
'warn',
|
'warn',
|
||||||
|
|||||||
2
.github/actions/setup-javascript/action.yml
vendored
2
.github/actions/setup-javascript/action.yml
vendored
@ -23,7 +23,7 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
|
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v3
|
||||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||||
|
|||||||
2
.github/workflows/crowdin-download.yml
vendored
2
.github/workflows/crowdin-download.yml
vendored
@ -53,7 +53,7 @@ jobs:
|
|||||||
|
|
||||||
# Create or update the pull request
|
# Create or update the pull request
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@v6.0.0
|
uses: peter-evans/create-pull-request@v5.0.2
|
||||||
with:
|
with:
|
||||||
commit-message: 'New Crowdin translations'
|
commit-message: 'New Crowdin translations'
|
||||||
title: 'New Crowdin Translations (automated)'
|
title: 'New Crowdin Translations (automated)'
|
||||||
|
|||||||
2
.github/workflows/lint-haml.yml
vendored
2
.github/workflows/lint-haml.yml
vendored
@ -36,4 +36,4 @@ jobs:
|
|||||||
- name: Run haml-lint
|
- name: Run haml-lint
|
||||||
run: |
|
run: |
|
||||||
echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json"
|
echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json"
|
||||||
bundle exec haml-lint --reporter github
|
bundle exec haml-lint
|
||||||
|
|||||||
6
.github/workflows/test-ruby.yml
vendored
6
.github/workflows/test-ruby.yml
vendored
@ -139,7 +139,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
if: matrix.ruby-version == '.ruby-version'
|
if: matrix.ruby-version == '.ruby-version'
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v3
|
||||||
with:
|
with:
|
||||||
files: coverage/lcov/mastodon.lcov
|
files: coverage/lcov/mastodon.lcov
|
||||||
|
|
||||||
@ -224,7 +224,7 @@ jobs:
|
|||||||
if: failure()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
name: e2e-screenshots
|
name: e2e-screenshots
|
||||||
path: tmp/capybara/
|
path: tmp/screenshots/
|
||||||
|
|
||||||
test-search:
|
test-search:
|
||||||
name: Elastic Search integration testing
|
name: Elastic Search integration testing
|
||||||
@ -328,4 +328,4 @@ jobs:
|
|||||||
if: failure()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
name: test-search-screenshots
|
name: test-search-screenshots
|
||||||
path: tmp/capybara/
|
path: tmp/screenshots/
|
||||||
|
|||||||
@ -1 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
yarn lint-staged
|
yarn lint-staged
|
||||||
|
|||||||
26
.rubocop.yml
26
.rubocop.yml
@ -96,6 +96,13 @@ Rails/FilePath:
|
|||||||
Rails/HttpStatus:
|
Rails/HttpStatus:
|
||||||
EnforcedStyle: numeric
|
EnforcedStyle: numeric
|
||||||
|
|
||||||
|
# Reason: Allowed in `tootctl` CLI code and in boot ENV checker
|
||||||
|
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsexit
|
||||||
|
Rails/Exit:
|
||||||
|
Exclude:
|
||||||
|
- 'config/boot.rb'
|
||||||
|
- 'lib/mastodon/cli/*.rb'
|
||||||
|
|
||||||
# Reason: Conflicts with `Lint/UselessMethodDefinition` for inherited controller actions
|
# Reason: Conflicts with `Lint/UselessMethodDefinition` for inherited controller actions
|
||||||
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railslexicallyscopedactionfilter
|
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railslexicallyscopedactionfilter
|
||||||
Rails/LexicallyScopedActionFilter:
|
Rails/LexicallyScopedActionFilter:
|
||||||
@ -128,11 +135,6 @@ Rails/UnusedIgnoredColumns:
|
|||||||
Rails/NegateInclude:
|
Rails/NegateInclude:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
# Reason: Enforce default limit, but allow some elements to span lines
|
|
||||||
# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecexamplelength
|
|
||||||
RSpec/ExampleLength:
|
|
||||||
CountAsOne: ['array', 'heredoc', 'method_call']
|
|
||||||
|
|
||||||
# Reason: Deprecated cop, will be removed in 3.0, replaced by SpecFilePathFormat
|
# Reason: Deprecated cop, will be removed in 3.0, replaced by SpecFilePathFormat
|
||||||
# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecfilepath
|
# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecfilepath
|
||||||
RSpec/FilePath:
|
RSpec/FilePath:
|
||||||
@ -173,15 +175,6 @@ Style/ClassAndModuleChildren:
|
|||||||
Style/Documentation:
|
Style/Documentation:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
# Reason: Route redirects are not token-formatted and must be skipped
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#styleformatstringtoken
|
|
||||||
Style/FormatStringToken:
|
|
||||||
inherit_mode:
|
|
||||||
merge:
|
|
||||||
- AllowedMethods # The rubocop-rails config adds `redirect`
|
|
||||||
AllowedMethods:
|
|
||||||
- redirect_with_vary
|
|
||||||
|
|
||||||
# Reason: Enforce modern Ruby style
|
# Reason: Enforce modern Ruby style
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#stylehashsyntax
|
# https://docs.rubocop.org/rubocop/cops_style.html#stylehashsyntax
|
||||||
Style/HashSyntax:
|
Style/HashSyntax:
|
||||||
@ -210,6 +203,11 @@ Style/RedundantBegin:
|
|||||||
Style/RescueStandardError:
|
Style/RescueStandardError:
|
||||||
EnforcedStyle: implicit
|
EnforcedStyle: implicit
|
||||||
|
|
||||||
|
# Reason: Simplify some spec layouts
|
||||||
|
# https://docs.rubocop.org/rubocop/cops_style.html#stylesemicolon
|
||||||
|
Style/Semicolon:
|
||||||
|
AllowAsExpressionSeparator: true
|
||||||
|
|
||||||
# Reason: Originally disabled for CodeClimate, and no config consensus has been found
|
# Reason: Originally disabled for CodeClimate, and no config consensus has been found
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#stylesymbolarray
|
# https://docs.rubocop.org/rubocop/cops_style.html#stylesymbolarray
|
||||||
Style/SymbolArray:
|
Style/SymbolArray:
|
||||||
|
|||||||
@ -36,10 +36,10 @@ Metrics/PerceivedComplexity:
|
|||||||
|
|
||||||
# Configuration parameters: CountAsOne.
|
# Configuration parameters: CountAsOne.
|
||||||
RSpec/ExampleLength:
|
RSpec/ExampleLength:
|
||||||
Max: 20 # Override default of 5
|
Max: 22
|
||||||
|
|
||||||
RSpec/MultipleExpectations:
|
RSpec/MultipleExpectations:
|
||||||
Max: 7
|
Max: 8
|
||||||
|
|
||||||
# Configuration parameters: AllowSubject.
|
# Configuration parameters: AllowSubject.
|
||||||
RSpec/MultipleMemoizedHelpers:
|
RSpec/MultipleMemoizedHelpers:
|
||||||
|
|||||||
12
Gemfile
12
Gemfile
@ -125,6 +125,12 @@ group :test do
|
|||||||
# Used to mock environment variables
|
# Used to mock environment variables
|
||||||
gem 'climate_control'
|
gem 'climate_control'
|
||||||
|
|
||||||
|
# Generating fake data for specs
|
||||||
|
gem 'faker', '~> 3.2'
|
||||||
|
|
||||||
|
# Generate test objects for specs
|
||||||
|
gem 'fabrication', '~> 2.30'
|
||||||
|
|
||||||
# Add back helpers functions removed in Rails 5.1
|
# Add back helpers functions removed in Rails 5.1
|
||||||
gem 'rails-controller-testing', '~> 1.0'
|
gem 'rails-controller-testing', '~> 1.0'
|
||||||
|
|
||||||
@ -176,12 +182,6 @@ group :development, :test do
|
|||||||
# Interactive Debugging tools
|
# Interactive Debugging tools
|
||||||
gem 'debug', '~> 1.8'
|
gem 'debug', '~> 1.8'
|
||||||
|
|
||||||
# Generate fake data values
|
|
||||||
gem 'faker', '~> 3.2'
|
|
||||||
|
|
||||||
# Generate factory objects
|
|
||||||
gem 'fabrication', '~> 2.30'
|
|
||||||
|
|
||||||
# Profiling tools
|
# Profiling tools
|
||||||
gem 'memory_profiler', require: false
|
gem 'memory_profiler', require: false
|
||||||
gem 'ruby-prof', require: false
|
gem 'ruby-prof', require: false
|
||||||
|
|||||||
124
Gemfile.lock
124
Gemfile.lock
@ -10,35 +10,35 @@ GIT
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (7.1.3.2)
|
actioncable (7.1.3)
|
||||||
actionpack (= 7.1.3.2)
|
actionpack (= 7.1.3)
|
||||||
activesupport (= 7.1.3.2)
|
activesupport (= 7.1.3)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (7.1.3.2)
|
actionmailbox (7.1.3)
|
||||||
actionpack (= 7.1.3.2)
|
actionpack (= 7.1.3)
|
||||||
activejob (= 7.1.3.2)
|
activejob (= 7.1.3)
|
||||||
activerecord (= 7.1.3.2)
|
activerecord (= 7.1.3)
|
||||||
activestorage (= 7.1.3.2)
|
activestorage (= 7.1.3)
|
||||||
activesupport (= 7.1.3.2)
|
activesupport (= 7.1.3)
|
||||||
mail (>= 2.7.1)
|
mail (>= 2.7.1)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
actionmailer (7.1.3.2)
|
actionmailer (7.1.3)
|
||||||
actionpack (= 7.1.3.2)
|
actionpack (= 7.1.3)
|
||||||
actionview (= 7.1.3.2)
|
actionview (= 7.1.3)
|
||||||
activejob (= 7.1.3.2)
|
activejob (= 7.1.3)
|
||||||
activesupport (= 7.1.3.2)
|
activesupport (= 7.1.3)
|
||||||
mail (~> 2.5, >= 2.5.4)
|
mail (~> 2.5, >= 2.5.4)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (7.1.3.2)
|
actionpack (7.1.3)
|
||||||
actionview (= 7.1.3.2)
|
actionview (= 7.1.3)
|
||||||
activesupport (= 7.1.3.2)
|
activesupport (= 7.1.3)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
racc
|
racc
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
@ -46,15 +46,15 @@ GEM
|
|||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
actiontext (7.1.3.2)
|
actiontext (7.1.3)
|
||||||
actionpack (= 7.1.3.2)
|
actionpack (= 7.1.3)
|
||||||
activerecord (= 7.1.3.2)
|
activerecord (= 7.1.3)
|
||||||
activestorage (= 7.1.3.2)
|
activestorage (= 7.1.3)
|
||||||
activesupport (= 7.1.3.2)
|
activesupport (= 7.1.3)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (7.1.3.2)
|
actionview (7.1.3)
|
||||||
activesupport (= 7.1.3.2)
|
activesupport (= 7.1.3)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
@ -64,22 +64,22 @@ GEM
|
|||||||
activemodel (>= 4.1)
|
activemodel (>= 4.1)
|
||||||
case_transform (>= 0.2)
|
case_transform (>= 0.2)
|
||||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||||
activejob (7.1.3.2)
|
activejob (7.1.3)
|
||||||
activesupport (= 7.1.3.2)
|
activesupport (= 7.1.3)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (7.1.3.2)
|
activemodel (7.1.3)
|
||||||
activesupport (= 7.1.3.2)
|
activesupport (= 7.1.3)
|
||||||
activerecord (7.1.3.2)
|
activerecord (7.1.3)
|
||||||
activemodel (= 7.1.3.2)
|
activemodel (= 7.1.3)
|
||||||
activesupport (= 7.1.3.2)
|
activesupport (= 7.1.3)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activestorage (7.1.3.2)
|
activestorage (7.1.3)
|
||||||
actionpack (= 7.1.3.2)
|
actionpack (= 7.1.3)
|
||||||
activejob (= 7.1.3.2)
|
activejob (= 7.1.3)
|
||||||
activerecord (= 7.1.3.2)
|
activerecord (= 7.1.3)
|
||||||
activesupport (= 7.1.3.2)
|
activesupport (= 7.1.3)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (7.1.3.2)
|
activesupport (7.1.3)
|
||||||
base64
|
base64
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
@ -219,7 +219,7 @@ GEM
|
|||||||
docile (1.4.0)
|
docile (1.4.0)
|
||||||
domain_name (0.5.20190701)
|
domain_name (0.5.20190701)
|
||||||
unf (>= 0.0.5, < 1.0.0)
|
unf (>= 0.0.5, < 1.0.0)
|
||||||
doorkeeper (5.6.9)
|
doorkeeper (5.6.8)
|
||||||
railties (>= 5)
|
railties (>= 5)
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
dotenv-rails (2.8.1)
|
dotenv-rails (2.8.1)
|
||||||
@ -309,7 +309,7 @@ GEM
|
|||||||
activesupport (>= 5.1)
|
activesupport (>= 5.1)
|
||||||
haml (>= 4.0.6)
|
haml (>= 4.0.6)
|
||||||
railties (>= 5.1)
|
railties (>= 5.1)
|
||||||
haml_lint (0.57.0)
|
haml_lint (0.56.0)
|
||||||
haml (>= 5.0)
|
haml (>= 5.0)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
rainbow
|
rainbow
|
||||||
@ -333,7 +333,7 @@ GEM
|
|||||||
http-form_data (2.3.0)
|
http-form_data (2.3.0)
|
||||||
http_accept_language (2.1.1)
|
http_accept_language (2.1.1)
|
||||||
httpclient (2.8.3)
|
httpclient (2.8.3)
|
||||||
httplog (1.6.3)
|
httplog (1.6.2)
|
||||||
rack (>= 2.0)
|
rack (>= 2.0)
|
||||||
rainbow (>= 2.0.0)
|
rainbow (>= 2.0.0)
|
||||||
i18n (1.14.1)
|
i18n (1.14.1)
|
||||||
@ -444,7 +444,7 @@ GEM
|
|||||||
uri
|
uri
|
||||||
net-http-persistent (4.0.2)
|
net-http-persistent (4.0.2)
|
||||||
connection_pool (~> 2.2)
|
connection_pool (~> 2.2)
|
||||||
net-imap (0.4.10)
|
net-imap (0.4.9.1)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ldap (0.19.0)
|
net-ldap (0.19.0)
|
||||||
@ -532,7 +532,7 @@ GEM
|
|||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.7.3)
|
racc (1.7.3)
|
||||||
rack (2.2.8.1)
|
rack (2.2.8)
|
||||||
rack-attack (6.7.0)
|
rack-attack (6.7.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-cors (2.0.1)
|
rack-cors (2.0.1)
|
||||||
@ -554,20 +554,20 @@ GEM
|
|||||||
rackup (1.0.0)
|
rackup (1.0.0)
|
||||||
rack (< 3)
|
rack (< 3)
|
||||||
webrick
|
webrick
|
||||||
rails (7.1.3.2)
|
rails (7.1.3)
|
||||||
actioncable (= 7.1.3.2)
|
actioncable (= 7.1.3)
|
||||||
actionmailbox (= 7.1.3.2)
|
actionmailbox (= 7.1.3)
|
||||||
actionmailer (= 7.1.3.2)
|
actionmailer (= 7.1.3)
|
||||||
actionpack (= 7.1.3.2)
|
actionpack (= 7.1.3)
|
||||||
actiontext (= 7.1.3.2)
|
actiontext (= 7.1.3)
|
||||||
actionview (= 7.1.3.2)
|
actionview (= 7.1.3)
|
||||||
activejob (= 7.1.3.2)
|
activejob (= 7.1.3)
|
||||||
activemodel (= 7.1.3.2)
|
activemodel (= 7.1.3)
|
||||||
activerecord (= 7.1.3.2)
|
activerecord (= 7.1.3)
|
||||||
activestorage (= 7.1.3.2)
|
activestorage (= 7.1.3)
|
||||||
activesupport (= 7.1.3.2)
|
activesupport (= 7.1.3)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 7.1.3.2)
|
railties (= 7.1.3)
|
||||||
rails-controller-testing (1.0.5)
|
rails-controller-testing (1.0.5)
|
||||||
actionpack (>= 5.0.1.rc1)
|
actionpack (>= 5.0.1.rc1)
|
||||||
actionview (>= 5.0.1.rc1)
|
actionview (>= 5.0.1.rc1)
|
||||||
@ -582,9 +582,9 @@ GEM
|
|||||||
rails-i18n (7.0.8)
|
rails-i18n (7.0.8)
|
||||||
i18n (>= 0.7, < 2)
|
i18n (>= 0.7, < 2)
|
||||||
railties (>= 6.0.0, < 8)
|
railties (>= 6.0.0, < 8)
|
||||||
railties (7.1.3.2)
|
railties (7.1.3)
|
||||||
actionpack (= 7.1.3.2)
|
actionpack (= 7.1.3)
|
||||||
activesupport (= 7.1.3.2)
|
activesupport (= 7.1.3)
|
||||||
irb
|
irb
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
@ -691,7 +691,7 @@ GEM
|
|||||||
scenic (1.7.0)
|
scenic (1.7.0)
|
||||||
activerecord (>= 4.0.0)
|
activerecord (>= 4.0.0)
|
||||||
railties (>= 4.0.0)
|
railties (>= 4.0.0)
|
||||||
selenium-webdriver (4.18.1)
|
selenium-webdriver (4.17.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 3.0)
|
rubyzip (>= 1.2.2, < 3.0)
|
||||||
@ -744,7 +744,7 @@ GEM
|
|||||||
terrapin (1.0.1)
|
terrapin (1.0.1)
|
||||||
climate_control
|
climate_control
|
||||||
test-prof (1.3.1)
|
test-prof (1.3.1)
|
||||||
thor (1.3.1)
|
thor (1.3.0)
|
||||||
tilt (2.3.0)
|
tilt (2.3.0)
|
||||||
timeout (0.4.1)
|
timeout (0.4.1)
|
||||||
tpm-key_attestation (0.12.0)
|
tpm-key_attestation (0.12.0)
|
||||||
@ -793,7 +793,7 @@ GEM
|
|||||||
webfinger (1.2.0)
|
webfinger (1.2.0)
|
||||||
activesupport
|
activesupport
|
||||||
httpclient (>= 2.4)
|
httpclient (>= 2.4)
|
||||||
webmock (3.22.0)
|
webmock (3.20.0)
|
||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
@ -811,7 +811,7 @@ GEM
|
|||||||
xorcist (1.1.3)
|
xorcist (1.1.3)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
zeitwerk (2.6.13)
|
zeitwerk (2.6.12)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
ruby
|
ruby
|
||||||
|
|||||||
@ -62,10 +62,11 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
|
|||||||
return if raw_params.blank? || ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] == 'true' || signed_request_account.nil?
|
return if raw_params.blank? || ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] == 'true' || signed_request_account.nil?
|
||||||
|
|
||||||
# Re-using the syntax for signature parameters
|
# Re-using the syntax for signature parameters
|
||||||
params = SignatureParser.parse(raw_params)
|
tree = SignatureParamsParser.new.parse(raw_params)
|
||||||
|
params = SignatureParamsTransformer.new.apply(tree)
|
||||||
|
|
||||||
ActivityPub::PrepareFollowersSynchronizationService.new.call(signed_request_account, params)
|
ActivityPub::PrepareFollowersSynchronizationService.new.call(signed_request_account, params)
|
||||||
rescue SignatureParser::ParsingError
|
rescue Parslet::ParseFailed
|
||||||
Rails.logger.warn 'Error parsing Collection-Synchronization header'
|
Rails.logger.warn 'Error parsing Collection-Synchronization header'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -35,7 +35,6 @@ class Api::V1::Admin::ReportsController < Api::BaseController
|
|||||||
def update
|
def update
|
||||||
authorize @report, :update?
|
authorize @report, :update?
|
||||||
@report.update!(report_params)
|
@report.update!(report_params)
|
||||||
log_action :update, @report
|
|
||||||
render json: @report, serializer: REST::Admin::ReportSerializer
|
render json: @report, serializer: REST::Admin::ReportSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -72,9 +72,13 @@ class Api::V1::StatusesController < Api::BaseController
|
|||||||
with_rate_limit: true
|
with_rate_limit: true
|
||||||
)
|
)
|
||||||
|
|
||||||
render json: @status, serializer: serializer_for_status
|
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
|
||||||
rescue PostStatusService::UnexpectedMentionsError => e
|
rescue PostStatusService::UnexpectedMentionsError => e
|
||||||
render json: unexpected_accounts_error_json(e), status: 422
|
unexpected_accounts = ActiveModel::Serializer::CollectionSerializer.new(
|
||||||
|
e.accounts,
|
||||||
|
serializer: REST::AccountSerializer
|
||||||
|
)
|
||||||
|
render json: { error: e.message, unexpected_accounts: unexpected_accounts }, status: 422
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@ -154,21 +158,6 @@ class Api::V1::StatusesController < Api::BaseController
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def serializer_for_status
|
|
||||||
@status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
|
|
||||||
end
|
|
||||||
|
|
||||||
def unexpected_accounts_error_json(error)
|
|
||||||
{
|
|
||||||
error: error.message,
|
|
||||||
unexpected_accounts: serialized_accounts(error.accounts),
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def serialized_accounts(accounts)
|
|
||||||
ActiveModel::Serializer::CollectionSerializer.new(accounts, serializer: REST::AccountSerializer)
|
|
||||||
end
|
|
||||||
|
|
||||||
def pagination_params(core_params)
|
def pagination_params(core_params)
|
||||||
params.slice(:limit).permit(:limit).merge(core_params)
|
params.slice(:limit).permit(:limit).merge(core_params)
|
||||||
end
|
end
|
||||||
|
|||||||
@ -180,7 +180,7 @@ class ApplicationController < ActionController::Base
|
|||||||
use_pack 'error'
|
use_pack 'error'
|
||||||
render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html]
|
render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html]
|
||||||
end
|
end
|
||||||
format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: 410 }
|
format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: code }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -188,9 +188,7 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Only send a notification email every hour at most
|
# Only send a notification email every hour at most
|
||||||
return if redis.get("2fa_failure_notification:#{user.id}").present?
|
return if redis.set("2fa_failure_notification:#{user.id}", '1', ex: 1.hour, get: true).present?
|
||||||
|
|
||||||
redis.set("2fa_failure_notification:#{user.id}", '1', ex: 1.hour)
|
|
||||||
|
|
||||||
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
|
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
|
||||||
end
|
end
|
||||||
|
|||||||
@ -12,6 +12,39 @@ module SignatureVerification
|
|||||||
|
|
||||||
class SignatureVerificationError < StandardError; end
|
class SignatureVerificationError < StandardError; end
|
||||||
|
|
||||||
|
class SignatureParamsParser < Parslet::Parser
|
||||||
|
rule(:token) { match("[0-9a-zA-Z!#$%&'*+.^_`|~-]").repeat(1).as(:token) }
|
||||||
|
rule(:quoted_string) { str('"') >> (qdtext | quoted_pair).repeat.as(:quoted_string) >> str('"') }
|
||||||
|
# qdtext and quoted_pair are not exactly according to spec but meh
|
||||||
|
rule(:qdtext) { match('[^\\\\"]') }
|
||||||
|
rule(:quoted_pair) { str('\\') >> any }
|
||||||
|
rule(:bws) { match('\s').repeat }
|
||||||
|
rule(:param) { (token.as(:key) >> bws >> str('=') >> bws >> (token | quoted_string).as(:value)).as(:param) }
|
||||||
|
rule(:comma) { bws >> str(',') >> bws }
|
||||||
|
# Old versions of node-http-signature add an incorrect "Signature " prefix to the header
|
||||||
|
rule(:buggy_prefix) { str('Signature ') }
|
||||||
|
rule(:params) { buggy_prefix.maybe >> (param >> (comma >> param).repeat).as(:params) }
|
||||||
|
root(:params)
|
||||||
|
end
|
||||||
|
|
||||||
|
class SignatureParamsTransformer < Parslet::Transform
|
||||||
|
rule(params: subtree(:param)) do
|
||||||
|
(param.is_a?(Array) ? param : [param]).each_with_object({}) { |(key, value), hash| hash[key] = value }
|
||||||
|
end
|
||||||
|
|
||||||
|
rule(param: { key: simple(:key), value: simple(:val) }) do
|
||||||
|
[key, val]
|
||||||
|
end
|
||||||
|
|
||||||
|
rule(quoted_string: simple(:string)) do
|
||||||
|
string.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
rule(token: simple(:string)) do
|
||||||
|
string.to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def require_account_signature!
|
def require_account_signature!
|
||||||
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
|
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
|
||||||
end
|
end
|
||||||
@ -102,8 +135,12 @@ module SignatureVerification
|
|||||||
end
|
end
|
||||||
|
|
||||||
def signature_params
|
def signature_params
|
||||||
@signature_params ||= SignatureParser.parse(request.headers['Signature'])
|
@signature_params ||= begin
|
||||||
rescue SignatureParser::ParsingError
|
raw_signature = request.headers['Signature']
|
||||||
|
tree = SignatureParamsParser.new.parse(raw_signature)
|
||||||
|
SignatureParamsTransformer.new.apply(tree)
|
||||||
|
end
|
||||||
|
rescue Parslet::ParseFailed
|
||||||
raise SignatureVerificationError, 'Error parsing signature parameters'
|
raise SignatureVerificationError, 'Error parsing signature parameters'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,6 @@ class CustomCssController < ActionController::Base # rubocop:disable Rails/Appli
|
|||||||
helper_method :custom_css_styles
|
helper_method :custom_css_styles
|
||||||
|
|
||||||
def set_user_roles
|
def set_user_roles
|
||||||
@user_roles = UserRole.providing_styles
|
@user_roles = UserRole.where(highlighted: true).where.not(color: [nil, ''])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,26 +1,27 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class IntentsController < ApplicationController
|
class IntentsController < ApplicationController
|
||||||
EXPECTED_SCHEME = 'web+mastodon'
|
before_action :check_uri
|
||||||
|
|
||||||
before_action :handle_invalid_uri, unless: :valid_uri?
|
|
||||||
rescue_from Addressable::URI::InvalidURIError, with: :handle_invalid_uri
|
rescue_from Addressable::URI::InvalidURIError, with: :handle_invalid_uri
|
||||||
|
|
||||||
def show
|
def show
|
||||||
case uri.host
|
if uri.scheme == 'web+mastodon'
|
||||||
when 'follow'
|
case uri.host
|
||||||
redirect_to authorize_interaction_path(uri: uri.query_values['uri'].delete_prefix('acct:'))
|
when 'follow'
|
||||||
when 'share'
|
return redirect_to authorize_interaction_path(uri: uri.query_values['uri'].delete_prefix('acct:'))
|
||||||
redirect_to share_path(text: uri.query_values['text'])
|
when 'share'
|
||||||
else
|
return redirect_to share_path(text: uri.query_values['text'])
|
||||||
handle_invalid_uri
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def valid_uri?
|
def check_uri
|
||||||
uri.present? && uri.scheme == EXPECTED_SCHEME
|
not_found if uri.blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_invalid_uri
|
def handle_invalid_uri
|
||||||
|
|||||||
@ -15,20 +15,9 @@ module ReactComponentHelper
|
|||||||
div_tag_with_data(data)
|
div_tag_with_data(data)
|
||||||
end
|
end
|
||||||
|
|
||||||
def serialized_media_attachments(media_attachments)
|
|
||||||
media_attachments.map { |attachment| serialized_attachment(attachment) }
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def div_tag_with_data(data)
|
def div_tag_with_data(data)
|
||||||
content_tag(:div, nil, data: data)
|
content_tag(:div, nil, data: data)
|
||||||
end
|
end
|
||||||
|
|
||||||
def serialized_attachment(attachment)
|
|
||||||
ActiveModelSerializers::SerializableResource.new(
|
|
||||||
attachment,
|
|
||||||
serializer: REST::MediaAttachmentSerializer
|
|
||||||
).as_json
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@ -93,6 +93,18 @@ Rails.delegate(document, batchCheckboxClassName, 'change', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Rails.delegate(document, '.media-spoiler-show-button', 'click', () => {
|
||||||
|
[].forEach.call(document.querySelectorAll('button.media-spoiler'), (element) => {
|
||||||
|
element.click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Rails.delegate(document, '.media-spoiler-hide-button', 'click', () => {
|
||||||
|
[].forEach.call(document.querySelectorAll('.spoiler-button.spoiler-button--visible button'), (element) => {
|
||||||
|
element.click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Rails.delegate(document, '.filter-subset--with-select select', 'change', ({ target }) => {
|
Rails.delegate(document, '.filter-subset--with-select select', 'change', ({ target }) => {
|
||||||
target.form.submit();
|
target.form.submit();
|
||||||
});
|
});
|
||||||
@ -132,10 +144,6 @@ Rails.delegate(document, '#form_admin_settings_enable_bootstrap_timeline_account
|
|||||||
const onChangeRegistrationMode = (target) => {
|
const onChangeRegistrationMode = (target) => {
|
||||||
const enabled = target.value === 'approved';
|
const enabled = target.value === 'approved';
|
||||||
|
|
||||||
[].forEach.call(document.querySelectorAll('.form_admin_settings_registrations_mode .warning-hint'), (warning_hint) => {
|
|
||||||
warning_hint.style.display = target.value === 'open' ? 'inline' : 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
[].forEach.call(document.querySelectorAll('#form_admin_settings_require_invite_text'), (input) => {
|
[].forEach.call(document.querySelectorAll('#form_admin_settings_require_invite_text'), (input) => {
|
||||||
input.disabled = !enabled;
|
input.disabled = !enabled;
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
|
|||||||
@ -21,6 +21,7 @@ let fetchComposeSuggestionsAccountsController;
|
|||||||
let fetchComposeSuggestionsTagsController;
|
let fetchComposeSuggestionsTagsController;
|
||||||
|
|
||||||
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
|
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
|
||||||
|
export const COMPOSE_CYCLE_ELEFRIEND = 'COMPOSE_CYCLE_ELEFRIEND';
|
||||||
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
|
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
|
||||||
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
|
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
|
||||||
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
|
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
|
||||||
@ -58,7 +59,7 @@ export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
|
|||||||
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
|
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
|
||||||
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
|
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
|
||||||
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
|
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
|
||||||
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
|
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
|
||||||
export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE';
|
export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE';
|
||||||
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
|
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
|
||||||
|
|
||||||
@ -116,6 +117,12 @@ export function changeCompose(text) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cycleElefriendCompose() {
|
||||||
|
return {
|
||||||
|
type: COMPOSE_CYCLE_ELEFRIEND,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function replyCompose(status, routerHistory) {
|
export function replyCompose(status, routerHistory) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const prependCWRe = getState().getIn(['local_settings', 'prepend_cw_re']);
|
const prependCWRe = getState().getIn(['local_settings', 'prepend_cw_re']);
|
||||||
@ -141,13 +148,13 @@ export function resetCompose() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const focusCompose = (routerHistory, defaultText) => (dispatch, getState) => {
|
export const focusCompose = (routerHistory, defaultText) => dispatch => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: COMPOSE_FOCUS,
|
type: COMPOSE_FOCUS,
|
||||||
defaultText,
|
defaultText,
|
||||||
});
|
});
|
||||||
|
|
||||||
ensureComposeIsVisible(getState, routerHistory);
|
ensureComposeIsVisible(routerHistory);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function mentionCompose(account, routerHistory) {
|
export function mentionCompose(account, routerHistory) {
|
||||||
@ -172,7 +179,7 @@ export function directCompose(account, routerHistory) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submitCompose(routerHistory, overridePrivacy = null) {
|
export function submitCompose(routerHistory) {
|
||||||
return function (dispatch, getState) {
|
return function (dispatch, getState) {
|
||||||
let status = getState().getIn(['compose', 'text'], '');
|
let status = getState().getIn(['compose', 'text'], '');
|
||||||
const media = getState().getIn(['compose', 'media_attachments']);
|
const media = getState().getIn(['compose', 'media_attachments']);
|
||||||
@ -221,7 +228,7 @@ export function submitCompose(routerHistory, overridePrivacy = null) {
|
|||||||
media_attributes,
|
media_attributes,
|
||||||
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
|
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
|
||||||
spoiler_text: spoilerText,
|
spoiler_text: spoilerText,
|
||||||
visibility: overridePrivacy || getState().getIn(['compose', 'privacy']),
|
visibility: getState().getIn(['compose', 'privacy']),
|
||||||
poll: getState().getIn(['compose', 'poll'], null),
|
poll: getState().getIn(['compose', 'poll'], null),
|
||||||
language: getState().getIn(['compose', 'language']),
|
language: getState().getIn(['compose', 'language']),
|
||||||
},
|
},
|
||||||
@ -239,6 +246,11 @@ export function submitCompose(routerHistory, overridePrivacy = null) {
|
|||||||
dispatch(insertIntoTagHistory(response.data.tags, status));
|
dispatch(insertIntoTagHistory(response.data.tags, status));
|
||||||
dispatch(submitComposeSuccess({ ...response.data }));
|
dispatch(submitComposeSuccess({ ...response.data }));
|
||||||
|
|
||||||
|
// If the response has no data then we can't do anything else.
|
||||||
|
if (!response.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// To make the app more responsive, immediately push the status
|
// To make the app more responsive, immediately push the status
|
||||||
// into the columns
|
// into the columns
|
||||||
const insertIfOnline = timelineId => {
|
const insertIfOnline = timelineId => {
|
||||||
@ -648,19 +660,15 @@ export const readyComposeSuggestionsTags = (token, tags) => ({
|
|||||||
|
|
||||||
export function selectComposeSuggestion(position, token, suggestion, path) {
|
export function selectComposeSuggestion(position, token, suggestion, path) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
let completion, startPosition;
|
let completion;
|
||||||
|
|
||||||
if (suggestion.type === 'emoji') {
|
if (suggestion.type === 'emoji') {
|
||||||
completion = suggestion.native || suggestion.colons;
|
completion = suggestion.native || suggestion.colons;
|
||||||
startPosition = position - 1;
|
|
||||||
|
|
||||||
dispatch(useEmoji(suggestion));
|
dispatch(useEmoji(suggestion));
|
||||||
} else if (suggestion.type === 'hashtag') {
|
} else if (suggestion.type === 'hashtag') {
|
||||||
completion = `#${suggestion.name}`;
|
completion = `#${suggestion.name}`;
|
||||||
startPosition = position - 1;
|
|
||||||
} else if (suggestion.type === 'account') {
|
} else if (suggestion.type === 'account') {
|
||||||
completion = getState().getIn(['accounts', suggestion.id, 'acct']);
|
completion = '@' + getState().getIn(['accounts', suggestion.id, 'acct']);
|
||||||
startPosition = position;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that
|
// We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that
|
||||||
@ -668,7 +676,7 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
|
|||||||
if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) {
|
if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: COMPOSE_SUGGESTION_SELECT,
|
type: COMPOSE_SUGGESTION_SELECT,
|
||||||
position: startPosition,
|
position,
|
||||||
token,
|
token,
|
||||||
completion,
|
completion,
|
||||||
path,
|
path,
|
||||||
@ -676,7 +684,7 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
|
|||||||
} else {
|
} else {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: COMPOSE_SUGGESTION_IGNORE,
|
type: COMPOSE_SUGGESTION_IGNORE,
|
||||||
position: startPosition,
|
position,
|
||||||
token,
|
token,
|
||||||
completion,
|
completion,
|
||||||
path,
|
path,
|
||||||
@ -778,22 +786,6 @@ export function changeComposeVisibility(value) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function insertEmojiCompose(position, emoji, needsSpace) {
|
|
||||||
return {
|
|
||||||
type: COMPOSE_EMOJI_INSERT,
|
|
||||||
position,
|
|
||||||
emoji,
|
|
||||||
needsSpace,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function changeComposing(value) {
|
|
||||||
return {
|
|
||||||
type: COMPOSE_COMPOSING_CHANGE,
|
|
||||||
value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function changeComposeContentType(value) {
|
export function changeComposeContentType(value) {
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_CONTENT_TYPE_CHANGE,
|
type: COMPOSE_CONTENT_TYPE_CHANGE,
|
||||||
@ -801,6 +793,14 @@ export function changeComposeContentType(value) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function insertEmojiCompose(position, emoji) {
|
||||||
|
return {
|
||||||
|
type: COMPOSE_EMOJI_INSERT,
|
||||||
|
position,
|
||||||
|
emoji,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function addPoll() {
|
export function addPoll() {
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_POLL_ADD,
|
type: COMPOSE_POLL_ADD,
|
||||||
|
|||||||
@ -54,5 +54,12 @@ export const dismissSuggestion = accountId => (dispatch, getState) => {
|
|||||||
id: accountId,
|
id: accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
api(getState).delete(`/api/v1/suggestions/${accountId}`).catch(() => {});
|
api(getState).delete(`/api/v1/suggestions/${accountId}`).then(() => {
|
||||||
|
dispatch(fetchSuggestionsRequest());
|
||||||
|
|
||||||
|
api(getState).get('/api/v2/suggestions').then(response => {
|
||||||
|
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
|
||||||
|
dispatch(fetchSuggestionsSuccess(response.data));
|
||||||
|
}).catch(error => dispatch(fetchSuggestionsFail(error)));
|
||||||
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -22,10 +22,6 @@ export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
|
|||||||
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
|
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
|
||||||
|
|
||||||
export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
|
export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
|
||||||
export const TIMELINE_INSERT = 'TIMELINE_INSERT';
|
|
||||||
|
|
||||||
export const TIMELINE_SUGGESTIONS = 'inline-follow-suggestions';
|
|
||||||
export const TIMELINE_GAP = null;
|
|
||||||
|
|
||||||
export const loadPending = timeline => ({
|
export const loadPending = timeline => ({
|
||||||
type: TIMELINE_LOAD_PENDING,
|
type: TIMELINE_LOAD_PENDING,
|
||||||
@ -127,19 +123,9 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
|||||||
|
|
||||||
api(getState).get(path, { params }).then(response => {
|
api(getState).get(path, { params }).then(response => {
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||||
|
|
||||||
dispatch(importFetchedStatuses(response.data));
|
dispatch(importFetchedStatuses(response.data));
|
||||||
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
|
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
|
||||||
|
|
||||||
if (timelineId === 'home' && !isLoadingMore && !isLoadingRecent) {
|
|
||||||
const now = new Date();
|
|
||||||
const fittingIndex = response.data.findIndex(status => now - (new Date(status.created_at)) > 4 * 3600 * 1000);
|
|
||||||
|
|
||||||
if (fittingIndex !== -1) {
|
|
||||||
dispatch(insertIntoTimeline(timelineId, TIMELINE_SUGGESTIONS, Math.max(1, fittingIndex)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timelineId === 'home') {
|
if (timelineId === 'home') {
|
||||||
dispatch(submitMarkers());
|
dispatch(submitMarkers());
|
||||||
}
|
}
|
||||||
@ -247,10 +233,3 @@ export const markAsPartial = timeline => ({
|
|||||||
type: TIMELINE_MARK_AS_PARTIAL,
|
type: TIMELINE_MARK_AS_PARTIAL,
|
||||||
timeline,
|
timeline,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const insertIntoTimeline = (timeline, key, index) => ({
|
|
||||||
type: TIMELINE_INSERT,
|
|
||||||
timeline,
|
|
||||||
index,
|
|
||||||
key,
|
|
||||||
});
|
|
||||||
|
|||||||
@ -43,5 +43,4 @@ export interface ApiAccountJSON {
|
|||||||
suspended?: boolean;
|
suspended?: boolean;
|
||||||
limited?: boolean;
|
limited?: boolean;
|
||||||
memorial?: boolean;
|
memorial?: boolean;
|
||||||
hide_collections: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,10 +37,10 @@ class Account extends ImmutablePureComponent {
|
|||||||
static propTypes = {
|
static propTypes = {
|
||||||
size: PropTypes.number,
|
size: PropTypes.number,
|
||||||
account: ImmutablePropTypes.record,
|
account: ImmutablePropTypes.record,
|
||||||
onFollow: PropTypes.func,
|
onFollow: PropTypes.func.isRequired,
|
||||||
onBlock: PropTypes.func,
|
onBlock: PropTypes.func.isRequired,
|
||||||
onMute: PropTypes.func,
|
onMute: PropTypes.func.isRequired,
|
||||||
onMuteNotifications: PropTypes.func,
|
onMuteNotifications: PropTypes.func.isRequired,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
hidden: PropTypes.bool,
|
hidden: PropTypes.bool,
|
||||||
minimal: PropTypes.bool,
|
minimal: PropTypes.bool,
|
||||||
@ -147,7 +147,7 @@ class Account extends ImmutablePureComponent {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='account__contents'>
|
<div className='account__contents'>
|
||||||
<DisplayName account={account} />
|
<DisplayName account={account} inline />
|
||||||
{!minimal && (
|
{!minimal && (
|
||||||
<div className='account__details'>
|
<div className='account__details'>
|
||||||
{account.get('followers_count') !== -1 && (
|
{account.get('followers_count') !== -1 && (
|
||||||
|
|||||||
@ -124,7 +124,7 @@ class ReportReasonSelector extends PureComponent {
|
|||||||
|
|
||||||
api().put(`/api/v1/admin/reports/${id}`, {
|
api().put(`/api/v1/admin/reports/${id}`, {
|
||||||
category,
|
category,
|
||||||
rule_ids: category === 'violation' ? rule_ids : [],
|
rule_ids,
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export default class AutosuggestEmoji extends PureComponent {
|
|||||||
alt={emoji.native || emoji.colons}
|
alt={emoji.native || emoji.colons}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className='autosuggest-emoji__name'>{emoji.colons}</div>
|
{emoji.colons}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -14,18 +16,27 @@ interface Props {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => (
|
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => {
|
||||||
<div className='autosuggest-hashtag'>
|
const weeklyUses = tag.history && (
|
||||||
<div className='autosuggest-hashtag__name'>
|
<ShortNumber
|
||||||
#<strong>{tag.name}</strong>
|
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
|
||||||
</div>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
{tag.history !== undefined && (
|
return (
|
||||||
<div className='autosuggest-hashtag__uses'>
|
<div className='autosuggest-hashtag'>
|
||||||
<ShortNumber
|
<div className='autosuggest-hashtag__name'>
|
||||||
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
|
#<strong>{tag.name}</strong>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{tag.history !== undefined && (
|
||||||
</div>
|
<div className='autosuggest-hashtag__uses'>
|
||||||
);
|
<FormattedMessage
|
||||||
|
id='autosuggest_hashtag.per_week'
|
||||||
|
defaultMessage='{count} per week'
|
||||||
|
values={{ count: weeklyUses }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -5,8 +5,6 @@ import classNames from 'classnames';
|
|||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
import Overlay from 'react-overlays/Overlay';
|
|
||||||
|
|
||||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||||
|
|
||||||
import AutosuggestEmoji from './autosuggest_emoji';
|
import AutosuggestEmoji from './autosuggest_emoji';
|
||||||
@ -15,8 +13,8 @@ import { AutosuggestHashtag } from './autosuggest_hashtag';
|
|||||||
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
||||||
let word;
|
let word;
|
||||||
|
|
||||||
let left = str.slice(0, caretPosition).search(/\S+$/);
|
let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
|
||||||
let right = str.slice(caretPosition).search(/\s/);
|
let right = str.slice(caretPosition).search(/[\s\u200B]/);
|
||||||
|
|
||||||
if (right < 0) {
|
if (right < 0) {
|
||||||
word = str.slice(left);
|
word = str.slice(left);
|
||||||
@ -31,7 +29,7 @@ const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
|||||||
word = word.trim().toLowerCase();
|
word = word.trim().toLowerCase();
|
||||||
|
|
||||||
if (word.length > 0) {
|
if (word.length > 0) {
|
||||||
return [left + 1, word];
|
return [left, word];
|
||||||
} else {
|
} else {
|
||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
}
|
||||||
@ -197,37 +195,34 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='autosuggest-input'>
|
<div className='autosuggest-input'>
|
||||||
<input
|
<label>
|
||||||
type='text'
|
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||||
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}
|
|
||||||
dir='auto'
|
|
||||||
aria-autocomplete='list'
|
|
||||||
aria-label={placeholder}
|
|
||||||
id={id}
|
|
||||||
className={className}
|
|
||||||
maxLength={maxLength}
|
|
||||||
lang={lang}
|
|
||||||
spellCheck={spellCheck}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={this.input} popperConfig={{ strategy: 'fixed' }}>
|
<input
|
||||||
{({ props }) => (
|
type='text'
|
||||||
<div {...props}>
|
ref={this.setInput}
|
||||||
<div className='autosuggest-textarea__suggestions' style={{ width: this.input?.clientWidth }}>
|
disabled={disabled}
|
||||||
{suggestions.map(this.renderSuggestion)}
|
placeholder={placeholder}
|
||||||
</div>
|
autoFocus={autoFocus}
|
||||||
</div>
|
value={value}
|
||||||
)}
|
onChange={this.onChange}
|
||||||
</Overlay>
|
onKeyDown={this.onKeyDown}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
onFocus={this.onFocus}
|
||||||
|
onBlur={this.onBlur}
|
||||||
|
dir='auto'
|
||||||
|
aria-autocomplete='list'
|
||||||
|
id={id}
|
||||||
|
className={className}
|
||||||
|
maxLength={maxLength}
|
||||||
|
lang={lang}
|
||||||
|
spellCheck={spellCheck}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
|
||||||
|
{suggestions.map(this.renderSuggestion)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import classNames from 'classnames';
|
|||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
|
||||||
import Overlay from 'react-overlays/Overlay';
|
|
||||||
import Textarea from 'react-textarea-autosize';
|
import Textarea from 'react-textarea-autosize';
|
||||||
|
|
||||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||||
@ -16,8 +15,8 @@ import { AutosuggestHashtag } from './autosuggest_hashtag';
|
|||||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||||
let word;
|
let word;
|
||||||
|
|
||||||
let left = str.slice(0, caretPosition).search(/\S+$/);
|
let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
|
||||||
let right = str.slice(caretPosition).search(/\s/);
|
let right = str.slice(caretPosition).search(/[\s\u200B]/);
|
||||||
|
|
||||||
if (right < 0) {
|
if (right < 0) {
|
||||||
word = str.slice(left);
|
word = str.slice(left);
|
||||||
@ -32,7 +31,7 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
|
|||||||
word = word.trim().toLowerCase();
|
word = word.trim().toLowerCase();
|
||||||
|
|
||||||
if (word.length > 0) {
|
if (word.length > 0) {
|
||||||
return [left + 1, word];
|
return [left, word];
|
||||||
} else {
|
} else {
|
||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
}
|
||||||
@ -53,6 +52,7 @@ const AutosuggestTextarea = forwardRef(({
|
|||||||
onFocus,
|
onFocus,
|
||||||
autoFocus = true,
|
autoFocus = true,
|
||||||
lang,
|
lang,
|
||||||
|
children,
|
||||||
}, textareaRef) => {
|
}, textareaRef) => {
|
||||||
|
|
||||||
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
|
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
|
||||||
@ -183,38 +183,40 @@ const AutosuggestTextarea = forwardRef(({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return [
|
||||||
<div className='autosuggest-textarea'>
|
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
|
||||||
<Textarea
|
<div className='autosuggest-textarea'>
|
||||||
ref={textareaRef}
|
<label>
|
||||||
className='autosuggest-textarea__textarea'
|
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||||
disabled={disabled}
|
|
||||||
placeholder={placeholder}
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
value={value}
|
|
||||||
onChange={handleChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onKeyUp={onKeyUp}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onPaste={handlePaste}
|
|
||||||
dir='auto'
|
|
||||||
aria-autocomplete='list'
|
|
||||||
aria-label={placeholder}
|
|
||||||
lang={lang}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
|
<Textarea
|
||||||
{({ props }) => (
|
ref={textareaRef}
|
||||||
<div {...props}>
|
className='autosuggest-textarea__textarea'
|
||||||
<div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
|
disabled={disabled}
|
||||||
{suggestions.map(renderSuggestion)}
|
placeholder={placeholder}
|
||||||
</div>
|
autoFocus={autoFocus}
|
||||||
</div>
|
value={value}
|
||||||
)}
|
onChange={handleChange}
|
||||||
</Overlay>
|
onKeyDown={handleKeyDown}
|
||||||
</div>
|
onKeyUp={onKeyUp}
|
||||||
);
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
dir='auto'
|
||||||
|
aria-autocomplete='list'
|
||||||
|
lang={lang}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>,
|
||||||
|
|
||||||
|
<div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
|
||||||
|
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
|
||||||
|
{suggestions.map(renderSuggestion)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
AutosuggestTextarea.propTypes = {
|
AutosuggestTextarea.propTypes = {
|
||||||
@ -230,6 +232,7 @@ AutosuggestTextarea.propTypes = {
|
|||||||
onKeyDown: PropTypes.func,
|
onKeyDown: PropTypes.func,
|
||||||
onPaste: PropTypes.func.isRequired,
|
onPaste: PropTypes.func.isRequired,
|
||||||
onFocus:PropTypes.func,
|
onFocus:PropTypes.func,
|
||||||
|
children: PropTypes.node,
|
||||||
autoFocus: PropTypes.bool,
|
autoFocus: PropTypes.bool,
|
||||||
lang: PropTypes.string,
|
lang: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import ExpandLessIcon from '@/material-icons/400-24px/expand_less.svg?react';
|
|
||||||
|
|
||||||
import { IconButton } from './icon_button';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
|
|
||||||
uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export const CollapseButton = ({ collapsed, setCollapsed }) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const handleCollapsedClick = useCallback((e) => {
|
|
||||||
if (e.button === 0) {
|
|
||||||
setCollapsed(!collapsed);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}, [collapsed, setCollapsed]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
className='status__collapse-button'
|
|
||||||
animate
|
|
||||||
active={collapsed}
|
|
||||||
title={
|
|
||||||
collapsed ?
|
|
||||||
intl.formatMessage(messages.uncollapse) :
|
|
||||||
intl.formatMessage(messages.collapse)
|
|
||||||
}
|
|
||||||
icon='angle-double-up'
|
|
||||||
iconComponent={ExpandLessIcon}
|
|
||||||
onClick={handleCollapsedClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CollapseButton.propTypes = {
|
|
||||||
collapsed: PropTypes.bool,
|
|
||||||
setCollapsed: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
|
|
||||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
|
||||||
import { showAlert } from 'flavours/glitch/actions/alerts';
|
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
copied: { id: 'copy_icon_button.copied', defaultMessage: 'Copied to clipboard' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export const CopyIconButton = ({ title, value, className }) => {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
navigator.clipboard.writeText(value);
|
|
||||||
setCopied(true);
|
|
||||||
dispatch(showAlert({ message: messages.copied }));
|
|
||||||
setTimeout(() => setCopied(false), 700);
|
|
||||||
}, [setCopied, value, dispatch]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
className={classNames(className, copied ? 'copied' : 'copyable')}
|
|
||||||
title={title}
|
|
||||||
onClick={handleClick}
|
|
||||||
iconComponent={ContentCopyIcon}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CopyIconButton.propTypes = {
|
|
||||||
title: PropTypes.string,
|
|
||||||
value: PropTypes.string,
|
|
||||||
className: PropTypes.string,
|
|
||||||
};
|
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import type { List } from 'immutable';
|
import type { List } from 'immutable';
|
||||||
|
|
||||||
import type { Account } from 'flavours/glitch/models/account';
|
import type { Account } from 'flavours/glitch/models/account';
|
||||||
@ -12,6 +14,7 @@ interface Props {
|
|||||||
account?: Account;
|
account?: Account;
|
||||||
others?: List<Account>;
|
others?: List<Account>;
|
||||||
localDomain?: string;
|
localDomain?: string;
|
||||||
|
inline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DisplayName extends React.PureComponent<Props> {
|
export class DisplayName extends React.PureComponent<Props> {
|
||||||
@ -48,7 +51,7 @@ export class DisplayName extends React.PureComponent<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { others, localDomain } = this.props;
|
const { others, localDomain, inline } = this.props;
|
||||||
|
|
||||||
let displayName: React.ReactNode,
|
let displayName: React.ReactNode,
|
||||||
suffix: React.ReactNode,
|
suffix: React.ReactNode,
|
||||||
@ -111,11 +114,13 @@ export class DisplayName extends React.PureComponent<Props> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className='display-name'
|
className={classNames('display-name', { inline })}
|
||||||
onMouseEnter={this.handleMouseEnter}
|
onMouseEnter={this.handleMouseEnter}
|
||||||
onMouseLeave={this.handleMouseLeave}
|
onMouseLeave={this.handleMouseLeave}
|
||||||
>
|
>
|
||||||
{displayName} {suffix}
|
{displayName}
|
||||||
|
{inline ? ' ' : null}
|
||||||
|
{suffix}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -165,7 +165,7 @@ class Dropdown extends PureComponent {
|
|||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
icon: PropTypes.string,
|
icon: PropTypes.string,
|
||||||
iconComponent: PropTypes.func,
|
iconComponent: PropTypes.func,
|
||||||
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]),
|
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
|
||||||
loading: PropTypes.bool,
|
loading: PropTypes.bool,
|
||||||
size: PropTypes.number,
|
size: PropTypes.number,
|
||||||
title: PropTypes.string,
|
title: PropTypes.string,
|
||||||
|
|||||||
@ -18,7 +18,26 @@ import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
|
|||||||
import { IconButton } from './icon_button';
|
import { IconButton } from './icon_button';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: '{number, plural, one {Hide image} other {Hide images}}' },
|
hidden: {
|
||||||
|
defaultMessage: 'Media hidden',
|
||||||
|
id: 'status.media_hidden',
|
||||||
|
},
|
||||||
|
sensitive: {
|
||||||
|
defaultMessage: 'Sensitive',
|
||||||
|
id: 'media_gallery.sensitive',
|
||||||
|
},
|
||||||
|
toggle: {
|
||||||
|
defaultMessage: 'Click to view',
|
||||||
|
id: 'status.sensitive_toggle',
|
||||||
|
},
|
||||||
|
toggle_visible: {
|
||||||
|
defaultMessage: '{number, plural, one {Hide image} other {Hide images}}',
|
||||||
|
id: 'media_gallery.toggle_visible',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
defaultMessage: 'Sensitive content',
|
||||||
|
id: 'status.sensitive_warning',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
class Item extends PureComponent {
|
class Item extends PureComponent {
|
||||||
@ -280,8 +299,8 @@ class MediaGallery extends PureComponent {
|
|||||||
this.props.onOpenMedia(this.props.media, index, this.props.lang);
|
this.props.onOpenMedia(this.props.media, index, this.props.lang);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleRef = c => {
|
handleRef = (node) => {
|
||||||
this.node = c;
|
this.node = node;
|
||||||
|
|
||||||
if (this.node) {
|
if (this.node) {
|
||||||
this._setDimensions();
|
this._setDimensions();
|
||||||
@ -360,6 +379,11 @@ class MediaGallery extends PureComponent {
|
|||||||
<div className={computedClass} style={style} ref={this.handleRef}>
|
<div className={computedClass} style={style} ref={this.handleRef}>
|
||||||
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
|
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
|
||||||
{spoilerButton}
|
{spoilerButton}
|
||||||
|
{visible && sensitive && (
|
||||||
|
<span className='sensitive-marker'>
|
||||||
|
<FormattedMessage {...messages.sensitive} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export default class SettingText extends PureComponent {
|
|||||||
<label>
|
<label>
|
||||||
<span style={{ display: 'none' }}>{label}</span>
|
<span style={{ display: 'none' }}>{label}</span>
|
||||||
<input
|
<input
|
||||||
className='glitch-setting-text'
|
className='setting-text'
|
||||||
value={settings.getIn(settingPath)}
|
value={settings.getIn(settingPath)}
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
placeholder={label}
|
placeholder={label}
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import { MediaGallery, Video, Audio } from '../features/ui/util/async-components
|
|||||||
import { displayMedia } from '../initial_state';
|
import { displayMedia } from '../initial_state';
|
||||||
|
|
||||||
import AttachmentList from './attachment_list';
|
import AttachmentList from './attachment_list';
|
||||||
import { CollapseButton } from './collapse_button';
|
|
||||||
import { getHashtagBarForStatus } from './hashtag_bar';
|
import { getHashtagBarForStatus } from './hashtag_bar';
|
||||||
import StatusActionBar from './status_action_bar';
|
import StatusActionBar from './status_action_bar';
|
||||||
import StatusContent from './status_content';
|
import StatusContent from './status_content';
|
||||||
@ -511,6 +510,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
const {
|
const {
|
||||||
|
handleRef,
|
||||||
parseClick,
|
parseClick,
|
||||||
setCollapsed,
|
setCollapsed,
|
||||||
} = this;
|
} = this;
|
||||||
@ -729,6 +729,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
<Card
|
<Card
|
||||||
onOpenMedia={this.handleOpenMedia}
|
onOpenMedia={this.handleOpenMedia}
|
||||||
card={status.get('card')}
|
card={status.get('card')}
|
||||||
|
compact
|
||||||
sensitive={status.get('sensitive')}
|
sensitive={status.get('sensitive')}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@ -763,13 +764,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
account={account}
|
account={account}
|
||||||
parseClick={parseClick}
|
parseClick={parseClick}
|
||||||
notificationId={this.props.notificationId}
|
notificationId={this.props.notificationId}
|
||||||
>
|
/>
|
||||||
{muted && settings.getIn(['collapsed', 'enabled']) && (
|
|
||||||
<div className='notification__message-collapse-button'>
|
|
||||||
<CollapseButton collapsed={isCollapsed} setCollapsed={setCollapsed} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</StatusPrepend>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -777,77 +772,85 @@ class Status extends ImmutablePureComponent {
|
|||||||
rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: account.get('acct') });
|
rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: account.get('acct') });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const computedClass = classNames('status', `status-${status.get('visibility')}`, {
|
||||||
|
collapsed: isCollapsed,
|
||||||
|
'has-background': isCollapsed && background,
|
||||||
|
'status__wrapper-reply': !!status.get('in_reply_to_id'),
|
||||||
|
'status--in-thread': !!rootId,
|
||||||
|
'status--first-in-thread': previousId && (!connectUp || connectToRoot),
|
||||||
|
unread,
|
||||||
|
muted,
|
||||||
|
}, 'focusable');
|
||||||
|
|
||||||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||||
contentMedia.push(hashtagBar);
|
contentMedia.push(hashtagBar);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers}>
|
<HotKeys handlers={handlers}>
|
||||||
<div
|
<div
|
||||||
className={classNames('status__wrapper', 'focusable', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, collapsed: isCollapsed })}
|
className={computedClass}
|
||||||
|
style={isCollapsed && background ? { backgroundImage: `url(${background})` } : null}
|
||||||
{...selectorAttribs}
|
{...selectorAttribs}
|
||||||
|
ref={handleRef}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
data-featured={featured ? 'true' : null}
|
data-featured={featured ? 'true' : null}
|
||||||
aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))}
|
aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))}
|
||||||
ref={this.handleRef}
|
|
||||||
data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}
|
data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}
|
||||||
>
|
>
|
||||||
{prepend}
|
{!muted && prepend}
|
||||||
|
|
||||||
<div
|
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
|
||||||
className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted, 'has-background': isCollapsed && background, collapsed: isCollapsed })}
|
|
||||||
data-id={status.get('id')}
|
|
||||||
style={isCollapsed && background ? { backgroundImage: `url(${background})` } : null}
|
|
||||||
>
|
|
||||||
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
|
|
||||||
|
|
||||||
{(!muted || !isCollapsed) && (
|
<header className='status__info'>
|
||||||
<header className='status__info'>
|
<span>
|
||||||
|
{muted && prepend}
|
||||||
|
{!muted || !isCollapsed ? (
|
||||||
<StatusHeader
|
<StatusHeader
|
||||||
status={status}
|
status={status}
|
||||||
friend={account}
|
friend={account}
|
||||||
collapsed={isCollapsed}
|
collapsed={isCollapsed}
|
||||||
parseClick={parseClick}
|
parseClick={parseClick}
|
||||||
/>
|
/>
|
||||||
<StatusIcons
|
) : null}
|
||||||
status={status}
|
</span>
|
||||||
mediaIcons={contentMediaIcons.concat(extraMediaIcons)}
|
<StatusIcons
|
||||||
collapsible={!muted && settings.getIn(['collapsed', 'enabled'])}
|
|
||||||
collapsed={isCollapsed}
|
|
||||||
setCollapsed={setCollapsed}
|
|
||||||
settings={settings.get('status_icons')}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
)}
|
|
||||||
<StatusContent
|
|
||||||
status={status}
|
status={status}
|
||||||
media={contentMedia}
|
mediaIcons={contentMediaIcons.concat(extraMediaIcons)}
|
||||||
extraMedia={extraMedia}
|
collapsible={settings.getIn(['collapsed', 'enabled'])}
|
||||||
mediaIcons={contentMediaIcons}
|
collapsed={isCollapsed}
|
||||||
expanded={isExpanded}
|
setCollapsed={setCollapsed}
|
||||||
onExpandedToggle={this.handleExpandedToggle}
|
settings={settings.get('status_icons')}
|
||||||
onTranslate={this.handleTranslate}
|
|
||||||
parseClick={parseClick}
|
|
||||||
disabled={!history}
|
|
||||||
tagLinks={settings.get('tag_misleading_links')}
|
|
||||||
rewriteMentions={settings.get('rewrite_mentions')}
|
|
||||||
{...statusContentProps}
|
|
||||||
/>
|
/>
|
||||||
|
</header>
|
||||||
|
<StatusContent
|
||||||
|
status={status}
|
||||||
|
media={contentMedia}
|
||||||
|
extraMedia={extraMedia}
|
||||||
|
mediaIcons={contentMediaIcons}
|
||||||
|
expanded={isExpanded}
|
||||||
|
onExpandedToggle={this.handleExpandedToggle}
|
||||||
|
onTranslate={this.handleTranslate}
|
||||||
|
parseClick={parseClick}
|
||||||
|
disabled={!history}
|
||||||
|
tagLinks={settings.get('tag_misleading_links')}
|
||||||
|
rewriteMentions={settings.get('rewrite_mentions')}
|
||||||
|
{...statusContentProps}
|
||||||
|
/>
|
||||||
|
|
||||||
{(!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar']))) && (
|
{!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
|
||||||
<StatusActionBar
|
<StatusActionBar
|
||||||
status={status}
|
status={status}
|
||||||
account={status.get('account')}
|
account={status.get('account')}
|
||||||
showReplyCount={settings.get('show_reply_count')}
|
showReplyCount={settings.get('show_reply_count')}
|
||||||
onFilter={matchedFilters ? this.handleFilterClick : null}
|
onFilter={matchedFilters ? this.handleFilterClick : null}
|
||||||
{...other}
|
{...other}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
{notification && (
|
{notification ? (
|
||||||
<NotificationOverlayContainer
|
<NotificationOverlayContainer
|
||||||
notification={notification}
|
notification={notification}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -45,19 +45,26 @@ export default class StatusHeader extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<div className='status__info__account'>
|
||||||
href={account.get('url')}
|
<a
|
||||||
className='status__display-name'
|
href={account.get('url')}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
onClick={this.handleAccountClick}
|
className='status__avatar'
|
||||||
rel='noopener noreferrer'
|
onClick={this.handleAccountClick}
|
||||||
>
|
rel='noopener noreferrer'
|
||||||
<div className='status__avatar'>
|
>
|
||||||
{statusAvatar}
|
{statusAvatar}
|
||||||
</div>
|
</a>
|
||||||
|
<a
|
||||||
<DisplayName account={account} />
|
href={account.get('url')}
|
||||||
</a>
|
target='_blank'
|
||||||
|
className='status__display-name'
|
||||||
|
onClick={this.handleAccountClick}
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
<DisplayName account={account} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { defineMessages, injectIntl } from 'react-intl';
|
|||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
|
||||||
|
import ExpandLessIcon from '@/material-icons/400-24px/expand_less.svg?react';
|
||||||
import ForumIcon from '@/material-icons/400-24px/forum.svg?react';
|
import ForumIcon from '@/material-icons/400-24px/forum.svg?react';
|
||||||
import HomeIcon from '@/material-icons/400-24px/home.svg?react';
|
import HomeIcon from '@/material-icons/400-24px/home.svg?react';
|
||||||
import ImageIcon from '@/material-icons/400-24px/image.svg?react';
|
import ImageIcon from '@/material-icons/400-24px/image.svg?react';
|
||||||
@ -16,7 +17,8 @@ import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react';
|
|||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { languages } from 'flavours/glitch/initial_state';
|
import { languages } from 'flavours/glitch/initial_state';
|
||||||
|
|
||||||
import { CollapseButton } from './collapse_button';
|
|
||||||
|
import { IconButton } from './icon_button';
|
||||||
import { VisibilityIcon } from './visibility_icon';
|
import { VisibilityIcon } from './visibility_icon';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@ -116,7 +118,6 @@ class StatusIcons extends PureComponent {
|
|||||||
mediaIcons,
|
mediaIcons,
|
||||||
collapsible,
|
collapsible,
|
||||||
collapsed,
|
collapsed,
|
||||||
setCollapsed,
|
|
||||||
settings,
|
settings,
|
||||||
intl,
|
intl,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
@ -142,7 +143,21 @@ class StatusIcons extends PureComponent {
|
|||||||
/>}
|
/>}
|
||||||
{settings.get('media') && !!mediaIcons && mediaIcons.map(icon => this.renderIcon(icon))}
|
{settings.get('media') && !!mediaIcons && mediaIcons.map(icon => this.renderIcon(icon))}
|
||||||
{settings.get('visibility') && <VisibilityIcon visibility={status.get('visibility')} />}
|
{settings.get('visibility') && <VisibilityIcon visibility={status.get('visibility')} />}
|
||||||
{collapsible && <CollapseButton collapsed={collapsed} setCollapsed={setCollapsed} />}
|
{collapsible && (
|
||||||
|
<IconButton
|
||||||
|
className='status__collapse-button'
|
||||||
|
animate
|
||||||
|
active={collapsed}
|
||||||
|
title={
|
||||||
|
collapsed ?
|
||||||
|
intl.formatMessage(messages.uncollapse) :
|
||||||
|
intl.formatMessage(messages.collapse)
|
||||||
|
}
|
||||||
|
icon='angle-double-up'
|
||||||
|
iconComponent={ExpandLessIcon}
|
||||||
|
onClick={this.handleCollapsedClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,9 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||||||
|
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
|
|
||||||
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'flavours/glitch/actions/timelines';
|
|
||||||
import RegenerationIndicator from 'flavours/glitch/components/regeneration_indicator';
|
import RegenerationIndicator from 'flavours/glitch/components/regeneration_indicator';
|
||||||
import { InlineFollowSuggestions } from 'flavours/glitch/features/home_timeline/components/inline_follow_suggestions';
|
|
||||||
|
|
||||||
import StatusContainer from '../containers/status_container';
|
import StatusContainer from '../containers/status_container';
|
||||||
|
|
||||||
@ -94,37 +92,24 @@ export default class StatusList extends ImmutablePureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let scrollableContent = (isLoading || statusIds.size > 0) ? (
|
let scrollableContent = (isLoading || statusIds.size > 0) ? (
|
||||||
statusIds.map((statusId, index) => {
|
statusIds.map((statusId, index) => statusId === null ? (
|
||||||
switch(statusId) {
|
<LoadGap
|
||||||
case TIMELINE_SUGGESTIONS:
|
key={'gap:' + statusIds.get(index + 1)}
|
||||||
return (
|
disabled={isLoading}
|
||||||
<InlineFollowSuggestions
|
maxId={index > 0 ? statusIds.get(index - 1) : null}
|
||||||
key='inline-follow-suggestions'
|
onClick={onLoadMore}
|
||||||
/>
|
/>
|
||||||
);
|
) : (
|
||||||
case TIMELINE_GAP:
|
<StatusContainer
|
||||||
return (
|
key={statusId}
|
||||||
<LoadGap
|
id={statusId}
|
||||||
key={'gap:' + statusIds.get(index + 1)}
|
onMoveUp={this.handleMoveUp}
|
||||||
disabled={isLoading}
|
onMoveDown={this.handleMoveDown}
|
||||||
maxId={index > 0 ? statusIds.get(index - 1) : null}
|
contextType={timelineId}
|
||||||
onClick={onLoadMore}
|
scrollKey={this.props.scrollKey}
|
||||||
/>
|
withCounters={this.props.withCounters}
|
||||||
);
|
/>
|
||||||
default:
|
))
|
||||||
return (
|
|
||||||
<StatusContainer
|
|
||||||
key={statusId}
|
|
||||||
id={statusId}
|
|
||||||
onMoveUp={this.handleMoveUp}
|
|
||||||
onMoveDown={this.handleMoveDown}
|
|
||||||
contextType={timelineId}
|
|
||||||
scrollKey={this.props.scrollKey}
|
|
||||||
withCounters={this.props.withCounters}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (scrollableContent && featuredStatusIds) {
|
if (scrollableContent && featuredStatusIds) {
|
||||||
|
|||||||
@ -23,7 +23,6 @@ export default class StatusPrepend extends PureComponent {
|
|||||||
account: ImmutablePropTypes.map.isRequired,
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
parseClick: PropTypes.func.isRequired,
|
parseClick: PropTypes.func.isRequired,
|
||||||
notificationId: PropTypes.number,
|
notificationId: PropTypes.number,
|
||||||
children: PropTypes.node,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleClick = (e) => {
|
handleClick = (e) => {
|
||||||
@ -39,13 +38,11 @@ export default class StatusPrepend extends PureComponent {
|
|||||||
href={account.get('url')}
|
href={account.get('url')}
|
||||||
className='status__display-name'
|
className='status__display-name'
|
||||||
>
|
>
|
||||||
<bdi>
|
<b
|
||||||
<strong
|
dangerouslySetInnerHTML={{
|
||||||
dangerouslySetInnerHTML={{
|
__html : account.get('display_name_html') || account.get('username'),
|
||||||
__html : account.get('display_name_html') || account.get('username'),
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</bdi>
|
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@ -115,7 +112,7 @@ export default class StatusPrepend extends PureComponent {
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { Message } = this;
|
const { Message } = this;
|
||||||
const { type, children } = this.props;
|
const { type } = this.props;
|
||||||
|
|
||||||
let iconId, iconComponent;
|
let iconId, iconComponent;
|
||||||
|
|
||||||
@ -149,13 +146,14 @@ export default class StatusPrepend extends PureComponent {
|
|||||||
|
|
||||||
return !type ? null : (
|
return !type ? null : (
|
||||||
<aside className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend' : 'notification__message'}>
|
<aside className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend' : 'notification__message'}>
|
||||||
<Icon
|
<div className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}>
|
||||||
className={`status__prepend-icon ${type === 'favourite' ? 'star-icon' : ''}`}
|
<Icon
|
||||||
id={iconId}
|
className={`status__prepend-icon ${type === 'favourite' ? 'star-icon' : ''}`}
|
||||||
icon={iconComponent}
|
id={iconId}
|
||||||
/>
|
icon={iconComponent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Message />
|
<Message />
|
||||||
{children}
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||||
|
import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
|
||||||
import MailIcon from '@/material-icons/400-24px/mail.svg?react';
|
import MailIcon from '@/material-icons/400-24px/mail.svg?react';
|
||||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||||
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
|
||||||
|
|
||||||
import { Icon } from './icon';
|
import { Icon } from './icon';
|
||||||
|
|
||||||
@ -11,17 +11,14 @@ type Visibility = 'public' | 'unlisted' | 'private' | 'direct';
|
|||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||||
unlisted_short: {
|
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
||||||
id: 'privacy.unlisted.short',
|
|
||||||
defaultMessage: 'Quiet public',
|
|
||||||
},
|
|
||||||
private_short: {
|
private_short: {
|
||||||
id: 'privacy.private.short',
|
id: 'privacy.private.short',
|
||||||
defaultMessage: 'Followers',
|
defaultMessage: 'Followers only',
|
||||||
},
|
},
|
||||||
direct_short: {
|
direct_short: {
|
||||||
id: 'privacy.direct.short',
|
id: 'privacy.direct.short',
|
||||||
defaultMessage: 'Specific people',
|
defaultMessage: 'Mentioned people only',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -38,7 +35,7 @@ export const VisibilityIcon: React.FC<{ visibility: Visibility }> = ({
|
|||||||
},
|
},
|
||||||
unlisted: {
|
unlisted: {
|
||||||
icon: 'unlock',
|
icon: 'unlock',
|
||||||
iconComponent: QuietTimeIcon,
|
iconComponent: LockOpenIcon,
|
||||||
text: intl.formatMessage(messages.unlisted_short),
|
text: intl.formatMessage(messages.unlisted_short),
|
||||||
},
|
},
|
||||||
private: {
|
private: {
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
|
import { fetchCustomEmojis } from '../actions/custom_emojis';
|
||||||
import { hydrateStore } from 'flavours/glitch/actions/store';
|
import { hydrateStore } from '../actions/store';
|
||||||
import { Router } from 'flavours/glitch/components/router';
|
import Compose from '../features/standalone/compose';
|
||||||
import Compose from 'flavours/glitch/features/standalone/compose';
|
import initialState from '../initial_state';
|
||||||
import initialState from 'flavours/glitch/initial_state';
|
import { IntlProvider } from '../locales';
|
||||||
import { IntlProvider } from 'flavours/glitch/locales';
|
import { store } from '../store';
|
||||||
import { store } from 'flavours/glitch/store';
|
|
||||||
|
|
||||||
if (initialState) {
|
if (initialState) {
|
||||||
store.dispatch(hydrateStore(initialState));
|
store.dispatch(hydrateStore(initialState));
|
||||||
@ -14,14 +16,16 @@ if (initialState) {
|
|||||||
|
|
||||||
store.dispatch(fetchCustomEmojis());
|
store.dispatch(fetchCustomEmojis());
|
||||||
|
|
||||||
const ComposeContainer = () => (
|
export default class ComposeContainer extends PureComponent {
|
||||||
<IntlProvider>
|
|
||||||
<Provider store={store}>
|
|
||||||
<Router>
|
|
||||||
<Compose />
|
|
||||||
</Router>
|
|
||||||
</Provider>
|
|
||||||
</IntlProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default ComposeContainer;
|
render () {
|
||||||
|
return (
|
||||||
|
<IntlProvider>
|
||||||
|
<Provider store={store}>
|
||||||
|
<Compose />
|
||||||
|
</Provider>
|
||||||
|
</IntlProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
|||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
|
||||||
|
|
||||||
export default class FollowRequestNote extends ImmutablePureComponent {
|
export default class FollowRequestNote extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
|||||||
@ -9,16 +9,15 @@ import { withRouter } from 'react-router-dom';
|
|||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
|
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||||
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
|
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
|
||||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
import { Badge, AutomatedBadge, GroupBadge } from 'flavours/glitch/components/badge';
|
import { Badge, AutomatedBadge, GroupBadge } from 'flavours/glitch/components/badge';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button';
|
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
||||||
@ -46,7 +45,6 @@ const messages = defineMessages({
|
|||||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||||
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
|
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
|
||||||
share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
|
share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
|
||||||
copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
|
|
||||||
media: { id: 'account.media', defaultMessage: 'Media' },
|
media: { id: 'account.media', defaultMessage: 'Media' },
|
||||||
blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
|
blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
|
||||||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
|
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
|
||||||
@ -178,10 +176,11 @@ class Header extends ImmutablePureComponent {
|
|||||||
const isRemote = account.get('acct') !== account.get('username');
|
const isRemote = account.get('acct') !== account.get('username');
|
||||||
const remoteDomain = isRemote ? account.get('acct').split('@')[1] : null;
|
const remoteDomain = isRemote ? account.get('acct').split('@')[1] : null;
|
||||||
|
|
||||||
let actionBtn, bellBtn, lockedIcon, shareBtn;
|
let info = [];
|
||||||
|
let actionBtn = '';
|
||||||
let info = [];
|
let bellBtn = '';
|
||||||
let menu = [];
|
let lockedIcon = '';
|
||||||
|
let menu = [];
|
||||||
|
|
||||||
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
|
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
|
||||||
info.push(<span className='relationship-tag'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>);
|
info.push(<span className='relationship-tag'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>);
|
||||||
@ -199,12 +198,6 @@ class Header extends ImmutablePureComponent {
|
|||||||
bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} iconComponent={account.getIn(['relationship', 'notifying']) ? NotificationsActiveIcon : NotificationsIcon} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
|
bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} iconComponent={account.getIn(['relationship', 'notifying']) ? NotificationsActiveIcon : NotificationsIcon} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('share' in navigator) {
|
|
||||||
shareBtn = <IconButton className='optional' iconComponent={ShareIcon} title={intl.formatMessage(messages.share, { name: account.get('username') })} onClick={this.handleShare} />;
|
|
||||||
} else {
|
|
||||||
shareBtn = <CopyIconButton className='optional' title={intl.formatMessage(messages.copy)} value={account.get('url')} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (me !== account.get('id')) {
|
if (me !== account.get('id')) {
|
||||||
if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
|
if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
|
||||||
actionBtn = '';
|
actionBtn = '';
|
||||||
@ -242,6 +235,11 @@ class Header extends ImmutablePureComponent {
|
|||||||
menu.push(null);
|
menu.push(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('share' in navigator && !account.get('suspended')) {
|
||||||
|
menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
|
||||||
|
menu.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
if (account.get('id') === me) {
|
if (account.get('id') === me) {
|
||||||
if (profileLink) menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
|
if (profileLink) menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
|
||||||
if (preferencesLink) menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
|
if (preferencesLink) menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
|
||||||
@ -310,7 +308,7 @@ class Header extends ImmutablePureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = { __html: account.get('note_emojified') };
|
const content = { __html: account.get('note_emojified') };
|
||||||
const displayNameHtml = { __html: account.get('display_name_html') };
|
const displayNameHtml = { __html: account.get('display_name_html') };
|
||||||
const fields = account.get('fields');
|
const fields = account.get('fields');
|
||||||
const isLocal = account.get('acct').indexOf('@') === -1;
|
const isLocal = account.get('acct').indexOf('@') === -1;
|
||||||
@ -352,7 +350,6 @@ class Header extends ImmutablePureComponent {
|
|||||||
<>
|
<>
|
||||||
{actionBtn}
|
{actionBtn}
|
||||||
{bellBtn}
|
{bellBtn}
|
||||||
{shareBtn}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -375,29 +372,28 @@ class Header extends ImmutablePureComponent {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{signedIn && <AccountNoteContainer account={account} />}
|
||||||
|
|
||||||
{!(suspended || hidden) && (
|
{!(suspended || hidden) && (
|
||||||
<div className='account__header__extra'>
|
<div className='account__header__extra'>
|
||||||
<div className='account__header__bio'>
|
<div className='account__header__bio'>
|
||||||
{(account.get('id') !== me && signedIn) && <AccountNoteContainer account={account} />}
|
{ fields.size > 0 && (
|
||||||
|
<div className='account__header__fields'>
|
||||||
|
{fields.map((pair, i) => (
|
||||||
|
<dl key={i}>
|
||||||
|
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
|
||||||
|
|
||||||
|
<dd className={pair.get('verified_at') && 'verified'} title={pair.get('value_plain')}>
|
||||||
|
{pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' icon={CheckIcon} className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} className='translate' />
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />}
|
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />}
|
||||||
|
|
||||||
<div className='account__header__fields'>
|
<div className='account__header__joined'><FormattedMessage id='account.joined' defaultMessage='Joined {date}' values={{ date: intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' }) }} /></div>
|
||||||
<dl>
|
|
||||||
<dt><FormattedMessage id='account.joined_short' defaultMessage='Joined' /></dt>
|
|
||||||
<dd>{intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' })}</dd>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{fields.map((pair, i) => (
|
|
||||||
<dl key={i} className={classNames({ verified: pair.get('verified_at') })}>
|
|
||||||
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} className='translate' />
|
|
||||||
|
|
||||||
<dd className='translate' title={pair.get('value_plain')}>
|
|
||||||
{pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' icon={CheckIcon} className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,38 +1,54 @@
|
|||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
|
|
||||||
|
import TripIcon from '@/material-icons/400-24px/trip.svg?react';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||||
|
|
||||||
import { AvatarOverlay } from '../../../components/avatar_overlay';
|
import { AvatarOverlay } from '../../../components/avatar_overlay';
|
||||||
import { DisplayName } from '../../../components/display_name';
|
import { DisplayName } from '../../../components/display_name';
|
||||||
import { Permalink } from '../../../components/permalink';
|
|
||||||
|
|
||||||
export default class MovedNote extends ImmutablePureComponent {
|
class MovedNote extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
from: ImmutablePropTypes.map.isRequired,
|
from: ImmutablePropTypes.map.isRequired,
|
||||||
to: ImmutablePropTypes.map.isRequired,
|
to: ImmutablePropTypes.map.isRequired,
|
||||||
|
...WithRouterPropTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAccountClick = e => {
|
||||||
|
if (e.button === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.props.history.push(`/@${this.props.to.get('acct')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { from, to } = this.props;
|
const { from, to } = this.props;
|
||||||
|
const displayNameHtml = { __html: from.get('display_name_html') };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='moved-account-banner'>
|
<div className='account__moved-note'>
|
||||||
<div className='moved-account-banner__message'>
|
<div className='account__moved-note__message'>
|
||||||
<FormattedMessage id='account.moved_to' defaultMessage='{name} has indicated that their new account is now:' values={{ name: <bdi><strong dangerouslySetInnerHTML={{ __html: from.get('display_name_html') }} /></bdi> }} />
|
<div className='account__moved-note__icon-wrapper'><Icon id='suitcase' className='account__moved-note__icon' icon={TripIcon} /></div>
|
||||||
|
<FormattedMessage id='account.moved_to' defaultMessage='{name} has indicated that their new account is now:' values={{ name: <bdi><strong dangerouslySetInnerHTML={displayNameHtml} /></bdi> }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='moved-account-banner__action'>
|
<a href={to.get('url')} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
||||||
<Permalink to={`/@${to.get('acct')}`} href={to.get('url')} className='detailed-status__display-name'>
|
<div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
|
||||||
<div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
|
<DisplayName account={to} />
|
||||||
<DisplayName account={to} />
|
</a>
|
||||||
</Permalink>
|
|
||||||
|
|
||||||
<Permalink to={`/@${to.get('acct')}`} href={to.get('url')} className='button'><FormattedMessage id='account.go_to_profile' defaultMessage='Go to profile' /></Permalink>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withRouter(MovedNote);
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import { useCallback } from 'react';
|
import PropTypes from 'prop-types';
|
||||||
|
import { PureComponent } from 'react';
|
||||||
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
import { useDispatch } from 'react-redux';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
|
||||||
import { openModal } from 'flavours/glitch/actions/modal';
|
import { preferencesLink, profileLink } from 'flavours/glitch/utils/backend_links';
|
||||||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
|
||||||
import { logOut } from 'flavours/glitch/utils/log_out';
|
|
||||||
|
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||||
@ -23,52 +25,51 @@ const messages = defineMessages({
|
|||||||
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
|
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
|
||||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||||
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
|
|
||||||
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ActionBar = () => {
|
class ActionBar extends PureComponent {
|
||||||
const dispatch = useDispatch();
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const handleLogoutClick = useCallback(() => {
|
static propTypes = {
|
||||||
dispatch(openModal({
|
account: ImmutablePropTypes.record.isRequired,
|
||||||
modalType: 'CONFIRM',
|
onLogout: PropTypes.func.isRequired,
|
||||||
modalProps: {
|
intl: PropTypes.object.isRequired,
|
||||||
message: intl.formatMessage(messages.logoutMessage),
|
};
|
||||||
confirm: intl.formatMessage(messages.logoutConfirm),
|
|
||||||
closeWhenConfirm: false,
|
|
||||||
onConfirm: () => logOut(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}, [dispatch, intl]);
|
|
||||||
|
|
||||||
let menu = [];
|
handleLogout = () => {
|
||||||
|
this.props.onLogout();
|
||||||
|
};
|
||||||
|
|
||||||
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
|
render () {
|
||||||
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
|
const { intl } = this.props;
|
||||||
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
|
|
||||||
menu.push(null);
|
|
||||||
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
|
|
||||||
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
|
|
||||||
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
|
|
||||||
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
|
||||||
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
|
|
||||||
menu.push(null);
|
|
||||||
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
|
||||||
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
|
||||||
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
|
|
||||||
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
|
|
||||||
menu.push(null);
|
|
||||||
menu.push({ text: intl.formatMessage(messages.logout), action: handleLogoutClick });
|
|
||||||
|
|
||||||
return (
|
let menu = [];
|
||||||
<DropdownMenuContainer
|
|
||||||
items={menu}
|
menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
|
||||||
icon='bars'
|
menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
|
||||||
iconComponent={MoreHorizIcon}
|
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
|
||||||
size={24}
|
menu.push(null);
|
||||||
direction='right'
|
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
|
||||||
/>
|
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
|
||||||
);
|
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
|
||||||
};
|
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
|
||||||
|
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
|
||||||
|
menu.push(null);
|
||||||
|
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
|
||||||
|
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
||||||
|
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
|
||||||
|
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
|
||||||
|
menu.push(null);
|
||||||
|
menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='compose__action-bar'>
|
||||||
|
<div className='compose__action-bar-dropdown'>
|
||||||
|
<DropdownMenuContainer items={menu} icon='bars' iconComponent={MenuIcon} size={24} direction='right' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default injectIntl(ActionBar);
|
||||||
|
|||||||
@ -15,8 +15,8 @@ export default class AutosuggestAccount extends ImmutablePureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='autosuggest-account' title={account.get('acct')}>
|
<div className='autosuggest-account' title={account.get('acct')}>
|
||||||
<Avatar account={account} size={24} />
|
<div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div>
|
||||||
<DisplayName account={account} />
|
<DisplayName account={account} inline />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,26 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { PureComponent } from 'react';
|
||||||
|
|
||||||
import { length } from 'stringz';
|
import { length } from 'stringz';
|
||||||
|
|
||||||
export const CharacterCounter = ({ text, max }) => {
|
export default class CharacterCounter extends PureComponent {
|
||||||
const diff = max - length(text);
|
|
||||||
|
|
||||||
if (diff < 0) {
|
static propTypes = {
|
||||||
return <span className='character-counter character-counter--over'>{diff}</span>;
|
text: PropTypes.string.isRequired,
|
||||||
|
max: PropTypes.number.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
checkRemainingText (diff) {
|
||||||
|
if (diff < 0) {
|
||||||
|
return <span className='character-counter character-counter--over'>{diff}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className='character-counter'>{diff}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <span className='character-counter'>{diff}</span>;
|
render () {
|
||||||
};
|
const diff = this.props.max - length(this.props.text);
|
||||||
|
return this.checkRemainingText(diff);
|
||||||
|
}
|
||||||
|
|
||||||
CharacterCounter.propTypes = {
|
}
|
||||||
text: PropTypes.string.isRequired,
|
|
||||||
max: PropTypes.number.isRequired,
|
|
||||||
};
|
|
||||||
|
|||||||
@ -10,39 +10,35 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||||||
|
|
||||||
import { length } from 'stringz';
|
import { length } from 'stringz';
|
||||||
|
|
||||||
|
import { maxChars } from 'flavours/glitch/initial_state';
|
||||||
|
import { isMobile } from 'flavours/glitch/is_mobile';
|
||||||
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router';
|
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router';
|
||||||
|
|
||||||
import AutosuggestInput from '../../../components/autosuggest_input';
|
import AutosuggestInput from '../../../components/autosuggest_input';
|
||||||
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
||||||
import { Button } from '../../../components/button';
|
|
||||||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
||||||
import LanguageDropdown from '../containers/language_dropdown_container';
|
import OptionsContainer from '../containers/options_container';
|
||||||
import PollButtonContainer from '../containers/poll_button_container';
|
import PollFormContainer from '../containers/poll_form_container';
|
||||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
|
||||||
import SpoilerButtonContainer from '../containers/spoiler_button_container';
|
|
||||||
import UploadButtonContainer from '../containers/upload_button_container';
|
|
||||||
import UploadFormContainer from '../containers/upload_form_container';
|
import UploadFormContainer from '../containers/upload_form_container';
|
||||||
import WarningContainer from '../containers/warning_container';
|
import WarningContainer from '../containers/warning_container';
|
||||||
import { countableText } from '../util/counter';
|
import { countableText } from '../util/counter';
|
||||||
|
|
||||||
import { CharacterCounter } from './character_counter';
|
import CharacterCounter from './character_counter';
|
||||||
import { ContentTypeButton } from './content_type_button';
|
import Publisher from './publisher';
|
||||||
import { EditIndicator } from './edit_indicator';
|
import TextareaIcons from './textarea_icons';
|
||||||
import { FederationButton } from './federation_button';
|
|
||||||
import { NavigationBar } from './navigation_bar';
|
|
||||||
import { PollForm } from "./poll_form";
|
|
||||||
import { ReplyIndicator } from './reply_indicator';
|
|
||||||
import { SecondaryPrivacyButton } from './secondary_privacy_button';
|
|
||||||
import { ThreadModeButton } from './thread_mode_button';
|
|
||||||
|
|
||||||
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
|
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
|
||||||
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning (optional)' },
|
missingDescriptionMessage: {
|
||||||
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
|
id: 'confirmations.missing_media_description.message',
|
||||||
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Update' },
|
defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.',
|
||||||
reply: { id: 'compose_form.reply', defaultMessage: 'Reply' },
|
},
|
||||||
|
missingDescriptionConfirm: {
|
||||||
|
id: 'confirmations.missing_media_description.confirm',
|
||||||
|
defaultMessage: 'Send anyway',
|
||||||
|
},
|
||||||
|
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
|
||||||
});
|
});
|
||||||
|
|
||||||
class ComposeForm extends ImmutablePureComponent {
|
class ComposeForm extends ImmutablePureComponent {
|
||||||
@ -51,14 +47,11 @@ class ComposeForm extends ImmutablePureComponent {
|
|||||||
text: PropTypes.string.isRequired,
|
text: PropTypes.string.isRequired,
|
||||||
suggestions: ImmutablePropTypes.list,
|
suggestions: ImmutablePropTypes.list,
|
||||||
spoiler: PropTypes.bool,
|
spoiler: PropTypes.bool,
|
||||||
spoilerAlwaysOn: PropTypes.bool,
|
|
||||||
privacy: PropTypes.string,
|
privacy: PropTypes.string,
|
||||||
sideArm: PropTypes.string,
|
|
||||||
spoilerText: PropTypes.string,
|
spoilerText: PropTypes.string,
|
||||||
focusDate: PropTypes.instanceOf(Date),
|
focusDate: PropTypes.instanceOf(Date),
|
||||||
caretPosition: PropTypes.number,
|
caretPosition: PropTypes.number,
|
||||||
preselectDate: PropTypes.instanceOf(Date),
|
preselectDate: PropTypes.instanceOf(Date),
|
||||||
preselectOnReply: PropTypes.bool,
|
|
||||||
isSubmitting: PropTypes.bool,
|
isSubmitting: PropTypes.bool,
|
||||||
isChangingUpload: PropTypes.bool,
|
isChangingUpload: PropTypes.bool,
|
||||||
isEditing: PropTypes.bool,
|
isEditing: PropTypes.bool,
|
||||||
@ -71,21 +64,26 @@ class ComposeForm extends ImmutablePureComponent {
|
|||||||
onChangeSpoilerText: PropTypes.func.isRequired,
|
onChangeSpoilerText: PropTypes.func.isRequired,
|
||||||
onPaste: PropTypes.func.isRequired,
|
onPaste: PropTypes.func.isRequired,
|
||||||
onPickEmoji: PropTypes.func.isRequired,
|
onPickEmoji: PropTypes.func.isRequired,
|
||||||
autoFocus: PropTypes.bool,
|
showSearch: PropTypes.bool,
|
||||||
withoutNavigation: PropTypes.bool,
|
|
||||||
anyMedia: PropTypes.bool,
|
anyMedia: PropTypes.bool,
|
||||||
media: ImmutablePropTypes.list,
|
|
||||||
mediaDescriptionConfirmation: PropTypes.bool,
|
|
||||||
onMediaDescriptionConfirm: PropTypes.func.isRequired,
|
|
||||||
isInReply: PropTypes.bool,
|
isInReply: PropTypes.bool,
|
||||||
singleColumn: PropTypes.bool,
|
singleColumn: PropTypes.bool,
|
||||||
lang: PropTypes.string,
|
lang: PropTypes.string,
|
||||||
maxChars: PropTypes.number,
|
advancedOptions: ImmutablePropTypes.map,
|
||||||
|
media: ImmutablePropTypes.list,
|
||||||
|
sideArm: PropTypes.string,
|
||||||
|
sensitive: PropTypes.bool,
|
||||||
|
spoilersAlwaysOn: PropTypes.bool,
|
||||||
|
mediaDescriptionConfirmation: PropTypes.bool,
|
||||||
|
preselectOnReply: PropTypes.bool,
|
||||||
|
onChangeSpoilerness: PropTypes.func.isRequired,
|
||||||
|
onChangeVisibility: PropTypes.func.isRequired,
|
||||||
|
onMediaDescriptionConfirm: PropTypes.func.isRequired,
|
||||||
...WithOptionalRouterPropTypes
|
...WithOptionalRouterPropTypes
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
autoFocus: false,
|
showSearch: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
@ -103,27 +101,30 @@ class ComposeForm extends ImmutablePureComponent {
|
|||||||
|
|
||||||
handleKeyDown = (e) => {
|
handleKeyDown = (e) => {
|
||||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||||
this.handleSubmit(e);
|
this.handleSubmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.keyCode === 13 && e.altKey) {
|
if (e.keyCode === 13 && e.altKey) {
|
||||||
this.handleSecondarySubmit(e);
|
this.handleSecondarySubmit();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
getFulltextForCharacterCounting = () => {
|
getFulltextForCharacterCounting = () => {
|
||||||
return [this.props.spoiler? this.props.spoilerText: '', countableText(this.props.text)].join('');
|
return [
|
||||||
|
this.props.spoiler? this.props.spoilerText: '',
|
||||||
|
countableText(this.props.text),
|
||||||
|
this.props.advancedOptions && this.props.advancedOptions.get('do_not_federate') ? ' 👁️' : '',
|
||||||
|
].join('');
|
||||||
};
|
};
|
||||||
|
|
||||||
canSubmit = () => {
|
canSubmit = () => {
|
||||||
const { isSubmitting, isChangingUpload, isUploading, anyMedia, maxChars } = this.props;
|
const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props;
|
||||||
const fulltext = this.getFulltextForCharacterCounting();
|
const fulltext = this.getFulltextForCharacterCounting();
|
||||||
const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;
|
|
||||||
|
|
||||||
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (isOnlyWhitespace && !anyMedia));
|
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (!fulltext.trim().length && !anyMedia));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleSubmit = (e, overridePrivacy = null) => {
|
handleSubmit = (e, overriddenVisibility = null) => {
|
||||||
if (this.props.text !== this.textareaRef.current.value) {
|
if (this.props.text !== this.textareaRef.current.value) {
|
||||||
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
|
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
|
||||||
// Update the state to match the current text
|
// Update the state to match the current text
|
||||||
@ -141,15 +142,19 @@ class ComposeForm extends ImmutablePureComponent {
|
|||||||
// Submit unless there are media with missing descriptions
|
// Submit unless there are media with missing descriptions
|
||||||
if (this.props.mediaDescriptionConfirmation && this.props.media && this.props.media.some(item => !item.get('description'))) {
|
if (this.props.mediaDescriptionConfirmation && this.props.media && this.props.media.some(item => !item.get('description'))) {
|
||||||
const firstWithoutDescription = this.props.media.find(item => !item.get('description'));
|
const firstWithoutDescription = this.props.media.find(item => !item.get('description'));
|
||||||
this.props.onMediaDescriptionConfirm(this.props.history || null, firstWithoutDescription.get('id'), overridePrivacy);
|
this.props.onMediaDescriptionConfirm(this.props.history || null, firstWithoutDescription.get('id'), overriddenVisibility);
|
||||||
} else {
|
} else {
|
||||||
this.props.onSubmit(this.props.history || null, overridePrivacy);
|
if (overriddenVisibility) {
|
||||||
|
this.props.onChangeVisibility(overriddenVisibility);
|
||||||
|
}
|
||||||
|
this.props.onSubmit(this.props.history || null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleSecondarySubmit = (e) => {
|
// Handles the secondary submit button.
|
||||||
|
handleSecondarySubmit = () => {
|
||||||
const { sideArm } = this.props;
|
const { sideArm } = this.props;
|
||||||
this.handleSubmit(e, sideArm === 'none' ? null : sideArm);
|
this.handleSubmit(null, sideArm === 'none' ? null : sideArm);
|
||||||
};
|
};
|
||||||
|
|
||||||
onSuggestionsClearRequested = () => {
|
onSuggestionsClearRequested = () => {
|
||||||
@ -219,6 +224,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
|
this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
|
||||||
this.textareaRef.current.focus();
|
this.textareaRef.current.focus();
|
||||||
|
if (!this.props.singleColumn) this.textareaRef.current.scrollIntoView();
|
||||||
this.setState({ highlighted: true });
|
this.setState({ highlighted: true });
|
||||||
this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
|
this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
@ -242,109 +248,103 @@ class ComposeForm extends ImmutablePureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleEmojiPick = (data) => {
|
handleEmojiPick = (data) => {
|
||||||
const { text } = this.props;
|
const position = this.textareaRef.current.selectionStart;
|
||||||
const position = this.textareaRef.current.selectionStart;
|
|
||||||
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
|
|
||||||
|
|
||||||
this.props.onPickEmoji(position, data, needsSpace);
|
this.props.onPickEmoji(position, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, onPaste, autoFocus, withoutNavigation, maxChars } = this.props;
|
const {
|
||||||
|
intl,
|
||||||
|
advancedOptions,
|
||||||
|
isSubmitting,
|
||||||
|
onChangeSpoilerness,
|
||||||
|
onPaste,
|
||||||
|
privacy,
|
||||||
|
sensitive,
|
||||||
|
showSearch,
|
||||||
|
sideArm,
|
||||||
|
spoilersAlwaysOn,
|
||||||
|
isEditing,
|
||||||
|
} = this.props;
|
||||||
const { highlighted } = this.state;
|
const { highlighted } = this.state;
|
||||||
const disabled = this.props.isSubmitting;
|
const disabled = this.props.isSubmitting;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className='compose-form' onSubmit={this.handleSubmit}>
|
<form className='compose-form' onSubmit={this.handleSubmit}>
|
||||||
<ReplyIndicator />
|
|
||||||
{!withoutNavigation && <NavigationBar />}
|
|
||||||
<WarningContainer />
|
<WarningContainer />
|
||||||
|
|
||||||
<div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
|
<ReplyIndicatorContainer />
|
||||||
<div className='compose-form__scrollable'>
|
|
||||||
<EditIndicator />
|
|
||||||
|
|
||||||
{this.props.spoiler && (
|
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef} aria-hidden={!this.props.spoiler}>
|
||||||
<div className='spoiler-input'>
|
<AutosuggestInput
|
||||||
<div className='spoiler-input__border' />
|
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
||||||
|
value={this.props.spoilerText}
|
||||||
|
onChange={this.handleChangeSpoilerText}
|
||||||
|
onKeyDown={this.handleKeyDown}
|
||||||
|
disabled={!this.props.spoiler}
|
||||||
|
ref={this.setSpoilerText}
|
||||||
|
suggestions={this.props.suggestions}
|
||||||
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||||
|
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||||
|
onSuggestionSelected={this.onSpoilerSuggestionSelected}
|
||||||
|
searchTokens={[':']}
|
||||||
|
id='cw-spoiler-input'
|
||||||
|
className='spoiler-input__input'
|
||||||
|
lang={this.props.lang}
|
||||||
|
autoFocus={false}
|
||||||
|
spellCheck
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AutosuggestInput
|
<div className={classNames('compose-form__highlightable', { active: highlighted })}>
|
||||||
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
<AutosuggestTextarea
|
||||||
value={this.props.spoilerText}
|
ref={this.textareaRef}
|
||||||
disabled={disabled}
|
placeholder={intl.formatMessage(messages.placeholder)}
|
||||||
onChange={this.handleChangeSpoilerText}
|
disabled={disabled}
|
||||||
onKeyDown={this.handleKeyDown}
|
value={this.props.text}
|
||||||
ref={this.setSpoilerText}
|
onChange={this.handleChange}
|
||||||
suggestions={this.props.suggestions}
|
suggestions={this.props.suggestions}
|
||||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
onFocus={this.handleFocus}
|
||||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
onKeyDown={this.handleKeyDown}
|
||||||
onSuggestionSelected={this.onSpoilerSuggestionSelected}
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||||
searchTokens={[':']}
|
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||||
id='cw-spoiler-input'
|
onSuggestionSelected={this.onSuggestionSelected}
|
||||||
className='spoiler-input__input'
|
onPaste={onPaste}
|
||||||
lang={this.props.lang}
|
autoFocus={!showSearch && !isMobile(window.innerWidth)}
|
||||||
spellCheck
|
lang={this.props.lang}
|
||||||
/>
|
>
|
||||||
|
<TextareaIcons advancedOptions={advancedOptions} />
|
||||||
<div className='spoiler-input__border' />
|
<div className='compose-form__modifiers'>
|
||||||
</div>
|
<UploadFormContainer />
|
||||||
)}
|
<PollFormContainer />
|
||||||
|
|
||||||
<AutosuggestTextarea
|
|
||||||
ref={this.textareaRef}
|
|
||||||
placeholder={intl.formatMessage(messages.placeholder)}
|
|
||||||
disabled={disabled}
|
|
||||||
value={this.props.text}
|
|
||||||
onChange={this.handleChange}
|
|
||||||
suggestions={this.props.suggestions}
|
|
||||||
onFocus={this.handleFocus}
|
|
||||||
onKeyDown={this.handleKeyDown}
|
|
||||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
|
||||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
|
||||||
onSuggestionSelected={this.onSuggestionSelected}
|
|
||||||
onPaste={onPaste}
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
lang={this.props.lang}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UploadFormContainer />
|
|
||||||
<PollForm />
|
|
||||||
|
|
||||||
<div className='compose-form__footer'>
|
|
||||||
<div className='compose-form__dropdowns'>
|
|
||||||
<PrivacyDropdownContainer disabled={this.props.isEditing} />
|
|
||||||
<LanguageDropdown />
|
|
||||||
</div>
|
</div>
|
||||||
|
</AutosuggestTextarea>
|
||||||
|
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
|
||||||
|
|
||||||
<div className='compose-form__actions'>
|
<div className='compose-form__buttons-wrapper'>
|
||||||
<div className='compose-form__buttons'>
|
<OptionsContainer
|
||||||
<UploadButtonContainer />
|
advancedOptions={advancedOptions}
|
||||||
<PollButtonContainer />
|
disabled={isSubmitting}
|
||||||
{!this.props.spoilerAlwaysOn && <SpoilerButtonContainer />}
|
onToggleSpoiler={this.props.spoilersAlwaysOn ? null : onChangeSpoilerness}
|
||||||
<ContentTypeButton />
|
onUpload={onPaste}
|
||||||
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
|
isEditing={isEditing}
|
||||||
<FederationButton />
|
sensitive={sensitive || (spoilersAlwaysOn && this.props.spoilerText && this.props.spoilerText.length > 0)}
|
||||||
<ThreadModeButton />
|
spoiler={spoilersAlwaysOn ? (this.props.spoilerText && this.props.spoilerText.length > 0) : this.props.spoiler}
|
||||||
<CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} />
|
/>
|
||||||
</div>
|
<div className='character-counter__wrapper'>
|
||||||
|
<CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} />
|
||||||
<div className='compose-form__submit'>
|
|
||||||
<SecondaryPrivacyButton
|
|
||||||
disabled={!this.canSubmit()}
|
|
||||||
privacy={this.props.sideArm}
|
|
||||||
isEditing={this.props.isEditing}
|
|
||||||
onClick={this.handleSecondarySubmit}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type='submit'
|
|
||||||
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
|
|
||||||
disabled={!this.canSubmit()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Publisher
|
||||||
|
disabled={!this.canSubmit()}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onSecondarySubmit={this.handleSecondarySubmit}
|
||||||
|
privacy={privacy}
|
||||||
|
sideArm={sideArm}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
import SmallCodeIcon from '@/material-icons/400-20px/code.svg?react';
|
|
||||||
import SmallDescriptionIcon from '@/material-icons/400-20px/description.svg?react';
|
|
||||||
import SmallMarkdownIcon from '@/material-icons/400-20px/markdown.svg?react';
|
|
||||||
import CodeIcon from '@/material-icons/400-24px/code.svg?react';
|
|
||||||
import DescriptionIcon from '@/material-icons/400-24px/description.svg?react';
|
|
||||||
import MarkdownIcon from '@/material-icons/400-24px/markdown.svg?react';
|
|
||||||
import { changeComposeContentType } from 'flavours/glitch/actions/compose';
|
|
||||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
|
||||||
|
|
||||||
import { DropdownIconButton } from './dropdown_icon_button';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
change_content_type: { id: 'compose.content-type.change', defaultMessage: 'Change advanced formatting options' },
|
|
||||||
plain_text_label: { id: 'compose.content-type.plain', defaultMessage: 'Plain text' },
|
|
||||||
plain_text_meta: { id: 'compose.content-type.plain_meta', defaultMessage: 'Write with no advanced formatting' },
|
|
||||||
markdown_label: { id: 'compose.content-type.markdown', defaultMessage: 'Markdown' },
|
|
||||||
markdown_meta: { id: 'compose.content-type.markdown_meta', defaultMessage: 'Format your posts using Markdown' },
|
|
||||||
html_label: { id: 'compose.content-type.html', defaultMessage: 'HTML' },
|
|
||||||
html_meta: { id: 'compose.content-type.html_meta', defaultMessage: 'Format your posts using HTML' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ContentTypeButton = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const showButton = useAppSelector((state) => state.getIn(['local_settings', 'show_content_type_choice']));
|
|
||||||
const contentType = useAppSelector((state) => state.getIn(['compose', 'content_type']));
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const handleChange = useCallback((value) => {
|
|
||||||
dispatch(changeComposeContentType(value));
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
if (!showButton) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
{ icon: 'file-text', iconComponent: DescriptionIcon, value: 'text/plain', text: intl.formatMessage(messages.plain_text_label), meta: intl.formatMessage(messages.plain_text_meta) },
|
|
||||||
{ icon: 'arrow-circle-down', iconComponent: MarkdownIcon, value: 'text/markdown', text: intl.formatMessage(messages.markdown_label), meta: intl.formatMessage(messages.markdown_meta) },
|
|
||||||
{ icon: 'code', iconComponent: CodeIcon, value: 'text/html', text: intl.formatMessage(messages.html_label), meta: intl.formatMessage(messages.html_meta) },
|
|
||||||
];
|
|
||||||
|
|
||||||
const icon = {
|
|
||||||
'text/plain': 'file-text',
|
|
||||||
'text/markdown': 'arrow-circle-down',
|
|
||||||
'text/html': 'code',
|
|
||||||
}[contentType];
|
|
||||||
|
|
||||||
const iconComponent = {
|
|
||||||
'text/plain': SmallDescriptionIcon,
|
|
||||||
'text/markdown': SmallMarkdownIcon,
|
|
||||||
'text/html': SmallCodeIcon,
|
|
||||||
}[contentType];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownIconButton
|
|
||||||
icon={icon}
|
|
||||||
iconComponent={iconComponent}
|
|
||||||
onChange={handleChange}
|
|
||||||
options={options}
|
|
||||||
title={intl.formatMessage(messages.change_content_type)}
|
|
||||||
value={contentType}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -0,0 +1,243 @@
|
|||||||
|
// Package imports.
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { PureComponent } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import Overlay from 'react-overlays/Overlay';
|
||||||
|
|
||||||
|
// Components.
|
||||||
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
|
|
||||||
|
import DropdownMenu from './dropdown_menu';
|
||||||
|
|
||||||
|
// The component.
|
||||||
|
export default class ComposerOptionsDropdown extends PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
isUserTouching: PropTypes.func,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
icon: PropTypes.string,
|
||||||
|
iconComponent: PropTypes.func,
|
||||||
|
items: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
icon: PropTypes.string,
|
||||||
|
iconComponent: PropTypes.func,
|
||||||
|
meta: PropTypes.string,
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
text: PropTypes.string,
|
||||||
|
})).isRequired,
|
||||||
|
onModalOpen: PropTypes.func,
|
||||||
|
onModalClose: PropTypes.func,
|
||||||
|
title: PropTypes.string,
|
||||||
|
value: PropTypes.string,
|
||||||
|
onChange: PropTypes.func,
|
||||||
|
container: PropTypes.func,
|
||||||
|
renderItemContents: PropTypes.func,
|
||||||
|
closeOnChange: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
closeOnChange: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
open: false,
|
||||||
|
openedViaKeyboard: undefined,
|
||||||
|
placement: 'bottom',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggles opening and closing the dropdown.
|
||||||
|
handleToggle = ({ type }) => {
|
||||||
|
const { onModalOpen } = this.props;
|
||||||
|
const { open } = this.state;
|
||||||
|
|
||||||
|
if (this.props.isUserTouching && this.props.isUserTouching()) {
|
||||||
|
if (open) {
|
||||||
|
this.props.onModalClose();
|
||||||
|
} else {
|
||||||
|
const modal = this.handleMakeModal();
|
||||||
|
if (modal && onModalOpen) {
|
||||||
|
onModalOpen(modal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (open && this.activeElement) {
|
||||||
|
this.activeElement.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
this.setState({ open: !open, openedViaKeyboard: type !== 'click' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleKeyDown = (e) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
this.handleClose();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleMouseDown = () => {
|
||||||
|
if (!this.state.open) {
|
||||||
|
this.activeElement = document.activeElement;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleButtonKeyDown = (e) => {
|
||||||
|
switch(e.key) {
|
||||||
|
case ' ':
|
||||||
|
case 'Enter':
|
||||||
|
this.handleMouseDown();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleKeyPress = (e) => {
|
||||||
|
switch(e.key) {
|
||||||
|
case ' ':
|
||||||
|
case 'Enter':
|
||||||
|
this.handleToggle(e);
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClose = () => {
|
||||||
|
if (this.state.open && this.activeElement) {
|
||||||
|
this.activeElement.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
this.setState({ open: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleItemClick = (e) => {
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
onChange,
|
||||||
|
onModalClose,
|
||||||
|
closeOnChange,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||||
|
|
||||||
|
const { name } = items[i];
|
||||||
|
|
||||||
|
e.preventDefault(); // Prevents focus from changing
|
||||||
|
if (closeOnChange) onModalClose();
|
||||||
|
onChange(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Creates an action modal object.
|
||||||
|
handleMakeModal = () => {
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
onChange,
|
||||||
|
onModalOpen,
|
||||||
|
onModalClose,
|
||||||
|
value,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
// Required props.
|
||||||
|
if (!(onChange && onModalOpen && onModalClose && items)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The object.
|
||||||
|
return {
|
||||||
|
renderItemContents: this.props.renderItemContents,
|
||||||
|
onClick: this.handleItemClick,
|
||||||
|
actions: items.map(
|
||||||
|
({
|
||||||
|
name,
|
||||||
|
...rest
|
||||||
|
}) => ({
|
||||||
|
...rest,
|
||||||
|
active: value && name === value,
|
||||||
|
name,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setTargetRef = c => {
|
||||||
|
this.target = c;
|
||||||
|
};
|
||||||
|
|
||||||
|
findTarget = () => {
|
||||||
|
return this.target;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleOverlayEnter = (state) => {
|
||||||
|
this.setState({ placement: state.placement });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rendering.
|
||||||
|
render () {
|
||||||
|
const {
|
||||||
|
disabled,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
iconComponent,
|
||||||
|
items,
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
container,
|
||||||
|
renderItemContents,
|
||||||
|
closeOnChange,
|
||||||
|
} = this.props;
|
||||||
|
const { open, placement } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames('privacy-dropdown', placement, { active: open })}
|
||||||
|
onKeyDown={this.handleKeyDown}
|
||||||
|
ref={this.setTargetRef}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
active={open}
|
||||||
|
className='privacy-dropdown__value-icon'
|
||||||
|
disabled={disabled}
|
||||||
|
icon={icon}
|
||||||
|
iconComponent={iconComponent}
|
||||||
|
inverted
|
||||||
|
onClick={this.handleToggle}
|
||||||
|
onMouseDown={this.handleMouseDown}
|
||||||
|
onKeyDown={this.handleButtonKeyDown}
|
||||||
|
onKeyPress={this.handleKeyPress}
|
||||||
|
size={18}
|
||||||
|
style={{
|
||||||
|
height: null,
|
||||||
|
lineHeight: '27px',
|
||||||
|
}}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Overlay
|
||||||
|
containerPadding={20}
|
||||||
|
placement={placement}
|
||||||
|
show={open}
|
||||||
|
flip
|
||||||
|
target={this.findTarget}
|
||||||
|
container={container}
|
||||||
|
popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}
|
||||||
|
>
|
||||||
|
{({ props, placement }) => (
|
||||||
|
<div {...props}>
|
||||||
|
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
|
||||||
|
<DropdownMenu
|
||||||
|
items={items}
|
||||||
|
renderItemContents={renderItemContents}
|
||||||
|
onChange={onChange}
|
||||||
|
onClose={this.handleClose}
|
||||||
|
value={value}
|
||||||
|
openedViaKeyboard={this.state.openedViaKeyboard}
|
||||||
|
closeOnChange={closeOnChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Overlay>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,78 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useCallback, useState, useRef } from 'react';
|
|
||||||
|
|
||||||
import Overlay from 'react-overlays/Overlay';
|
|
||||||
|
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
|
||||||
|
|
||||||
import { PrivacyDropdownMenu } from './privacy_dropdown_menu';
|
|
||||||
|
|
||||||
export const DropdownIconButton = ({ value, disabled, icon, onChange, iconComponent, title, options }) => {
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
|
|
||||||
const [activeElement, setActiveElement] = useState(null);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [placement, setPlacement] = useState('bottom');
|
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
|
||||||
if (open && activeElement) {
|
|
||||||
activeElement.focus({ preventScroll: true });
|
|
||||||
setActiveElement(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(!open);
|
|
||||||
}, [open, setOpen, activeElement, setActiveElement]);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
if (open && activeElement) {
|
|
||||||
activeElement.focus({ preventScroll: true });
|
|
||||||
setActiveElement(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
}, [open, setOpen, activeElement, setActiveElement]);
|
|
||||||
|
|
||||||
const handleOverlayEnter = useCallback((state) => {
|
|
||||||
setPlacement(state.placement);
|
|
||||||
}, [setPlacement]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef}>
|
|
||||||
<IconButton
|
|
||||||
disabled={disabled}
|
|
||||||
icon={icon}
|
|
||||||
onClick={handleToggle}
|
|
||||||
iconComponent={iconComponent}
|
|
||||||
title={title}
|
|
||||||
active={open}
|
|
||||||
size={18}
|
|
||||||
inverted
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Overlay show={open} offset={[5, 5]} placement={placement} flip target={containerRef} popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}>
|
|
||||||
{({ props, placement }) => (
|
|
||||||
<div {...props}>
|
|
||||||
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
|
|
||||||
<PrivacyDropdownMenu
|
|
||||||
items={options}
|
|
||||||
value={value}
|
|
||||||
onClose={handleClose}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Overlay>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
DropdownIconButton.propTypes = {
|
|
||||||
value: PropTypes.string.isRequired,
|
|
||||||
disabled: PropTypes.bool,
|
|
||||||
icon: PropTypes.string,
|
|
||||||
onChange: PropTypes.func.isRequired,
|
|
||||||
iconComponent: PropTypes.func.isRequired,
|
|
||||||
options: PropTypes.array.isRequired,
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
@ -0,0 +1,201 @@
|
|||||||
|
// Package imports.
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { PureComponent } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||||
|
|
||||||
|
// Components.
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
|
||||||
|
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
||||||
|
|
||||||
|
// The component.
|
||||||
|
export default class ComposerOptionsDropdownContent extends PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
items: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
icon: PropTypes.string,
|
||||||
|
iconComponent: PropTypes.func,
|
||||||
|
meta: PropTypes.node,
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
text: PropTypes.node,
|
||||||
|
})),
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
style: PropTypes.object,
|
||||||
|
value: PropTypes.string,
|
||||||
|
renderItemContents: PropTypes.func,
|
||||||
|
openedViaKeyboard: PropTypes.bool,
|
||||||
|
closeOnChange: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
style: {},
|
||||||
|
closeOnChange: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When the document is clicked elsewhere, we close the dropdown.
|
||||||
|
handleDocumentClick = (e) => {
|
||||||
|
if (this.node && !this.node.contains(e.target)) {
|
||||||
|
this.props.onClose();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stores our node in `this.node`.
|
||||||
|
setRef = (node) => {
|
||||||
|
this.node = node;
|
||||||
|
};
|
||||||
|
|
||||||
|
// On mounting, we add our listeners.
|
||||||
|
componentDidMount () {
|
||||||
|
document.addEventListener('click', this.handleDocumentClick, { capture: true });
|
||||||
|
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||||
|
if (this.focusedItem) {
|
||||||
|
this.focusedItem.focus({ preventScroll: true });
|
||||||
|
} else {
|
||||||
|
this.node.firstChild.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On unmounting, we remove our listeners.
|
||||||
|
componentWillUnmount () {
|
||||||
|
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
|
||||||
|
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClick = (e) => {
|
||||||
|
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||||
|
|
||||||
|
const {
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
closeOnChange,
|
||||||
|
items,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const { name } = items[i];
|
||||||
|
|
||||||
|
e.preventDefault(); // Prevents change in focus on click
|
||||||
|
if (closeOnChange) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
onChange(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle changes differently whether the dropdown is a list of options or actions
|
||||||
|
handleChange = (name) => {
|
||||||
|
if (this.props.value) {
|
||||||
|
this.props.onChange(name);
|
||||||
|
} else {
|
||||||
|
this.setState({ value: name });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleKeyDown = (e) => {
|
||||||
|
const index = Number(e.currentTarget.getAttribute('data-index'));
|
||||||
|
const { items } = this.props;
|
||||||
|
let element = null;
|
||||||
|
|
||||||
|
switch(e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
this.props.onClose();
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
this.handleClick(e);
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
element = this.node.childNodes[index + 1] || this.node.firstChild;
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
element = this.node.childNodes[index - 1] || this.node.lastChild;
|
||||||
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
if (e.shiftKey) {
|
||||||
|
element = this.node.childNodes[index - 1] || this.node.lastChild;
|
||||||
|
} else {
|
||||||
|
element = this.node.childNodes[index + 1] || this.node.firstChild;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
element = this.node.firstChild;
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
element = this.node.lastChild;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
this.handleChange(items[Number(element.getAttribute('data-index'))].name);
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setFocusRef = c => {
|
||||||
|
this.focusedItem = c;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderItem = (item, i) => {
|
||||||
|
const { name, icon, iconComponent, meta, text } = item;
|
||||||
|
|
||||||
|
const active = (name === (this.props.value || this.state.value));
|
||||||
|
|
||||||
|
const computedClass = classNames('privacy-dropdown__option', { active });
|
||||||
|
|
||||||
|
let contents = this.props.renderItemContents && this.props.renderItemContents(item, i);
|
||||||
|
|
||||||
|
if (!contents) {
|
||||||
|
contents = (
|
||||||
|
<>
|
||||||
|
{icon && <Icon className='icon' id={icon} icon={iconComponent} />}
|
||||||
|
|
||||||
|
<div className='privacy-dropdown__option__content'>
|
||||||
|
<strong>{text}</strong>
|
||||||
|
{meta}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={computedClass}
|
||||||
|
onClick={this.handleClick}
|
||||||
|
onKeyDown={this.handleKeyDown}
|
||||||
|
role='option'
|
||||||
|
aria-selected={active}
|
||||||
|
tabIndex={0}
|
||||||
|
key={name}
|
||||||
|
data-index={i}
|
||||||
|
ref={active ? this.setFocusRef : null}
|
||||||
|
>
|
||||||
|
{contents}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rendering.
|
||||||
|
render () {
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
style,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
// The result.
|
||||||
|
return (
|
||||||
|
<div style={{ ...style }} role='listbox' ref={this.setRef}>
|
||||||
|
{!!items && items.map((item, i) => this.renderItem(item, i))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,61 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
|
||||||
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
|
|
||||||
import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
|
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
|
||||||
import { Permalink } from 'flavours/glitch/components/permalink';
|
|
||||||
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export const EditIndicator = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const id = useSelector(state => state.getIn(['compose', 'id']));
|
|
||||||
const status = useSelector(state => state.getIn(['statuses', id]));
|
|
||||||
const account = useSelector(state => state.getIn(['accounts', status?.get('account')]));
|
|
||||||
|
|
||||||
const handleCancelClick = useCallback(() => {
|
|
||||||
dispatch(cancelReplyCompose());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
if (!status) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = { __html: status.get('contentHtml') };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='edit-indicator'>
|
|
||||||
<div className='edit-indicator__header'>
|
|
||||||
<div className='edit-indicator__display-name'>
|
|
||||||
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`}>@{account.get('acct')}</Permalink>
|
|
||||||
·
|
|
||||||
<Permalink href={status.get('url')} to={`/@${account.get('acct')}/${status.get('id')}`}><RelativeTimestamp timestamp={status.get('created_at')} /></Permalink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='edit-indicator__cancel'>
|
|
||||||
<IconButton title={intl.formatMessage(messages.cancel)} icon='times' iconComponent={CloseIcon} onClick={handleCancelClick} inverted />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='edit-indicator__content translate' dangerouslySetInnerHTML={content} />
|
|
||||||
|
|
||||||
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
|
||||||
<div className='edit-indicator__attachments'>
|
|
||||||
{status.get('poll') && <><Icon icon={BarChart4BarsIcon} /><FormattedMessage id='reply_indicator.poll' defaultMessage='Poll' /></>}
|
|
||||||
{status.get('media_attachments').size > 0 && <><Icon icon={PhotoLibraryIcon} /><FormattedMessage id='reply_indicator.attachments' defaultMessage='{count, plural, one {# attachment} other {# attachments}}' values={{ count: status.get('media_attachments').size }} /></>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -10,8 +10,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||||
import Overlay from 'react-overlays/Overlay';
|
import Overlay from 'react-overlays/Overlay';
|
||||||
|
|
||||||
import MoodIcon from '@/material-icons/400-20px/mood.svg?react';
|
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
|
||||||
import { useSystemEmojiFont } from 'flavours/glitch/initial_state';
|
import { useSystemEmojiFont } from 'flavours/glitch/initial_state';
|
||||||
import { assetHost } from 'flavours/glitch/utils/config';
|
import { assetHost } from 'flavours/glitch/utils/config';
|
||||||
|
|
||||||
@ -165,7 +163,6 @@ class EmojiPickerMenuImpl extends PureComponent {
|
|||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
skinTone: PropTypes.number.isRequired,
|
skinTone: PropTypes.number.isRequired,
|
||||||
onSkinTone: PropTypes.func.isRequired,
|
onSkinTone: PropTypes.func.isRequired,
|
||||||
pickerButtonRef: PropTypes.func.isRequired
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
@ -180,7 +177,7 @@ class EmojiPickerMenuImpl extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleDocumentClick = e => {
|
handleDocumentClick = e => {
|
||||||
if (this.node && !this.node.contains(e.target) && !this.props.pickerButtonRef.contains(e.target)) {
|
if (this.node && !this.node.contains(e.target)) {
|
||||||
this.props.onClose();
|
this.props.onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -235,7 +232,6 @@ class EmojiPickerMenuImpl extends PureComponent {
|
|||||||
emoji.native = emoji.colons;
|
emoji.native = emoji.colons;
|
||||||
}
|
}
|
||||||
if (!(event.ctrlKey || event.metaKey)) {
|
if (!(event.ctrlKey || event.metaKey)) {
|
||||||
|
|
||||||
this.props.onClose();
|
this.props.onClose();
|
||||||
}
|
}
|
||||||
this.props.onPick(emoji);
|
this.props.onPick(emoji);
|
||||||
@ -327,6 +323,7 @@ class EmojiPickerDropdown extends PureComponent {
|
|||||||
onPickEmoji: PropTypes.func.isRequired,
|
onPickEmoji: PropTypes.func.isRequired,
|
||||||
onSkinTone: PropTypes.func.isRequired,
|
onSkinTone: PropTypes.func.isRequired,
|
||||||
skinTone: PropTypes.number.isRequired,
|
skinTone: PropTypes.number.isRequired,
|
||||||
|
button: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
@ -384,24 +381,23 @@ class EmojiPickerDropdown extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
|
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
|
||||||
const title = intl.formatMessage(messages.emoji);
|
const title = intl.formatMessage(messages.emoji);
|
||||||
const { active, loading } = this.state;
|
const { active, loading } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown} ref={this.setTargetRef}>
|
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
|
||||||
<IconButton
|
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
|
||||||
title={title}
|
{button || <img
|
||||||
aria-expanded={active}
|
className={classNames('emojione', { 'pulse-loading': active && loading })}
|
||||||
active={active}
|
alt='🙂'
|
||||||
iconComponent={MoodIcon}
|
src={`${assetHost}/emoji/1f642.svg`}
|
||||||
onClick={this.onToggle}
|
/>}
|
||||||
inverted
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
<Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
|
<Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
|
||||||
{({ props, placement })=> (
|
{({ props, placement })=> (
|
||||||
<div {...props} style={{ ...props.style }}>
|
<div {...props} style={{ ...props.style, width: 299 }}>
|
||||||
<div className={`dropdown-animation ${placement}`}>
|
<div className={`dropdown-animation ${placement}`}>
|
||||||
<EmojiPickerMenu
|
<EmojiPickerMenu
|
||||||
custom_emojis={this.props.custom_emojis}
|
custom_emojis={this.props.custom_emojis}
|
||||||
@ -411,7 +407,6 @@ class EmojiPickerDropdown extends PureComponent {
|
|||||||
onSkinTone={onSkinTone}
|
onSkinTone={onSkinTone}
|
||||||
skinTone={skinTone}
|
skinTone={skinTone}
|
||||||
frequentlyUsedEmojis={frequentlyUsedEmojis}
|
frequentlyUsedEmojis={frequentlyUsedEmojis}
|
||||||
pickerButtonRef={this.target}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
import SmallShareIcon from '@/material-icons/400-20px/share.svg?react';
|
|
||||||
import SmallShareOffIcon from '@/material-icons/400-20px/share_off.svg?react';
|
|
||||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
|
||||||
import ShareOffIcon from '@/material-icons/400-24px/share_off.svg?react';
|
|
||||||
import { changeComposeAdvancedOption } from 'flavours/glitch/actions/compose';
|
|
||||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
|
||||||
|
|
||||||
import { DropdownIconButton } from './dropdown_icon_button';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
change_federation_settings: { id: 'compose.change_federation', defaultMessage: 'Change federation settings' },
|
|
||||||
local_only_label: { id: 'federation.local_only.short', defaultMessage: 'Local-only' },
|
|
||||||
local_only_meta: { id: 'federation.local_only.long', defaultMessage: 'Prevent this post from reaching other servers' },
|
|
||||||
federated_label: { id: 'federation.federated.short', defaultMessage: 'Federated' },
|
|
||||||
federated_meta: { id: 'federation.federated.long', defaultMessage: 'Allow this post to reach other servers' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export const FederationButton = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const isEditing = useAppSelector((state) => state.getIn(['compose', 'id']) !== null);
|
|
||||||
const do_not_federate = useAppSelector((state) => state.getIn(['compose', 'advanced_options', 'do_not_federate']));
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const handleChange = useCallback((value) => {
|
|
||||||
dispatch(changeComposeAdvancedOption('do_not_federate', value === 'local-only'));
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
{ icon: 'link', iconComponent: ShareIcon, value: 'federated', text: intl.formatMessage(messages.federated_label), meta: intl.formatMessage(messages.federated_meta) },
|
|
||||||
{ icon: 'link-slash', iconComponent: ShareOffIcon, value: 'local-only', text: intl.formatMessage(messages.local_only_label), meta: intl.formatMessage(messages.local_only_meta) },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownIconButton
|
|
||||||
disabled={isEditing}
|
|
||||||
icon={do_not_federate ? 'link-slash' : 'link'}
|
|
||||||
iconComponent={do_not_federate ? SmallShareOffIcon : SmallShareIcon}
|
|
||||||
onChange={handleChange}
|
|
||||||
options={options}
|
|
||||||
title={intl.formatMessage(messages.change_federation_settings)}
|
|
||||||
value={do_not_federate ? 'local-only' : 'federated'}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -0,0 +1,149 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { injectIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
|
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||||
|
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||||
|
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
|
||||||
|
import ManufacturingIcon from '@/material-icons/400-24px/manufacturing.svg?react';
|
||||||
|
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
|
||||||
|
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
||||||
|
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
import { signOutLink } from 'flavours/glitch/utils/backend_links';
|
||||||
|
import { conditionalRender } from 'flavours/glitch/utils/react_helpers';
|
||||||
|
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
class Header extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
columns: ImmutablePropTypes.list,
|
||||||
|
unreadNotifications: PropTypes.number,
|
||||||
|
showNotificationsBadge: PropTypes.bool,
|
||||||
|
intl: PropTypes.object,
|
||||||
|
onSettingsClick: PropTypes.func,
|
||||||
|
onLogout: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleLogoutClick = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
this.props.onLogout();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
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'
|
||||||
|
className='drawer__tab'
|
||||||
|
><Icon id='bars' icon={MenuIcon} /></Link>
|
||||||
|
{renderForColumn('HOME', (
|
||||||
|
<Link
|
||||||
|
aria-label={intl.formatMessage(messages.home_timeline)}
|
||||||
|
title={intl.formatMessage(messages.home_timeline)}
|
||||||
|
to='/home'
|
||||||
|
className='drawer__tab'
|
||||||
|
><Icon id='home' icon={HomeIcon} /></Link>
|
||||||
|
))}
|
||||||
|
{renderForColumn('NOTIFICATIONS', (
|
||||||
|
<Link
|
||||||
|
aria-label={intl.formatMessage(messages.notifications)}
|
||||||
|
title={intl.formatMessage(messages.notifications)}
|
||||||
|
to='/notifications'
|
||||||
|
className='drawer__tab'
|
||||||
|
>
|
||||||
|
<span className='icon-badge-wrapper'>
|
||||||
|
<Icon id='bell' icon={NotificationsIcon} />
|
||||||
|
{ showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{renderForColumn('COMMUNITY', (
|
||||||
|
<Link
|
||||||
|
aria-label={intl.formatMessage(messages.community)}
|
||||||
|
title={intl.formatMessage(messages.community)}
|
||||||
|
to='/public/local'
|
||||||
|
className='drawer__tab'
|
||||||
|
><Icon id='users' icon={PeopleIcon} /></Link>
|
||||||
|
))}
|
||||||
|
{renderForColumn('PUBLIC', (
|
||||||
|
<Link
|
||||||
|
aria-label={intl.formatMessage(messages.public)}
|
||||||
|
title={intl.formatMessage(messages.public)}
|
||||||
|
to='/public'
|
||||||
|
className='drawer__tab'
|
||||||
|
><Icon id='globe' icon={PublicIcon} /></Link>
|
||||||
|
))}
|
||||||
|
<a
|
||||||
|
aria-label={intl.formatMessage(messages.settings)}
|
||||||
|
onClick={onSettingsClick}
|
||||||
|
href='/settings/preferences'
|
||||||
|
title={intl.formatMessage(messages.settings)}
|
||||||
|
className='drawer__tab'
|
||||||
|
><Icon id='cogs' icon={ManufacturingIcon} /></a>
|
||||||
|
<a
|
||||||
|
aria-label={intl.formatMessage(messages.logout)}
|
||||||
|
onClick={this.handleLogoutClick}
|
||||||
|
href={signOutLink}
|
||||||
|
title={intl.formatMessage(messages.logout)}
|
||||||
|
className='drawer__tab'
|
||||||
|
><Icon id='sign-out' icon={LogoutIcon} /></a>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default injectIntl(Header);
|
||||||
@ -9,11 +9,10 @@ import { supportsPassiveEvents } from 'detect-passive-events';
|
|||||||
import fuzzysort from 'fuzzysort';
|
import fuzzysort from 'fuzzysort';
|
||||||
import Overlay from 'react-overlays/Overlay';
|
import Overlay from 'react-overlays/Overlay';
|
||||||
|
|
||||||
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
|
|
||||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
|
||||||
import TranslateIcon from '@/material-icons/400-24px/translate.svg?react';
|
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
|
||||||
import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
|
import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
|
||||||
|
import { loupeIcon, deleteIcon } from 'flavours/glitch/utils/icons';
|
||||||
|
|
||||||
|
import TextIconButton from './text_icon_button';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' },
|
changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' },
|
||||||
@ -141,7 +140,6 @@ class LanguageDropdownMenu extends PureComponent {
|
|||||||
case 'Escape':
|
case 'Escape':
|
||||||
onClose();
|
onClose();
|
||||||
break;
|
break;
|
||||||
case ' ':
|
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
this.handleClick(e);
|
this.handleClick(e);
|
||||||
break;
|
break;
|
||||||
@ -233,7 +231,7 @@ class LanguageDropdownMenu extends PureComponent {
|
|||||||
<div ref={this.setRef}>
|
<div ref={this.setRef}>
|
||||||
<div className='emoji-mart-search'>
|
<div className='emoji-mart-search'>
|
||||||
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
|
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
|
||||||
<button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}><Icon icon={!isSearching ? SearchIcon : CancelIcon} /></button>
|
<button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
|
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
|
||||||
@ -299,24 +297,20 @@ class LanguageDropdown extends PureComponent {
|
|||||||
render () {
|
render () {
|
||||||
const { value, intl, frequentlyUsedLanguages } = this.props;
|
const { value, intl, frequentlyUsedLanguages } = this.props;
|
||||||
const { open, placement } = this.state;
|
const { open, placement } = this.state;
|
||||||
const current = preloadedLanguages.find(lang => lang[0] === value) ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
|
<div className={classNames('privacy-dropdown', placement, { active: open })}>
|
||||||
<button
|
<div className='privacy-dropdown__value' ref={this.setTargetRef} >
|
||||||
type='button'
|
<TextIconButton
|
||||||
title={intl.formatMessage(messages.changeLanguage)}
|
className='privacy-dropdown__value-icon'
|
||||||
aria-expanded={open}
|
label={value && value.toUpperCase()}
|
||||||
onClick={this.handleToggle}
|
title={intl.formatMessage(messages.changeLanguage)}
|
||||||
onMouseDown={this.handleMouseDown}
|
active={open}
|
||||||
onKeyDown={this.handleButtonKeyDown}
|
onClick={this.handleToggle}
|
||||||
className={classNames('dropdown-button', { active: open })}
|
/>
|
||||||
>
|
</div>
|
||||||
<Icon icon={TranslateIcon} />
|
|
||||||
<span className='dropdown-button__label'>{current[2] ?? value}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Overlay show={open} offset={[5, 5]} placement={placement} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
|
<Overlay show={open} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
|
||||||
{({ props, placement }) => (
|
{({ props, placement }) => (
|
||||||
<div {...props}>
|
<div {...props}>
|
||||||
<div className={`dropdown-animation language-dropdown__dropdown ${placement}`} >
|
<div className={`dropdown-animation language-dropdown__dropdown ${placement}`} >
|
||||||
|
|||||||
@ -1,36 +1,54 @@
|
|||||||
import { useCallback } from 'react';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import { Permalink } from 'flavours/glitch/components/permalink';
|
||||||
import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
|
import { profileLink } from 'flavours/glitch/utils/backend_links';
|
||||||
import Account from 'flavours/glitch/components/account';
|
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
|
||||||
import { me } from 'flavours/glitch/initial_state';
|
|
||||||
|
|
||||||
import { ActionBar } from './action_bar';
|
import { Avatar } from '../../../components/avatar';
|
||||||
|
|
||||||
|
import ActionBar from './action_bar';
|
||||||
|
|
||||||
const messages = defineMessages({
|
export default class NavigationBar extends ImmutablePureComponent {
|
||||||
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export const NavigationBar = () => {
|
static propTypes = {
|
||||||
const dispatch = useDispatch();
|
account: ImmutablePropTypes.record.isRequired,
|
||||||
const intl = useIntl();
|
onLogout: PropTypes.func.isRequired,
|
||||||
const account = useSelector(state => state.getIn(['accounts', me]));
|
onClose: PropTypes.func,
|
||||||
const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to']));
|
};
|
||||||
|
|
||||||
const handleCancelClick = useCallback(() => {
|
render () {
|
||||||
dispatch(cancelReplyCompose());
|
const username = this.props.account.get('acct');
|
||||||
}, [dispatch]);
|
return (
|
||||||
|
<div className='navigation-bar'>
|
||||||
|
<Permalink className='avatar' href={this.props.account.get('url')} to={`/@${username}`}>
|
||||||
|
<span style={{ display: 'none' }}>{username}</span>
|
||||||
|
<Avatar account={this.props.account} size={46} />
|
||||||
|
</Permalink>
|
||||||
|
|
||||||
return (
|
<div className='navigation-bar__profile'>
|
||||||
<div className='navigation-bar'>
|
<span>
|
||||||
<Account account={account} minimal />
|
<Permalink className='acct' href={this.props.account.get('url')} to={`/@${username}`}>
|
||||||
{isReplying ? <IconButton title={intl.formatMessage(messages.cancel)} iconComponent={CloseIcon} onClick={handleCancelClick} /> : <ActionBar />}
|
<strong className='navigation-bar__profile-account'>@{username}</strong>
|
||||||
</div>
|
</Permalink>
|
||||||
);
|
</span>
|
||||||
};
|
|
||||||
|
{ profileLink !== undefined && (
|
||||||
|
<a
|
||||||
|
href={profileLink}
|
||||||
|
className='navigation-bar__profile-edit'
|
||||||
|
><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='navigation-bar__actions'>
|
||||||
|
<ActionBar account={this.props.account} onLogout={this.props.onLogout} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,328 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import Toggle from 'react-toggle';
|
||||||
|
|
||||||
|
import AttachFileIcon from '@/material-icons/400-24px/attach_file.svg?react';
|
||||||
|
import BrushIcon from '@/material-icons/400-24px/brush.svg?react';
|
||||||
|
import CodeIcon from '@/material-icons/400-24px/code.svg?react';
|
||||||
|
import DescriptionIcon from '@/material-icons/400-24px/description.svg?react';
|
||||||
|
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||||
|
import MarkdownIcon from '@/material-icons/400-24px/markdown.svg?react';
|
||||||
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
|
import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react';
|
||||||
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
|
import { pollLimits } from 'flavours/glitch/initial_state';
|
||||||
|
|
||||||
|
|
||||||
|
import DropdownContainer from '../containers/dropdown_container';
|
||||||
|
import LanguageDropdown from '../containers/language_dropdown_container';
|
||||||
|
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||||
|
|
||||||
|
import TextIconButton from './text_icon_button';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
advanced_options_icon_title: {
|
||||||
|
defaultMessage: 'Advanced options',
|
||||||
|
id: 'advanced_options.icon_title',
|
||||||
|
},
|
||||||
|
attach: {
|
||||||
|
defaultMessage: 'Attach...',
|
||||||
|
id: 'compose.attach',
|
||||||
|
},
|
||||||
|
content_type: {
|
||||||
|
defaultMessage: 'Content type',
|
||||||
|
id: 'content-type.change',
|
||||||
|
},
|
||||||
|
doodle: {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
local_only_short: {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
spoiler: {
|
||||||
|
defaultMessage: 'Hide text behind warning',
|
||||||
|
id: 'compose_form.spoiler',
|
||||||
|
},
|
||||||
|
threaded_mode_long: {
|
||||||
|
defaultMessage: 'Automatically opens a reply on posting',
|
||||||
|
id: 'advanced_options.threaded_mode.long',
|
||||||
|
},
|
||||||
|
threaded_mode_short: {
|
||||||
|
defaultMessage: 'Threaded mode',
|
||||||
|
id: 'advanced_options.threaded_mode.short',
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
defaultMessage: 'Upload a file',
|
||||||
|
id: 'compose.attach.upload',
|
||||||
|
},
|
||||||
|
add_poll: {
|
||||||
|
defaultMessage: 'Add a poll',
|
||||||
|
id: 'poll_button.add_poll',
|
||||||
|
},
|
||||||
|
remove_poll: {
|
||||||
|
defaultMessage: 'Remove poll',
|
||||||
|
id: 'poll_button.remove_poll',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapStateToProps = (state, { name }) => ({
|
||||||
|
checked: state.getIn(['compose', 'advanced_options', name]),
|
||||||
|
});
|
||||||
|
|
||||||
|
class ToggleOptionImpl extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
checked: PropTypes.bool,
|
||||||
|
onChangeAdvancedOption: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleChange = () => {
|
||||||
|
this.props.onChangeAdvancedOption(this.props.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { meta, text, checked } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toggle checked={checked} onChange={this.handleChange} />
|
||||||
|
|
||||||
|
<div className='privacy-dropdown__option__content'>
|
||||||
|
<strong>{text}</strong>
|
||||||
|
{meta}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToggleOption = connect(mapStateToProps)(ToggleOptionImpl);
|
||||||
|
|
||||||
|
class ComposerOptions extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
acceptContentTypes: PropTypes.string,
|
||||||
|
advancedOptions: ImmutablePropTypes.map,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
allowMedia: PropTypes.bool,
|
||||||
|
allowPoll: PropTypes.bool,
|
||||||
|
hasPoll: PropTypes.bool,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
onChangeAdvancedOption: PropTypes.func.isRequired,
|
||||||
|
onChangeContentType: PropTypes.func.isRequired,
|
||||||
|
onTogglePoll: PropTypes.func.isRequired,
|
||||||
|
onDoodleOpen: PropTypes.func.isRequired,
|
||||||
|
onToggleSpoiler: PropTypes.func,
|
||||||
|
onUpload: PropTypes.func.isRequired,
|
||||||
|
contentType: PropTypes.string,
|
||||||
|
resetFileKey: PropTypes.number,
|
||||||
|
spoiler: PropTypes.bool,
|
||||||
|
showContentTypeChoice: PropTypes.bool,
|
||||||
|
isEditing: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleChangeFiles = ({ target: { files } }) => {
|
||||||
|
const { onUpload } = this.props;
|
||||||
|
if (files.length) {
|
||||||
|
onUpload(files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClickAttach = (name) => {
|
||||||
|
const { fileElement } = this;
|
||||||
|
const { onDoodleOpen } = this.props;
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'upload':
|
||||||
|
if (fileElement) {
|
||||||
|
fileElement.click();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'doodle':
|
||||||
|
onDoodleOpen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleRefFileElement = (fileElement) => {
|
||||||
|
this.fileElement = fileElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderToggleItemContents = (item) => {
|
||||||
|
const { onChangeAdvancedOption } = this.props;
|
||||||
|
const { name, meta, text } = item;
|
||||||
|
|
||||||
|
return <ToggleOption name={name} text={text} meta={meta} onChangeAdvancedOption={onChangeAdvancedOption} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const {
|
||||||
|
acceptContentTypes,
|
||||||
|
advancedOptions,
|
||||||
|
contentType,
|
||||||
|
disabled,
|
||||||
|
allowMedia,
|
||||||
|
allowPoll,
|
||||||
|
hasPoll,
|
||||||
|
onChangeAdvancedOption,
|
||||||
|
onChangeContentType,
|
||||||
|
onTogglePoll,
|
||||||
|
onToggleSpoiler,
|
||||||
|
resetFileKey,
|
||||||
|
spoiler,
|
||||||
|
showContentTypeChoice,
|
||||||
|
isEditing,
|
||||||
|
intl: { formatMessage },
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const contentTypeItems = {
|
||||||
|
plain: {
|
||||||
|
icon: 'file-text',
|
||||||
|
iconComponent: DescriptionIcon,
|
||||||
|
name: 'text/plain',
|
||||||
|
text: formatMessage(messages.plain),
|
||||||
|
},
|
||||||
|
html: {
|
||||||
|
icon: 'code',
|
||||||
|
iconComponent: CodeIcon,
|
||||||
|
name: 'text/html',
|
||||||
|
text: formatMessage(messages.html),
|
||||||
|
},
|
||||||
|
markdown: {
|
||||||
|
icon: 'arrow-circle-down',
|
||||||
|
iconComponent: MarkdownIcon,
|
||||||
|
name: 'text/markdown',
|
||||||
|
text: formatMessage(messages.markdown),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// The result.
|
||||||
|
return (
|
||||||
|
<div className='compose-form__buttons'>
|
||||||
|
<input
|
||||||
|
accept={acceptContentTypes}
|
||||||
|
disabled={disabled || !allowMedia}
|
||||||
|
key={resetFileKey}
|
||||||
|
onChange={this.handleChangeFiles}
|
||||||
|
ref={this.handleRefFileElement}
|
||||||
|
type='file'
|
||||||
|
multiple
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<DropdownContainer
|
||||||
|
disabled={disabled || !allowMedia}
|
||||||
|
icon='paperclip'
|
||||||
|
iconComponent={AttachFileIcon}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
icon: 'cloud-upload',
|
||||||
|
iconComponent: UploadFileIcon,
|
||||||
|
name: 'upload',
|
||||||
|
text: formatMessage(messages.upload),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'paint-brush',
|
||||||
|
iconComponent: BrushIcon,
|
||||||
|
name: 'doodle',
|
||||||
|
text: formatMessage(messages.doodle),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={this.handleClickAttach}
|
||||||
|
title={formatMessage(messages.attach)}
|
||||||
|
/>
|
||||||
|
{!!pollLimits && (
|
||||||
|
<IconButton
|
||||||
|
active={hasPoll}
|
||||||
|
disabled={disabled || !allowPoll}
|
||||||
|
icon='tasks'
|
||||||
|
iconComponent={InsertChartIcon}
|
||||||
|
inverted
|
||||||
|
onClick={onTogglePoll}
|
||||||
|
size={18}
|
||||||
|
style={{
|
||||||
|
height: null,
|
||||||
|
lineHeight: null,
|
||||||
|
}}
|
||||||
|
title={formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<PrivacyDropdownContainer disabled={disabled || isEditing} />
|
||||||
|
{showContentTypeChoice && (
|
||||||
|
<DropdownContainer
|
||||||
|
disabled={disabled}
|
||||||
|
icon={(contentTypeItems[contentType.split('/')[1]] || {}).icon}
|
||||||
|
iconComponent={(contentTypeItems[contentType.split('/')[1]] || {}).iconComponent}
|
||||||
|
items={[
|
||||||
|
contentTypeItems.plain,
|
||||||
|
contentTypeItems.html,
|
||||||
|
contentTypeItems.markdown,
|
||||||
|
]}
|
||||||
|
onChange={onChangeContentType}
|
||||||
|
title={formatMessage(messages.content_type)}
|
||||||
|
value={contentType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onToggleSpoiler && (
|
||||||
|
<TextIconButton
|
||||||
|
active={spoiler}
|
||||||
|
ariaControls='cw-spoiler-input'
|
||||||
|
label='CW'
|
||||||
|
onClick={onToggleSpoiler}
|
||||||
|
title={formatMessage(messages.spoiler)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<LanguageDropdown />
|
||||||
|
<DropdownContainer
|
||||||
|
disabled={disabled || isEditing}
|
||||||
|
icon='ellipsis-h'
|
||||||
|
iconComponent={MoreHorizIcon}
|
||||||
|
items={advancedOptions ? [
|
||||||
|
{
|
||||||
|
meta: formatMessage(messages.local_only_long),
|
||||||
|
name: 'do_not_federate',
|
||||||
|
text: formatMessage(messages.local_only_short),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
meta: formatMessage(messages.threaded_mode_long),
|
||||||
|
name: 'threaded_mode',
|
||||||
|
text: formatMessage(messages.threaded_mode_short),
|
||||||
|
},
|
||||||
|
] : null}
|
||||||
|
onChange={onChangeAdvancedOption}
|
||||||
|
renderItemContents={this.renderToggleItemContents}
|
||||||
|
title={formatMessage(messages.advanced_options_icon_title)}
|
||||||
|
closeOnChange={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default injectIntl(ComposerOptions);
|
||||||
@ -1,55 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import BarChart4BarsIcon from '@/material-icons/400-20px/bar_chart_4_bars.svg?react';
|
|
||||||
|
|
||||||
import { IconButton } from '../../../components/icon_button';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
add_poll: { id: 'poll_button.add_poll', defaultMessage: 'Add a poll' },
|
|
||||||
remove_poll: { id: 'poll_button.remove_poll', defaultMessage: 'Remove poll' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const iconStyle = {
|
|
||||||
height: null,
|
|
||||||
lineHeight: '27px',
|
|
||||||
};
|
|
||||||
|
|
||||||
class PollButton extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
disabled: PropTypes.bool,
|
|
||||||
active: PropTypes.bool,
|
|
||||||
onClick: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleClick = () => {
|
|
||||||
this.props.onClick();
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { intl, active, disabled } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='compose-form__poll-button'>
|
|
||||||
<IconButton
|
|
||||||
icon='tasks'
|
|
||||||
iconComponent={BarChart4BarsIcon}
|
|
||||||
title={intl.formatMessage(active ? messages.remove_poll : messages.add_poll)}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={this.handleClick}
|
|
||||||
className={`compose-form__poll-button-icon ${active ? 'active' : ''}`}
|
|
||||||
size={18}
|
|
||||||
inverted
|
|
||||||
style={iconStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(PollButton);
|
|
||||||
@ -1,161 +1,179 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { useCallback } from 'react';
|
import { PureComponent } from 'react';
|
||||||
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
import {
|
|
||||||
changePollSettings,
|
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||||
changePollOption,
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
clearComposeSuggestions,
|
|
||||||
fetchComposeSuggestions,
|
|
||||||
selectComposeSuggestion,
|
|
||||||
} from 'flavours/glitch/actions/compose';
|
|
||||||
import AutosuggestInput from 'flavours/glitch/components/autosuggest_input';
|
import AutosuggestInput from 'flavours/glitch/components/autosuggest_input';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
import { pollLimits } from 'flavours/glitch/initial_state';
|
import { pollLimits } from 'flavours/glitch/initial_state';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Option {number}' },
|
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' },
|
||||||
duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll length' },
|
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' },
|
||||||
type: { id: 'compose_form.poll.type', defaultMessage: 'Style' },
|
remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' },
|
||||||
switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' },
|
poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' },
|
||||||
switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' },
|
single_choice: { id: 'compose_form.poll.single_choice', defaultMessage: 'Allow one choice' },
|
||||||
|
multiple_choices: { id: 'compose_form.poll.multiple_choices', defaultMessage: 'Allow multiple choices' },
|
||||||
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
|
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
|
||||||
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
|
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
|
||||||
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
|
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
|
||||||
singleChoice: { id: 'compose_form.poll.single', defaultMessage: 'Pick one' },
|
|
||||||
multipleChoice: { id: 'compose_form.poll.multiple', defaultMessage: 'Multiple choice' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const Select = ({ label, options, value, onChange }) => {
|
class OptionIntl extends PureComponent {
|
||||||
return (
|
|
||||||
<label className='compose-form__poll__select'>
|
|
||||||
<span className='compose-form__poll__select__label'>{label}</span>
|
|
||||||
|
|
||||||
<select className='compose-form__poll__select__value' value={value} onChange={onChange}>
|
static propTypes = {
|
||||||
{options.map((option, i) => (
|
title: PropTypes.string.isRequired,
|
||||||
<option key={i} value={option.value}>{option.label}</option>
|
lang: PropTypes.string,
|
||||||
))}
|
index: PropTypes.number.isRequired,
|
||||||
</select>
|
isPollMultiple: PropTypes.bool,
|
||||||
</label>
|
autoFocus: 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,
|
||||||
|
};
|
||||||
|
|
||||||
Select.propTypes = {
|
handleOptionTitleChange = e => {
|
||||||
label: PropTypes.node,
|
this.props.onChange(this.props.index, e.target.value);
|
||||||
value: PropTypes.any,
|
};
|
||||||
onChange: PropTypes.func,
|
|
||||||
options: PropTypes.arrayOf(PropTypes.shape({
|
|
||||||
label: PropTypes.node,
|
|
||||||
value: PropTypes.any,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const Option = ({ multipleChoice, index, title, autoFocus }) => {
|
handleOptionRemove = () => {
|
||||||
const intl = useIntl();
|
this.props.onRemove(this.props.index);
|
||||||
const dispatch = useDispatch();
|
};
|
||||||
const suggestions = useSelector(state => state.getIn(['compose', 'suggestions']));
|
|
||||||
const lang = useSelector(state => state.getIn(['compose', 'language']));
|
|
||||||
|
|
||||||
const handleChange = useCallback(({ target: { value } }) => {
|
onSuggestionsClearRequested = () => {
|
||||||
dispatch(changePollOption(index, value));
|
this.props.onClearSuggestions();
|
||||||
}, [dispatch, index]);
|
};
|
||||||
|
|
||||||
const handleSuggestionsFetchRequested = useCallback(token => {
|
onSuggestionsFetchRequested = (token) => {
|
||||||
dispatch(fetchComposeSuggestions(token));
|
this.props.onFetchSuggestions(token);
|
||||||
}, [dispatch]);
|
};
|
||||||
|
|
||||||
const handleSuggestionsClearRequested = useCallback(() => {
|
onSuggestionSelected = (tokenStart, token, value) => {
|
||||||
dispatch(clearComposeSuggestions());
|
this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]);
|
||||||
}, [dispatch]);
|
};
|
||||||
|
|
||||||
const handleSuggestionSelected = useCallback((tokenStart, token, value) => {
|
render () {
|
||||||
dispatch(selectComposeSuggestion(tokenStart, token, value, ['poll', 'options', index]));
|
const { isPollMultiple, title, lang, index, autoFocus, intl } = this.props;
|
||||||
}, [dispatch, index]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className={classNames('poll__option editable', { empty: index > 1 && title.length === 0 })}>
|
<li>
|
||||||
<span className={classNames('poll__input', { checkbox: multipleChoice })} />
|
<label className='poll__option editable'>
|
||||||
|
<span className={classNames('poll__input', { checkbox: isPollMultiple })} />
|
||||||
|
|
||||||
<AutosuggestInput
|
<AutosuggestInput
|
||||||
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
|
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
|
||||||
maxLength={pollLimits.max_option_chars}
|
maxLength={pollLimits.max_option_chars}
|
||||||
value={title}
|
value={title}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
spellCheck
|
spellCheck
|
||||||
onChange={handleChange}
|
onChange={this.handleOptionTitleChange}
|
||||||
suggestions={suggestions}
|
suggestions={this.props.suggestions}
|
||||||
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||||
onSuggestionsClearRequested={handleSuggestionsClearRequested}
|
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||||
onSuggestionSelected={handleSuggestionSelected}
|
onSuggestionSelected={this.onSuggestionSelected}
|
||||||
searchTokens={[':']}
|
searchTokens={[':']}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Option.propTypes = {
|
<div className='poll__cancel'>
|
||||||
title: PropTypes.string.isRequired,
|
<IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' iconComponent={CloseIcon} onClick={this.handleOptionRemove} />
|
||||||
index: PropTypes.number.isRequired,
|
</div>
|
||||||
multipleChoice: PropTypes.bool,
|
</li>
|
||||||
autoFocus: PropTypes.bool,
|
);
|
||||||
};
|
|
||||||
|
|
||||||
export const PollForm = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const poll = useSelector(state => state.getIn(['compose', 'poll']));
|
|
||||||
const options = poll?.get('options');
|
|
||||||
const expiresIn = poll?.get('expires_in');
|
|
||||||
const isMultiple = poll?.get('multiple');
|
|
||||||
|
|
||||||
const handleDurationChange = useCallback(({ target: { value } }) => {
|
|
||||||
dispatch(changePollSettings(value, isMultiple));
|
|
||||||
}, [dispatch, isMultiple]);
|
|
||||||
|
|
||||||
const handleTypeChange = useCallback(({ target: { value } }) => {
|
|
||||||
dispatch(changePollSettings(expiresIn, value === 'true'));
|
|
||||||
}, [dispatch, expiresIn]);
|
|
||||||
|
|
||||||
if (poll === null) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
}
|
||||||
<div className='compose-form__poll'>
|
|
||||||
{options.map((title, i) => (
|
|
||||||
<Option
|
|
||||||
title={title}
|
|
||||||
key={i}
|
|
||||||
index={i}
|
|
||||||
multipleChoice={isMultiple}
|
|
||||||
autoFocus={i === 0}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className='compose-form__poll__footer'>
|
const Option = injectIntl(OptionIntl);
|
||||||
<Select label={intl.formatMessage(messages.duration)} options={[
|
|
||||||
{ value: 300, label: intl.formatMessage(messages.minutes, { number: 5 })},
|
|
||||||
{ value: 1800, label: intl.formatMessage(messages.minutes, { number: 30 })},
|
|
||||||
{ value: 3600, label: intl.formatMessage(messages.hours, { number: 1 })},
|
|
||||||
{ value: 21600, label: intl.formatMessage(messages.hours, { number: 6 })},
|
|
||||||
{ value: 43200, label: intl.formatMessage(messages.hours, { number: 12 })},
|
|
||||||
{ value: 86400, label: intl.formatMessage(messages.days, { number: 1 })},
|
|
||||||
{ value: 259200, label: intl.formatMessage(messages.days, { number: 3 })},
|
|
||||||
{ value: 604800, label: intl.formatMessage(messages.days, { number: 7 })},
|
|
||||||
]} value={expiresIn} onChange={handleDurationChange} />
|
|
||||||
|
|
||||||
<div className='compose-form__poll__footer__sep' />
|
class PollForm extends ImmutablePureComponent {
|
||||||
|
|
||||||
<Select label={intl.formatMessage(messages.type)} options={[
|
static propTypes = {
|
||||||
{ value: false, label: intl.formatMessage(messages.singleChoice) },
|
options: ImmutablePropTypes.list,
|
||||||
{ value: true, label: intl.formatMessage(messages.multipleChoice) },
|
lang: PropTypes.string,
|
||||||
]} value={isMultiple} onChange={handleTypeChange} />
|
expiresIn: PropTypes.number,
|
||||||
|
isMultiple: PropTypes.bool,
|
||||||
|
onChangeOption: PropTypes.func.isRequired,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAddOption = () => {
|
||||||
|
this.props.onAddOption('');
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSelectDuration = e => {
|
||||||
|
this.props.onChangeSettings(e.target.value, this.props.isMultiple);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSelectMultiple = e => {
|
||||||
|
this.props.onChangeSettings(this.props.expiresIn, e.target.value === 'true');
|
||||||
|
};
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { options, lang, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
|
||||||
|
|
||||||
|
if (!options) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoFocusIndex = options.indexOf('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='compose-form__poll-wrapper'>
|
||||||
|
<ul>
|
||||||
|
{options.map((title, i) => <Option title={title} lang={lang} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} autoFocus={i === autoFocusIndex} {...other} />)}
|
||||||
|
{options.size < pollLimits.max_options && (
|
||||||
|
<label className='poll__text editable'>
|
||||||
|
<span className={classNames('poll__input')} style={{ opacity: 0 }} />
|
||||||
|
<button className='button button-secondary' onClick={this.handleAddOption} type='button'><Icon id='plus' icon={AddIcon} /> <FormattedMessage {...messages.add_option} /></button>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className='poll__footer'>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-onchange */}
|
||||||
|
<select value={isMultiple ? 'true' : 'false'} onChange={this.handleSelectMultiple}>
|
||||||
|
<option value='false'>{intl.formatMessage(messages.single_choice)}</option>
|
||||||
|
<option value='true'>{intl.formatMessage(messages.multiple_choices)}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-onchange */}
|
||||||
|
<select value={expiresIn} onChange={this.handleSelectDuration}>
|
||||||
|
<option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
|
||||||
|
<option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
|
||||||
|
<option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
|
||||||
|
<option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
|
||||||
|
<option value={43200}>{intl.formatMessage(messages.hours, { number: 12 })}</option>
|
||||||
|
<option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
|
||||||
|
<option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
|
||||||
|
<option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
};
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default injectIntl(PollForm);
|
||||||
|
|||||||
@ -3,29 +3,23 @@ import { PureComponent } from 'react';
|
|||||||
|
|
||||||
import { injectIntl, defineMessages } from 'react-intl';
|
import { injectIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import Overlay from 'react-overlays/Overlay';
|
|
||||||
|
|
||||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
|
||||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||||
|
import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
|
||||||
|
import MailIcon from '@/material-icons/400-24px/mail.svg?react';
|
||||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||||
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
|
||||||
|
|
||||||
import { PrivacyDropdownMenu } from './privacy_dropdown_menu';
|
import Dropdown from './dropdown';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all' },
|
||||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
|
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
||||||
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Fewer algorithmic fanfares' },
|
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' },
|
||||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
|
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
|
||||||
private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
|
private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
|
||||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
|
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
|
||||||
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Everyone mentioned in the post' },
|
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
|
||||||
change_privacy: { id: 'privacy.change', defaultMessage: 'Change post privacy' },
|
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
|
||||||
unlisted_extra: { id: 'privacy.unlisted.additional', defaultMessage: 'This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
class PrivacyDropdown extends PureComponent {
|
class PrivacyDropdown extends PureComponent {
|
||||||
@ -42,118 +36,62 @@ class PrivacyDropdown extends PureComponent {
|
|||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
|
||||||
open: false,
|
|
||||||
placement: 'bottom',
|
|
||||||
};
|
|
||||||
|
|
||||||
handleToggle = () => {
|
|
||||||
if (this.state.open && this.activeElement) {
|
|
||||||
this.activeElement.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ open: !this.state.open });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleKeyDown = e => {
|
|
||||||
switch(e.key) {
|
|
||||||
case 'Escape':
|
|
||||||
this.handleClose();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseDown = () => {
|
|
||||||
if (!this.state.open) {
|
|
||||||
this.activeElement = document.activeElement;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleButtonKeyDown = (e) => {
|
|
||||||
switch(e.key) {
|
|
||||||
case ' ':
|
|
||||||
case 'Enter':
|
|
||||||
this.handleMouseDown();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleClose = () => {
|
|
||||||
if (this.state.open && this.activeElement) {
|
|
||||||
this.activeElement.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
this.setState({ open: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleChange = value => {
|
|
||||||
this.props.onChange(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
UNSAFE_componentWillMount () {
|
|
||||||
const { intl: { formatMessage } } = this.props;
|
|
||||||
|
|
||||||
this.options = [
|
|
||||||
{ icon: 'globe', iconComponent: PublicIcon, value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
|
|
||||||
{ icon: 'unlock', iconComponent: QuietTimeIcon, value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long), extra: formatMessage(messages.unlisted_extra) },
|
|
||||||
{ icon: 'lock', iconComponent: LockIcon, value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!this.props.noDirect) {
|
|
||||||
this.options.push(
|
|
||||||
{ icon: 'at', iconComponent: AlternateEmailIcon, value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTargetRef = c => {
|
|
||||||
this.target = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
findTarget = () => {
|
|
||||||
return this.target;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleOverlayEnter = (state) => {
|
|
||||||
this.setState({ placement: state.placement });
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { value, container, disabled, intl } = this.props;
|
const { value, onChange, onModalOpen, onModalClose, disabled, noDirect, container, isUserTouching, intl: { formatMessage } } = this.props;
|
||||||
const { open, placement } = this.state;
|
|
||||||
|
|
||||||
const valueOption = this.options.find(item => item.value === value);
|
// We predefine our privacy items so that we can easily pick the
|
||||||
|
// dropdown icon later.
|
||||||
|
const privacyItems = {
|
||||||
|
direct: {
|
||||||
|
icon: 'envelope',
|
||||||
|
iconComponent: MailIcon,
|
||||||
|
meta: formatMessage(messages.direct_long),
|
||||||
|
name: 'direct',
|
||||||
|
text: formatMessage(messages.direct_short),
|
||||||
|
},
|
||||||
|
private: {
|
||||||
|
icon: 'lock',
|
||||||
|
iconComponent: LockIcon,
|
||||||
|
meta: formatMessage(messages.private_long),
|
||||||
|
name: 'private',
|
||||||
|
text: formatMessage(messages.private_short),
|
||||||
|
},
|
||||||
|
public: {
|
||||||
|
icon: 'globe',
|
||||||
|
iconComponent: PublicIcon,
|
||||||
|
meta: formatMessage(messages.public_long),
|
||||||
|
name: 'public',
|
||||||
|
text: formatMessage(messages.public_short),
|
||||||
|
},
|
||||||
|
unlisted: {
|
||||||
|
icon: 'unlock',
|
||||||
|
iconComponent: LockOpenIcon,
|
||||||
|
meta: formatMessage(messages.unlisted_long),
|
||||||
|
name: 'unlisted',
|
||||||
|
text: formatMessage(messages.unlisted_short),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = [privacyItems.public, privacyItems.unlisted, privacyItems.private];
|
||||||
|
|
||||||
|
if (!noDirect) {
|
||||||
|
items.push(privacyItems.direct);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
|
<Dropdown
|
||||||
<button
|
disabled={disabled}
|
||||||
type='button'
|
icon={(privacyItems[value] || {}).icon}
|
||||||
title={intl.formatMessage(messages.change_privacy)}
|
iconComponent={(privacyItems[value] || {}).iconComponent}
|
||||||
aria-expanded={open}
|
items={items}
|
||||||
onClick={this.handleToggle}
|
onChange={onChange}
|
||||||
onMouseDown={this.handleMouseDown}
|
isUserTouching={isUserTouching}
|
||||||
onKeyDown={this.handleButtonKeyDown}
|
onModalClose={onModalClose}
|
||||||
disabled={disabled}
|
onModalOpen={onModalOpen}
|
||||||
className={classNames('dropdown-button', { active: open })}
|
title={formatMessage(messages.change_privacy)}
|
||||||
>
|
container={container}
|
||||||
<Icon id={valueOption.icon} icon={valueOption.iconComponent} />
|
value={value}
|
||||||
<span className='dropdown-button__label'>{valueOption.text}</span>
|
/>
|
||||||
</button>
|
|
||||||
|
|
||||||
<Overlay show={open} offset={[5, 5]} placement={placement} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
|
|
||||||
{({ props, placement }) => (
|
|
||||||
<div {...props}>
|
|
||||||
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
|
|
||||||
<PrivacyDropdownMenu
|
|
||||||
items={this.options}
|
|
||||||
value={value}
|
|
||||||
onClose={this.handleClose}
|
|
||||||
onChange={this.handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Overlay>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,128 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
|
||||||
|
|
||||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
|
||||||
|
|
||||||
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
|
||||||
|
|
||||||
export const PrivacyDropdownMenu = ({ style, items, value, onClose, onChange }) => {
|
|
||||||
const nodeRef = useRef(null);
|
|
||||||
const focusedItemRef = useRef(null);
|
|
||||||
const [currentValue, setCurrentValue] = useState(value);
|
|
||||||
|
|
||||||
const handleDocumentClick = useCallback((e) => {
|
|
||||||
if (nodeRef.current && !nodeRef.current.contains(e.target)) {
|
|
||||||
onClose();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}, [nodeRef, onClose]);
|
|
||||||
|
|
||||||
const handleClick = useCallback((e) => {
|
|
||||||
const value = e.currentTarget.getAttribute('data-index');
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
onChange(value);
|
|
||||||
}, [onClose, onChange]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e) => {
|
|
||||||
const value = e.currentTarget.getAttribute('data-index');
|
|
||||||
const index = items.findIndex(item => (item.value === value));
|
|
||||||
|
|
||||||
let element = null;
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case 'Escape':
|
|
||||||
onClose();
|
|
||||||
break;
|
|
||||||
case ' ':
|
|
||||||
case 'Enter':
|
|
||||||
handleClick(e);
|
|
||||||
break;
|
|
||||||
case 'ArrowDown':
|
|
||||||
element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild;
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild;
|
|
||||||
break;
|
|
||||||
case 'Tab':
|
|
||||||
if (e.shiftKey) {
|
|
||||||
element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild;
|
|
||||||
} else {
|
|
||||||
element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'Home':
|
|
||||||
element = nodeRef.current.firstChild;
|
|
||||||
break;
|
|
||||||
case 'End':
|
|
||||||
element = nodeRef.current.lastChild;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
element.focus();
|
|
||||||
setCurrentValue(element.getAttribute('data-index'));
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}, [nodeRef, items, onClose, handleClick, setCurrentValue]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.addEventListener('click', handleDocumentClick, { capture: true });
|
|
||||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
|
||||||
focusedItemRef.current?.focus({ preventScroll: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('click', handleDocumentClick, { capture: true });
|
|
||||||
document.removeEventListener('touchend', handleDocumentClick, listenerOptions);
|
|
||||||
};
|
|
||||||
}, [handleDocumentClick]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul style={{ ...style }} role='listbox' ref={nodeRef}>
|
|
||||||
{items.map(item => (
|
|
||||||
<li
|
|
||||||
role='option'
|
|
||||||
tabIndex={0}
|
|
||||||
key={item.value}
|
|
||||||
data-index={item.value}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onClick={handleClick}
|
|
||||||
className={classNames('privacy-dropdown__option', { active: item.value === currentValue })}
|
|
||||||
aria-selected={item.value === currentValue}
|
|
||||||
ref={item.value === currentValue ? focusedItemRef : null}
|
|
||||||
>
|
|
||||||
<div className='privacy-dropdown__option__icon'>
|
|
||||||
<Icon id={item.icon} icon={item.iconComponent} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='privacy-dropdown__option__content'>
|
|
||||||
<strong>{item.text}</strong>
|
|
||||||
{item.meta}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{item.extra && (
|
|
||||||
<div className='privacy-dropdown__option__additional' title={item.extra}>
|
|
||||||
<Icon id='info-circle' icon={InfoIcon} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
PrivacyDropdownMenu.propTypes = {
|
|
||||||
style: PropTypes.object,
|
|
||||||
items: PropTypes.array.isRequired,
|
|
||||||
value: PropTypes.string.isRequired,
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
onChange: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
|
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||||
|
import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
|
||||||
|
import MailIcon from '@/material-icons/400-24px/mail.svg?react';
|
||||||
|
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||||
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
publish: {
|
||||||
|
defaultMessage: 'Publish',
|
||||||
|
id: 'compose_form.publish',
|
||||||
|
},
|
||||||
|
publishLoud: {
|
||||||
|
defaultMessage: '{publish}!',
|
||||||
|
id: 'compose_form.publish_loud',
|
||||||
|
},
|
||||||
|
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
|
||||||
|
public: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||||
|
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
||||||
|
private: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
|
||||||
|
direct: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
|
||||||
|
});
|
||||||
|
|
||||||
|
class Publisher extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
onSecondarySubmit: PropTypes.func,
|
||||||
|
privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
|
||||||
|
sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
|
||||||
|
isEditing: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { disabled, intl, onSecondarySubmit, privacy, sideArm, isEditing } = this.props;
|
||||||
|
|
||||||
|
const privacyIcons = {
|
||||||
|
direct: {
|
||||||
|
id: 'envelope',
|
||||||
|
icon: MailIcon,
|
||||||
|
},
|
||||||
|
private: {
|
||||||
|
id: 'lock',
|
||||||
|
icon: LockIcon,
|
||||||
|
},
|
||||||
|
public: {
|
||||||
|
id: 'globe',
|
||||||
|
icon: PublicIcon,
|
||||||
|
},
|
||||||
|
unlisted: {
|
||||||
|
id: 'unlock',
|
||||||
|
icon: LockOpenIcon,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let publishText;
|
||||||
|
if (isEditing) {
|
||||||
|
publishText = intl.formatMessage(messages.saveChanges);
|
||||||
|
} else if (privacy === 'private' || privacy === 'direct') {
|
||||||
|
const icon = privacyIcons[privacy];
|
||||||
|
publishText = (
|
||||||
|
<span>
|
||||||
|
<Icon {...icon} /> {intl.formatMessage(messages.publish)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
|
||||||
|
}
|
||||||
|
|
||||||
|
const privacyNames = {
|
||||||
|
public: messages.public,
|
||||||
|
unlisted: messages.unlisted,
|
||||||
|
private: messages.private,
|
||||||
|
direct: messages.direct,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='compose-form__publish'>
|
||||||
|
{sideArm && !isEditing && sideArm !== 'none' && (
|
||||||
|
<div className='compose-form__publish-button-wrapper'>
|
||||||
|
<Button
|
||||||
|
className='side_arm'
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onSecondarySubmit}
|
||||||
|
style={{ padding: null }}
|
||||||
|
text={<Icon {...privacyIcons[sideArm]} />}
|
||||||
|
title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage(privacyNames[sideArm])}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className='compose-form__publish-button-wrapper'>
|
||||||
|
<Button
|
||||||
|
className='primary'
|
||||||
|
type='submit'
|
||||||
|
text={publishText}
|
||||||
|
title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage(privacyNames[privacy])}`}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default injectIntl(Publisher);
|
||||||
@ -1,47 +1,75 @@
|
|||||||
import { FormattedMessage } from 'react-intl';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
|
||||||
import { Permalink } from 'flavours/glitch/components/permalink';
|
|
||||||
|
|
||||||
export const ReplyIndicator = () => {
|
|
||||||
const inReplyToId = useSelector(state => state.getIn(['compose', 'in_reply_to']));
|
|
||||||
const status = useSelector(state => state.getIn(['statuses', inReplyToId]));
|
|
||||||
const account = useSelector(state => state.getIn(['accounts', status?.get('account')]));
|
|
||||||
|
|
||||||
if (!status) {
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
return null;
|
import AttachmentList from 'flavours/glitch/components/attachment_list';
|
||||||
}
|
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router';
|
||||||
|
|
||||||
const content = { __html: status.get('contentHtml') };
|
import { Avatar } from '../../../components/avatar';
|
||||||
|
import { DisplayName } from '../../../components/display_name';
|
||||||
|
import { IconButton } from '../../../components/icon_button';
|
||||||
|
|
||||||
return (
|
const messages = defineMessages({
|
||||||
<div className='reply-indicator'>
|
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
|
||||||
<div className='reply-indicator__line' />
|
});
|
||||||
|
|
||||||
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-avatar'>
|
class ReplyIndicator extends ImmutablePureComponent {
|
||||||
<Avatar account={account} size={46} />
|
|
||||||
</Permalink>
|
|
||||||
|
|
||||||
<div className='reply-indicator__main'>
|
static propTypes = {
|
||||||
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
|
status: ImmutablePropTypes.map,
|
||||||
<DisplayName account={account} />
|
onCancel: PropTypes.func.isRequired,
|
||||||
</Permalink>
|
intl: PropTypes.object.isRequired,
|
||||||
|
...WithOptionalRouterPropTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClick = () => {
|
||||||
|
this.props.onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAccountClick = (e) => {
|
||||||
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.props.history?.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { status, intl } = this.props;
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = { __html: status.get('contentHtml') };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='reply-indicator'>
|
||||||
|
<div className='reply-indicator__header'>
|
||||||
|
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' iconComponent={CloseIcon} onClick={this.handleClick} inverted /></div>
|
||||||
|
|
||||||
|
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' target='_blank' rel='noopener noreferrer'>
|
||||||
|
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
|
||||||
|
<DisplayName account={status.get('account')} inline />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='reply-indicator__content translate' dangerouslySetInnerHTML={content} />
|
<div className='reply-indicator__content translate' dangerouslySetInnerHTML={content} />
|
||||||
|
|
||||||
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
{status.get('media_attachments').size > 0 && (
|
||||||
<div className='reply-indicator__attachments'>
|
<AttachmentList
|
||||||
{status.get('poll') && <><Icon icon={BarChart4BarsIcon} /><FormattedMessage id='reply_indicator.poll' defaultMessage='Poll' /></>}
|
compact
|
||||||
{status.get('media_attachments').size > 0 && <><Icon icon={PhotoLibraryIcon} /><FormattedMessage id='reply_indicator.attachments' defaultMessage='{count, plural, one {# attachment} other {# attachments}}' values={{ count: status.get('media_attachments').size }} /></>}
|
media={status.get('media_attachments')}
|
||||||
</div>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
};
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withOptionalRouter(injectIntl(ReplyIndicator));
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { withRouter } from 'react-router-dom';
|
|||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
|
||||||
|
|
||||||
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
|
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||||
@ -185,9 +186,9 @@ class Search extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleURLClick = () => {
|
handleURLClick = () => {
|
||||||
const { value, onOpenURL, history } = this.props;
|
const { onOpenURL, history } = this.props;
|
||||||
|
|
||||||
onOpenURL(value, history);
|
onOpenURL(history);
|
||||||
this._unfocus();
|
this._unfocus();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -330,7 +331,7 @@ class Search extends PureComponent {
|
|||||||
type='text'
|
type='text'
|
||||||
placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
|
placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
|
||||||
aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
|
aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
|
||||||
value={value}
|
value={value || ''}
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={this.handleKeyDown}
|
||||||
onFocus={this.handleFocus}
|
onFocus={this.handleFocus}
|
||||||
@ -338,8 +339,8 @@ class Search extends PureComponent {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
|
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
|
||||||
<Icon id='search' icon={SearchIcon} className={hasValue ? '' : 'active'} />
|
<Icon id='search' icon={SearchIcon} className={hasValue ? '' : 'active'} />
|
||||||
<Icon id='times-circle' icon={CancelIcon} className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} />
|
<Icon id='times-circle' icon={CancelIcon} className={hasValue ? 'active' : ''} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='search__popout'>
|
<div className='search__popout'>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { Icon } from 'flavours/glitch/components/icon';
|
|||||||
import { LoadMore } from 'flavours/glitch/components/load_more';
|
import { LoadMore } from 'flavours/glitch/components/load_more';
|
||||||
import { SearchSection } from 'flavours/glitch/features/explore/components/search_section';
|
import { SearchSection } from 'flavours/glitch/features/explore/components/search_section';
|
||||||
|
|
||||||
|
|
||||||
import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
|
import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
|
||||||
import AccountContainer from '../../../containers/account_container';
|
import AccountContainer from '../../../containers/account_container';
|
||||||
import StatusContainer from '../../../containers/status_container';
|
import StatusContainer from '../../../containers/status_container';
|
||||||
@ -76,10 +77,10 @@ class SearchResults extends ImmutablePureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='search-results'>
|
<div className='search-results'>
|
||||||
<div className='search-results__header'>
|
<header className='search-results__header'>
|
||||||
<Icon id='search' icon={SearchIcon} />
|
<Icon id='search' icon={SearchIcon} />
|
||||||
<FormattedMessage id='explore.search_results' defaultMessage='Search results' />
|
<FormattedMessage id='explore.search_results' defaultMessage='Search results' />
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
{accounts}
|
{accounts}
|
||||||
{hashtags}
|
{hashtags}
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
|
||||||
import MailIcon from '@/material-icons/400-24px/mail.svg?react';
|
|
||||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
|
||||||
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
public: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
|
||||||
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
|
|
||||||
private: { id: 'privacy.private.short', defaultMessage: 'Followers' },
|
|
||||||
direct: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SecondaryPrivacyButton = ({ disabled, privacy, isEditing, onClick }) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
if (isEditing || !privacy || privacy === 'none') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const privacyProps = {
|
|
||||||
direct: { icon: 'envelope', iconComponent: MailIcon, title: messages.direct },
|
|
||||||
private: { icon: 'lock', iconComponent: LockIcon, title: messages.private },
|
|
||||||
public: { icon: 'globe', iconComponent: PublicIcon, title: messages.public },
|
|
||||||
unlisted: { icon: 'unlock', iconComponent: QuietTimeIcon, title: messages.unlisted },
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button className='secondary-post-button' disabled={disabled} onClick={onClick} title={intl.formatMessage(privacyProps[privacy].title)}>
|
|
||||||
<Icon id={privacyProps[privacy].id} icon={privacyProps[privacy].iconComponent} />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
SecondaryPrivacyButton.propTypes = {
|
|
||||||
disabled: PropTypes.bool,
|
|
||||||
privacy: PropTypes.string,
|
|
||||||
isEditing: PropTypes.bool,
|
|
||||||
onClick: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
|
|
||||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
marked: {
|
|
||||||
id: 'compose_form.sensitive.marked',
|
|
||||||
defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}',
|
|
||||||
},
|
|
||||||
unmarked: {
|
|
||||||
id: 'compose_form.sensitive.unmarked',
|
|
||||||
defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SensitiveButton = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const spoilersAlwaysOn = useAppSelector((state) => state.getIn(['local_settings', 'always_show_spoilers_field']));
|
|
||||||
const spoilerText = useAppSelector((state) => state.getIn(['compose', 'spoiler_text']));
|
|
||||||
const sensitive = useAppSelector((state) => state.getIn(['compose', 'sensitive']));
|
|
||||||
const spoiler = useAppSelector((state) => state.getIn(['compose', 'spoiler']));
|
|
||||||
const mediaCount = useAppSelector((state) => state.getIn(['compose', 'media_attachments']).size);
|
|
||||||
const disabled = spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler;
|
|
||||||
|
|
||||||
const active = sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0);
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
dispatch(changeComposeSensitivity());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='compose-form__sensitive-button'>
|
|
||||||
<label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked, { count: mediaCount })}>
|
|
||||||
<input
|
|
||||||
name='mark-sensitive'
|
|
||||||
type='checkbox'
|
|
||||||
checked={active}
|
|
||||||
onChange={handleClick}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormattedMessage
|
|
||||||
id='compose_form.sensitive.hide'
|
|
||||||
defaultMessage='{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}'
|
|
||||||
values={{ count: mediaCount }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
|
import ForumIcon from '@/material-icons/400-24px/forum.svg?react';
|
||||||
|
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
|
||||||
|
|
||||||
|
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', HomeIcon, messages.localOnly],
|
||||||
|
['threaded_mode', 'comments', ForumIcon, messages.threadedMode],
|
||||||
|
];
|
||||||
|
|
||||||
|
class TextareaIcons extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
advancedOptions: ImmutablePropTypes.map,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { advancedOptions, intl } = this.props;
|
||||||
|
return (
|
||||||
|
<div className='compose-form__textarea-icons'>
|
||||||
|
{advancedOptions && iconMap.map(
|
||||||
|
([key, icon, iconComponent, message]) => advancedOptions.get(key) && (
|
||||||
|
<span
|
||||||
|
className='textarea_icon'
|
||||||
|
key={key}
|
||||||
|
title={intl.formatMessage(message)}
|
||||||
|
>
|
||||||
|
<Icon id={icon} icon={iconComponent} />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default injectIntl(TextareaIcons);
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
import QuickreplyIcon from '@/material-icons/400-20px/quickreply.svg?react';
|
|
||||||
import { changeComposeAdvancedOption } from 'flavours/glitch/actions/compose';
|
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
|
||||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
enable_threaded_mode: { id: 'compose.enable_threaded_mode', defaultMessage: 'Enable threaded mode' },
|
|
||||||
disable_threaded_mode: { id: 'compose.disable_threaded_mode', defaultMessage: 'Disable threaded mode' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ThreadModeButton = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const isEditing = useAppSelector((state) => state.getIn(['compose', 'id']) !== null);
|
|
||||||
const active = useAppSelector((state) => state.getIn(['compose', 'advanced_options', 'threaded_mode']));
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
dispatch(changeComposeAdvancedOption('threaded_mode', !active));
|
|
||||||
}, [active, dispatch]);
|
|
||||||
|
|
||||||
const title = intl.formatMessage(active ? messages.disable_threaded_mode : messages.enable_threaded_mode);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
disabled={isEditing}
|
|
||||||
icon=''
|
|
||||||
onClick={handleClick}
|
|
||||||
iconComponent={QuickreplyIcon}
|
|
||||||
title={title}
|
|
||||||
active={active}
|
|
||||||
size={18}
|
|
||||||
inverted
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -2,26 +2,23 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
import spring from 'react-motion/lib/spring';
|
import spring from 'react-motion/lib/spring';
|
||||||
|
|
||||||
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||||
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
|
||||||
|
|
||||||
import Motion from '../../ui/util/optional_motion';
|
import Motion from '../../ui/util/optional_motion';
|
||||||
|
|
||||||
export default class Upload extends ImmutablePureComponent {
|
export default class Upload extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
media: ImmutablePropTypes.map.isRequired,
|
media: ImmutablePropTypes.map.isRequired,
|
||||||
sensitive: PropTypes.bool,
|
|
||||||
onUndo: PropTypes.func.isRequired,
|
onUndo: PropTypes.func.isRequired,
|
||||||
onOpenFocalPoint: PropTypes.func.isRequired,
|
onOpenFocalPoint: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
@ -37,7 +34,7 @@ export default class Upload extends ImmutablePureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media, sensitive } = this.props;
|
const { media } = this.props;
|
||||||
|
|
||||||
if (!media) {
|
if (!media) {
|
||||||
return null;
|
return null;
|
||||||
@ -47,26 +44,22 @@ export default class Upload extends ImmutablePureComponent {
|
|||||||
const focusY = media.getIn(['meta', 'focus', 'y']);
|
const focusY = media.getIn(['meta', 'focus', 'y']);
|
||||||
const x = ((focusX / 2) + .5) * 100;
|
const x = ((focusX / 2) + .5) * 100;
|
||||||
const y = ((focusY / -2) + .5) * 100;
|
const y = ((focusY / -2) + .5) * 100;
|
||||||
const missingDescription = (media.get('description') || '').length === 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='compose-form__upload'>
|
<div className='compose-form__upload'>
|
||||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
||||||
{({ scale }) => (
|
{({ scale }) => (
|
||||||
<div className='compose-form__upload__thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: !sensitive ? `url(${media.get('preview_url')})` : null, backgroundPosition: `${x}% ${y}%` }}>
|
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
|
||||||
{sensitive && <Blurhash
|
|
||||||
hash={media.get('blurhash')}
|
|
||||||
className='compose-form__upload__preview'
|
|
||||||
/>}
|
|
||||||
|
|
||||||
<div className='compose-form__upload__actions'>
|
<div className='compose-form__upload__actions'>
|
||||||
<button type='button' className='icon-button compose-form__upload__delete' onClick={this.handleUndoClick}><Icon icon={CloseIcon} /></button>
|
<button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
|
||||||
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
|
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='compose-form__upload__warning'>
|
{(media.get('description') || '').length === 0 && (
|
||||||
<button type='button' className={classNames('icon-button', { active: missingDescription })} onClick={this.handleFocalPointClick}>{missingDescription && <Icon icon={WarningIcon} />} ALT</button>
|
<div className='compose-form__upload__warning'>
|
||||||
</div>
|
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' icon={InfoIcon} /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Motion>
|
</Motion>
|
||||||
|
|||||||
@ -1,109 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import PhotoLibraryIcon from '@/material-icons/400-20px/photo_library.svg?react';
|
|
||||||
import BrushIcon from '@/material-icons/400-24px/brush.svg?react';
|
|
||||||
import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react';
|
|
||||||
|
|
||||||
import { DropdownIconButton } from './dropdown_icon_button';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
upload: { id: 'upload_button.label', defaultMessage: 'Add images, a video or an audio file' },
|
|
||||||
doodle: { id: 'compose.attach.doodle', defaultMessage: 'Draw something' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
|
|
||||||
});
|
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
class UploadButton extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
disabled: PropTypes.bool,
|
|
||||||
onSelectFile: PropTypes.func.isRequired,
|
|
||||||
onDoodleOpen: PropTypes.func.isRequired,
|
|
||||||
style: PropTypes.object,
|
|
||||||
resetFileKey: PropTypes.number,
|
|
||||||
acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleChange = (e) => {
|
|
||||||
if (e.target.files.length > 0) {
|
|
||||||
this.props.onSelectFile(e.target.files);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleSelect = (value) => {
|
|
||||||
if (value === 'upload') {
|
|
||||||
this.fileElement.click();
|
|
||||||
} else {
|
|
||||||
this.props.onDoodleOpen();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setRef = (c) => {
|
|
||||||
this.fileElement = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
|
|
||||||
|
|
||||||
const message = intl.formatMessage(messages.upload);
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
{
|
|
||||||
icon: 'cloud-upload',
|
|
||||||
iconComponent: UploadFileIcon,
|
|
||||||
value: 'upload',
|
|
||||||
text: intl.formatMessage(messages.upload),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'paint-brush',
|
|
||||||
iconComponent: BrushIcon,
|
|
||||||
value: 'doodle',
|
|
||||||
text: intl.formatMessage(messages.doodle),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='compose-form__upload-button'>
|
|
||||||
<DropdownIconButton
|
|
||||||
icon='paperclip'
|
|
||||||
iconComponent={PhotoLibraryIcon}
|
|
||||||
title={message}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={this.handleSelect}
|
|
||||||
value='upload'
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
<label>
|
|
||||||
<span style={{ display: 'none' }}>{message}</span>
|
|
||||||
<input
|
|
||||||
key={resetFileKey}
|
|
||||||
ref={this.setRef}
|
|
||||||
type='file'
|
|
||||||
name='file-upload-input'
|
|
||||||
multiple
|
|
||||||
accept={acceptContentTypes.toArray().join(',')}
|
|
||||||
onChange={this.handleChange}
|
|
||||||
disabled={disabled}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(makeMapStateToProps)(injectIntl(UploadButton));
|
|
||||||
@ -1,11 +1,10 @@
|
|||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
|
import SensitiveButtonContainer from '../containers/sensitive_button_container';
|
||||||
import UploadContainer from '../containers/upload_container';
|
import UploadContainer from '../containers/upload_container';
|
||||||
import UploadProgressContainer from '../containers/upload_progress_container';
|
import UploadProgressContainer from '../containers/upload_progress_container';
|
||||||
|
|
||||||
import { SensitiveButton } from './sensitive_button';
|
|
||||||
|
|
||||||
export default class UploadForm extends ImmutablePureComponent {
|
export default class UploadForm extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
@ -16,19 +15,17 @@ export default class UploadForm extends ImmutablePureComponent {
|
|||||||
const { mediaIds } = this.props;
|
const { mediaIds } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className='compose-form__upload-wrapper'>
|
||||||
<UploadProgressContainer />
|
<UploadProgressContainer />
|
||||||
|
|
||||||
{mediaIds.size > 0 && (
|
<div className='compose-form__uploads-wrapper'>
|
||||||
<div className='compose-form__uploads'>
|
{mediaIds.map(id => (
|
||||||
{mediaIds.map(id => (
|
<UploadContainer id={id} key={id} />
|
||||||
<UploadContainer id={id} key={id} />
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!mediaIds.isEmpty() && <SensitiveButton />}
|
{!mediaIds.isEmpty() && <SensitiveButtonContainer />}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,7 +35,9 @@ export default class UploadProgress extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='upload-progress'>
|
<div className='upload-progress'>
|
||||||
<Icon id='upload' icon={UploadFileIcon} />
|
<div className='upload-progress__icon'>
|
||||||
|
<Icon id='upload' icon={UploadFileIcon} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='upload-progress__message'>
|
<div className='upload-progress__message'>
|
||||||
{message}
|
{message}
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import {
|
|||||||
fetchComposeSuggestions,
|
fetchComposeSuggestions,
|
||||||
selectComposeSuggestion,
|
selectComposeSuggestion,
|
||||||
changeComposeSpoilerText,
|
changeComposeSpoilerText,
|
||||||
|
changeComposeSpoilerness,
|
||||||
|
changeComposeVisibility,
|
||||||
insertEmojiCompose,
|
insertEmojiCompose,
|
||||||
uploadCompose,
|
uploadCompose,
|
||||||
} from '../../../actions/compose';
|
} from '../../../actions/compose';
|
||||||
@ -56,13 +58,11 @@ const mapStateToProps = state => ({
|
|||||||
text: state.getIn(['compose', 'text']),
|
text: state.getIn(['compose', 'text']),
|
||||||
suggestions: state.getIn(['compose', 'suggestions']),
|
suggestions: state.getIn(['compose', 'suggestions']),
|
||||||
spoiler: state.getIn(['local_settings', 'always_show_spoilers_field']) || state.getIn(['compose', 'spoiler']),
|
spoiler: state.getIn(['local_settings', 'always_show_spoilers_field']) || state.getIn(['compose', 'spoiler']),
|
||||||
spoilerAlwaysOn: state.getIn(['local_settings', 'always_show_spoilers_field']),
|
|
||||||
spoilerText: state.getIn(['compose', 'spoiler_text']),
|
spoilerText: state.getIn(['compose', 'spoiler_text']),
|
||||||
privacy: state.getIn(['compose', 'privacy']),
|
privacy: state.getIn(['compose', 'privacy']),
|
||||||
focusDate: state.getIn(['compose', 'focusDate']),
|
focusDate: state.getIn(['compose', 'focusDate']),
|
||||||
caretPosition: state.getIn(['compose', 'caretPosition']),
|
caretPosition: state.getIn(['compose', 'caretPosition']),
|
||||||
preselectDate: state.getIn(['compose', 'preselectDate']),
|
preselectDate: state.getIn(['compose', 'preselectDate']),
|
||||||
preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
|
|
||||||
isSubmitting: state.getIn(['compose', 'is_submitting']),
|
isSubmitting: state.getIn(['compose', 'is_submitting']),
|
||||||
isEditing: state.getIn(['compose', 'id']) !== null,
|
isEditing: state.getIn(['compose', 'id']) !== null,
|
||||||
isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
|
isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
|
||||||
@ -70,10 +70,14 @@ const mapStateToProps = state => ({
|
|||||||
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
|
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
|
||||||
isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
|
isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
|
||||||
lang: state.getIn(['compose', 'language']),
|
lang: state.getIn(['compose', 'language']),
|
||||||
sideArm: sideArmPrivacy(state),
|
advancedOptions: state.getIn(['compose', 'advanced_options']),
|
||||||
media: state.getIn(['compose', 'media_attachments']),
|
media: state.getIn(['compose', 'media_attachments']),
|
||||||
|
sideArm: sideArmPrivacy(state),
|
||||||
|
sensitive: state.getIn(['compose', 'sensitive']),
|
||||||
|
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
|
||||||
|
spoilersAlwaysOn: state.getIn(['local_settings', 'always_show_spoilers_field']),
|
||||||
mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
|
mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
|
||||||
maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),
|
preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
@ -82,8 +86,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||||||
dispatch(changeCompose(text));
|
dispatch(changeCompose(text));
|
||||||
},
|
},
|
||||||
|
|
||||||
onSubmit (router, overridePrivacy = null) {
|
onSubmit (router) {
|
||||||
dispatch(submitCompose(router, overridePrivacy));
|
dispatch(submitCompose(router));
|
||||||
},
|
},
|
||||||
|
|
||||||
onClearSuggestions () {
|
onClearSuggestions () {
|
||||||
@ -98,26 +102,37 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||||||
dispatch(selectComposeSuggestion(position, token, suggestion, path));
|
dispatch(selectComposeSuggestion(position, token, suggestion, path));
|
||||||
},
|
},
|
||||||
|
|
||||||
onChangeSpoilerText (checked) {
|
onChangeSpoilerText (text) {
|
||||||
dispatch(changeComposeSpoilerText(checked));
|
dispatch(changeComposeSpoilerText(text));
|
||||||
},
|
},
|
||||||
|
|
||||||
onPaste (files) {
|
onPaste (files) {
|
||||||
dispatch(uploadCompose(files));
|
dispatch(uploadCompose(files));
|
||||||
},
|
},
|
||||||
|
|
||||||
onPickEmoji (position, data, needsSpace) {
|
onPickEmoji (position, emoji) {
|
||||||
dispatch(insertEmojiCompose(position, data, needsSpace));
|
dispatch(insertEmojiCompose(position, emoji));
|
||||||
},
|
},
|
||||||
|
|
||||||
onMediaDescriptionConfirm (routerHistory, mediaId, overridePrivacy = null) {
|
onChangeSpoilerness() {
|
||||||
|
dispatch(changeComposeSpoilerness());
|
||||||
|
},
|
||||||
|
|
||||||
|
onChangeVisibility(value) {
|
||||||
|
dispatch(changeComposeVisibility(value));
|
||||||
|
},
|
||||||
|
|
||||||
|
onMediaDescriptionConfirm(routerHistory, mediaId, overriddenVisibility = null) {
|
||||||
dispatch(openModal({
|
dispatch(openModal({
|
||||||
modalType: 'CONFIRM',
|
modalType: 'CONFIRM',
|
||||||
modalProps: {
|
modalProps: {
|
||||||
message: intl.formatMessage(messages.missingDescriptionMessage),
|
message: intl.formatMessage(messages.missingDescriptionMessage),
|
||||||
confirm: intl.formatMessage(messages.missingDescriptionConfirm),
|
confirm: intl.formatMessage(messages.missingDescriptionConfirm),
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
dispatch(submitCompose(routerHistory, overridePrivacy));
|
if (overriddenVisibility) {
|
||||||
|
dispatch(changeComposeVisibility(overriddenVisibility));
|
||||||
|
}
|
||||||
|
dispatch(submitCompose(routerHistory));
|
||||||
},
|
},
|
||||||
secondary: intl.formatMessage(messages.missingDescriptionEdit),
|
secondary: intl.formatMessage(messages.missingDescriptionEdit),
|
||||||
onSecondary: () => dispatch(openModal({
|
onSecondary: () => dispatch(openModal({
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { openModal, closeModal } from 'flavours/glitch/actions/modal';
|
||||||
|
import { isUserTouching } from 'flavours/glitch/is_mobile';
|
||||||
|
|
||||||
|
import Dropdown from '../components/dropdown';
|
||||||
|
|
||||||
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
isUserTouching,
|
||||||
|
onModalOpen: props => dispatch(openModal({ modalType: 'ACTIONS', modalProps: props })),
|
||||||
|
onModalClose: () => dispatch(closeModal({ modalType: undefined, ignoreFocus: false })),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(null, mapDispatchToProps)(Dropdown);
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
|
import { logOut } from 'flavours/glitch/utils/log_out';
|
||||||
|
|
||||||
|
import Header from '../components/header';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
|
||||||
|
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapStateToProps = state => {
|
||||||
|
return {
|
||||||
|
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({ modalType: 'SETTINGS', modalProps: {} }));
|
||||||
|
},
|
||||||
|
onLogout () {
|
||||||
|
dispatch(openModal({
|
||||||
|
modalType: 'CONFIRM',
|
||||||
|
modalProps: {
|
||||||
|
message: intl.formatMessage(messages.logoutMessage),
|
||||||
|
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||||
|
closeWhenConfirm: false,
|
||||||
|
onConfirm: () => logOut(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Header));
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
|
import { logOut } from 'flavours/glitch/utils/log_out';
|
||||||
|
|
||||||
|
import { me } from '../../../initial_state';
|
||||||
|
import NavigationBar from '../components/navigation_bar';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
|
||||||
|
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapStateToProps = state => {
|
||||||
|
return {
|
||||||
|
account: state.getIn(['accounts', me]),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
|
onLogout () {
|
||||||
|
dispatch(openModal({
|
||||||
|
modalType: 'CONFIRM',
|
||||||
|
modalProps: {
|
||||||
|
message: intl.formatMessage(messages.logoutMessage),
|
||||||
|
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||||
|
closeWhenConfirm: false,
|
||||||
|
onConfirm: () => logOut(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar));
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import {
|
||||||
|
changeComposeAdvancedOption,
|
||||||
|
changeComposeContentType,
|
||||||
|
addPoll,
|
||||||
|
removePoll,
|
||||||
|
} from 'flavours/glitch/actions/compose';
|
||||||
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
|
|
||||||
|
import Options from '../components/options';
|
||||||
|
|
||||||
|
function mapStateToProps (state) {
|
||||||
|
const poll = state.getIn(['compose', 'poll']);
|
||||||
|
const media = state.getIn(['compose', 'media_attachments']);
|
||||||
|
const pending_media = state.getIn(['compose', 'pending_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 + pending_media < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : pending_media < 4),
|
||||||
|
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({
|
||||||
|
modalType: 'DOODLE',
|
||||||
|
modalProps: { noEsc: true, noClose: true },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(Options);
|
||||||
@ -1,25 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { addPoll, removePoll } from '../../../actions/compose';
|
|
||||||
import PollButton from '../components/poll_button';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 0),
|
|
||||||
active: state.getIn(['compose', 'poll']) !== null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
|
||||||
|
|
||||||
onClick () {
|
|
||||||
dispatch((_, getState) => {
|
|
||||||
if (getState().getIn(['compose', 'poll'])) {
|
|
||||||
dispatch(removePoll());
|
|
||||||
} else {
|
|
||||||
dispatch(addPoll());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(PollButton);
|
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addPollOption,
|
||||||
|
removePollOption,
|
||||||
|
changePollOption,
|
||||||
|
changePollSettings,
|
||||||
|
clearComposeSuggestions,
|
||||||
|
fetchComposeSuggestions,
|
||||||
|
selectComposeSuggestion,
|
||||||
|
} from '../../../actions/compose';
|
||||||
|
import PollForm from '../components/poll_form';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
suggestions: state.getIn(['compose', 'suggestions']),
|
||||||
|
options: state.getIn(['compose', 'poll', 'options']),
|
||||||
|
lang: state.getIn(['compose', 'language']),
|
||||||
|
expiresIn: state.getIn(['compose', 'poll', 'expires_in']),
|
||||||
|
isMultiple: state.getIn(['compose', 'poll', 'multiple']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
onAddOption(title) {
|
||||||
|
dispatch(addPollOption(title));
|
||||||
|
},
|
||||||
|
|
||||||
|
onRemoveOption(index) {
|
||||||
|
dispatch(removePollOption(index));
|
||||||
|
},
|
||||||
|
|
||||||
|
onChangeOption(index, title) {
|
||||||
|
dispatch(changePollOption(index, title));
|
||||||
|
},
|
||||||
|
|
||||||
|
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);
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { cancelReplyCompose } from '../../../actions/compose';
|
||||||
|
import { makeGetStatus } from '../../../selectors';
|
||||||
|
import ReplyIndicator from '../components/reply_indicator';
|
||||||
|
|
||||||
|
const makeMapStateToProps = () => {
|
||||||
|
const getStatus = makeGetStatus();
|
||||||
|
|
||||||
|
const mapStateToProps = state => {
|
||||||
|
let statusId = state.getIn(['compose', 'id'], null);
|
||||||
|
let editing = true;
|
||||||
|
|
||||||
|
if (statusId === null) {
|
||||||
|
statusId = state.getIn(['compose', 'in_reply_to']);
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: getStatus(state, { id: statusId }),
|
||||||
|
editing,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return mapStateToProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
|
||||||
|
onCancel () {
|
||||||
|
dispatch(cancelReplyCompose());
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
|
||||||
@ -42,8 +42,8 @@ const mapDispatchToProps = dispatch => ({
|
|||||||
dispatch(showSearch());
|
dispatch(showSearch());
|
||||||
},
|
},
|
||||||
|
|
||||||
onOpenURL (q, routerHistory) {
|
onOpenURL (routerHistory) {
|
||||||
dispatch(openURL(q, routerHistory));
|
dispatch(openURL(routerHistory));
|
||||||
},
|
},
|
||||||
|
|
||||||
onClickSearchResult (q, type) {
|
onClickSearchResult (q, type) {
|
||||||
|
|||||||
@ -0,0 +1,77 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { PureComponent } from 'react';
|
||||||
|
|
||||||
|
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
marked: {
|
||||||
|
id: 'compose_form.sensitive.marked',
|
||||||
|
defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}',
|
||||||
|
},
|
||||||
|
unmarked: {
|
||||||
|
id: 'compose_form.sensitive.unmarked',
|
||||||
|
defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {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']),
|
||||||
|
mediaCount: state.getIn(['compose', 'media_attachments']).size,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
|
||||||
|
onClick () {
|
||||||
|
dispatch(changeComposeSensitivity());
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
class SensitiveButton extends PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
active: PropTypes.bool,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
mediaCount: PropTypes.number,
|
||||||
|
onClick: PropTypes.func.isRequired,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { active, disabled, mediaCount, 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, { count: mediaCount })}>
|
||||||
|
<input
|
||||||
|
name='mark-sensitive'
|
||||||
|
type='checkbox'
|
||||||
|
checked={active}
|
||||||
|
onChange={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormattedMessage
|
||||||
|
id='compose_form.sensitive.hide'
|
||||||
|
defaultMessage='{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}'
|
||||||
|
values={{ count: mediaCount }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import { injectIntl, defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import WarningIcon from '@/material-icons/400-20px/warning.svg?react';
|
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
|
||||||
|
|
||||||
import { changeComposeSpoilerness } from '../../../actions/compose';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
marked: { id: 'compose_form.spoiler.marked', defaultMessage: 'Text is hidden behind warning' },
|
|
||||||
unmarked: { id: 'compose_form.spoiler.unmarked', defaultMessage: 'Text is not hidden' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { intl }) => ({
|
|
||||||
iconComponent: WarningIcon,
|
|
||||||
title: intl.formatMessage(state.getIn(['compose', 'spoiler']) ? messages.marked : messages.unmarked),
|
|
||||||
active: state.getIn(['compose', 'spoiler']),
|
|
||||||
ariaControls: 'cw-spoiler-input',
|
|
||||||
size: 18,
|
|
||||||
inverted: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
|
||||||
|
|
||||||
onClick () {
|
|
||||||
dispatch(changeComposeSpoilerness());
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(IconButton));
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { uploadCompose } from '../../../actions/compose';
|
|
||||||
import { openModal } from '../../../actions/modal';
|
|
||||||
import UploadButton from '../components/upload_button';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
disabled: state.getIn(['compose', 'poll']) !== null || state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size + state.getIn(['compose', 'pending_media_attachments']) > 3 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))),
|
|
||||||
resetFileKey: state.getIn(['compose', 'resetFileKey']),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
|
||||||
|
|
||||||
onSelectFile (files) {
|
|
||||||
dispatch(uploadCompose(files));
|
|
||||||
},
|
|
||||||
|
|
||||||
onDoodleOpen() {
|
|
||||||
dispatch(openModal({
|
|
||||||
modalType: 'DOODLE',
|
|
||||||
modalProps: { noEsc: true, noClose: true },
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);
|
|
||||||
@ -5,7 +5,6 @@ import Upload from '../components/upload';
|
|||||||
|
|
||||||
const mapStateToProps = (state, { id }) => ({
|
const mapStateToProps = (state, { id }) => ({
|
||||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
||||||
sensitive: state.getIn(['compose', 'sensitive']),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { me } from 'flavours/glitch/initial_state';
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
|
import { profileLink, privacyPolicyLink } from 'flavours/glitch/utils/backend_links';
|
||||||
import { HASHTAG_PATTERN_REGEX } from 'flavours/glitch/utils/hashtags';
|
import { HASHTAG_PATTERN_REGEX } from 'flavours/glitch/utils/hashtags';
|
||||||
|
|
||||||
import Warning from '../components/warning';
|
import Warning from '../components/warning';
|
||||||
@ -17,7 +18,7 @@ const mapStateToProps = state => ({
|
|||||||
|
|
||||||
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
|
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
|
||||||
if (needsLockWarning) {
|
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> }} />} />;
|
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={profileLink}><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hashtagWarning) {
|
if (hashtagWarning) {
|
||||||
@ -27,7 +28,7 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning
|
|||||||
if (directMessageWarning) {
|
if (directMessageWarning) {
|
||||||
const message = (
|
const message = (
|
||||||
<span>
|
<span>
|
||||||
<FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>
|
<FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> {!!privacyPolicyLink && <a href={privacyPolicyLink} target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -3,180 +3,93 @@ import { PureComponent } from 'react';
|
|||||||
|
|
||||||
import { injectIntl, defineMessages } from 'react-intl';
|
import { injectIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import spring from 'react-motion/lib/spring';
|
import spring from 'react-motion/lib/spring';
|
||||||
|
|
||||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
import { mountCompose, unmountCompose, cycleElefriendCompose } from 'flavours/glitch/actions/compose';
|
||||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
|
||||||
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
|
|
||||||
import ManufacturingIcon from '@/material-icons/400-24px/manufacturing-fill.svg?react';
|
|
||||||
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
|
|
||||||
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
|
||||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
|
||||||
import { openModal } from 'flavours/glitch/actions/modal';
|
|
||||||
import Column from 'flavours/glitch/components/column';
|
import Column from 'flavours/glitch/components/column';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
|
||||||
import glitchedElephant1 from 'flavours/glitch/images/mbstobon-ui-0.png';
|
|
||||||
import glitchedElephant2 from 'flavours/glitch/images/mbstobon-ui-1.png';
|
|
||||||
import glitchedElephant3 from 'flavours/glitch/images/mbstobon-ui-2.png';
|
|
||||||
import { logOut } from 'flavours/glitch/utils/log_out';
|
|
||||||
|
|
||||||
import elephantUIPlane from '../../../../images/elephant_ui_plane.svg';
|
|
||||||
import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
|
|
||||||
import { mascot } from '../../initial_state';
|
import { mascot } from '../../initial_state';
|
||||||
import { isMobile } from '../../is_mobile';
|
|
||||||
import Motion from '../ui/util/optional_motion';
|
import Motion from '../ui/util/optional_motion';
|
||||||
|
|
||||||
import ComposeFormContainer from './containers/compose_form_container';
|
import ComposeFormContainer from './containers/compose_form_container';
|
||||||
|
import HeaderContainer from './containers/header_container';
|
||||||
|
import NavigationContainer from './containers/navigation_container';
|
||||||
import SearchContainer from './containers/search_container';
|
import SearchContainer from './containers/search_container';
|
||||||
import SearchResultsContainer from './containers/search_results_container';
|
import SearchResultsContainer from './containers/search_results_container';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
|
||||||
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
|
||||||
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
|
|
||||||
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
|
|
||||||
community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
|
|
||||||
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
|
|
||||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
|
||||||
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
|
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
|
||||||
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
|
|
||||||
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = (state, ownProps) => ({
|
const mapStateToProps = (state, ownProps) => ({
|
||||||
columns: state.getIn(['settings', 'columns']),
|
elefriend: state.getIn(['compose', 'elefriend']),
|
||||||
showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
|
showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
|
||||||
unreadNotifications: state.getIn(['notifications', 'unread']),
|
|
||||||
showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ~4% chance you'll end up with an unexpected friend
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
// glitch-soc/mastodon repo created_at date: 2017-04-20T21:55:28Z
|
onClickElefriend () {
|
||||||
const glitchProbability = 1 - 0.0420215528;
|
dispatch(cycleElefriendCompose());
|
||||||
const totalElefriends = 3;
|
},
|
||||||
|
|
||||||
|
onMount () {
|
||||||
|
dispatch(mountCompose());
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnmount () {
|
||||||
|
dispatch(unmountCompose());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
class Compose extends PureComponent {
|
class Compose extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
columns: ImmutablePropTypes.list.isRequired,
|
|
||||||
multiColumn: PropTypes.bool,
|
multiColumn: PropTypes.bool,
|
||||||
showSearch: PropTypes.bool,
|
showSearch: PropTypes.bool,
|
||||||
unreadNotifications: PropTypes.number,
|
elefriend: PropTypes.number,
|
||||||
showNotificationsBadge: PropTypes.bool,
|
onClickElefriend: PropTypes.func,
|
||||||
|
onMount: PropTypes.func,
|
||||||
|
onUnmount: PropTypes.func,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
|
||||||
elefriend: Math.random() < glitchProbability ? Math.floor(Math.random() * totalElefriends) : totalElefriends,
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
const { dispatch } = this.props;
|
this.props.onMount();
|
||||||
dispatch(mountCompose());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
const { dispatch } = this.props;
|
this.props.onUnmount();
|
||||||
dispatch(unmountCompose());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLogoutClick = e => {
|
|
||||||
const { dispatch, intl } = this.props;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
dispatch(openModal({
|
|
||||||
modalType: 'CONFIRM',
|
|
||||||
modalProps: {
|
|
||||||
message: intl.formatMessage(messages.logoutMessage),
|
|
||||||
confirm: intl.formatMessage(messages.logoutConfirm),
|
|
||||||
closeWhenConfirm: false,
|
|
||||||
onConfirm: () => logOut(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleSettingsClick = e => {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
dispatch(openModal({ modalType: 'SETTINGS', modalProps: {} }));
|
|
||||||
};
|
|
||||||
|
|
||||||
onFocus = () => {
|
|
||||||
this.props.dispatch(changeComposing(true));
|
|
||||||
};
|
|
||||||
|
|
||||||
onBlur = () => {
|
|
||||||
this.props.dispatch(changeComposing(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
cycleElefriend = () => {
|
|
||||||
this.setState((state) => ({ elefriend: (state.elefriend + 1) % totalElefriends }));
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { multiColumn, showSearch, showNotificationsBadge, unreadNotifications, intl } = this.props;
|
const {
|
||||||
|
elefriend,
|
||||||
const elefriend = [glitchedElephant1, glitchedElephant2, glitchedElephant3, elephantUIPlane][this.state.elefriend];
|
intl,
|
||||||
|
multiColumn,
|
||||||
|
onClickElefriend,
|
||||||
|
showSearch,
|
||||||
|
} = this.props;
|
||||||
|
const computedClass = classNames('drawer', `mbstobon-${elefriend}`);
|
||||||
|
|
||||||
if (multiColumn) {
|
if (multiColumn) {
|
||||||
const { columns } = this.props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='drawer' role='region' aria-label={intl.formatMessage(messages.compose)}>
|
<div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}>
|
||||||
<nav className='drawer__header'>
|
<HeaderContainer />
|
||||||
<Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><Icon id='bars' icon={MenuIcon} /></Link>
|
|
||||||
{!columns.some(column => column.get('id') === 'HOME') && (
|
|
||||||
<Link to='/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><Icon id='home' icon={HomeIcon} /></Link>
|
|
||||||
)}
|
|
||||||
{!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
|
|
||||||
<Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}>
|
|
||||||
<span className='icon-badge-wrapper'>
|
|
||||||
<Icon id='bell' icon={NotificationsIcon} />
|
|
||||||
{showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{!columns.some(column => column.get('id') === 'COMMUNITY') && (
|
|
||||||
<Link to='/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><Icon id='users' icon={PeopleIcon} /></Link>
|
|
||||||
)}
|
|
||||||
{!columns.some(column => column.get('id') === 'PUBLIC') && (
|
|
||||||
<Link to='/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' icon={PublicIcon} /></Link>
|
|
||||||
)}
|
|
||||||
<a
|
|
||||||
onClick={this.handleSettingsClick}
|
|
||||||
href='/settings/preferences'
|
|
||||||
className='drawer__tab'
|
|
||||||
title={intl.formatMessage(messages.settings)}
|
|
||||||
aria-label={intl.formatMessage(messages.settings)}
|
|
||||||
>
|
|
||||||
<Icon id='cogs' icon={ManufacturingIcon} />
|
|
||||||
</a>
|
|
||||||
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' icon={LogoutIcon} /></a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{multiColumn && <SearchContainer /> }
|
{multiColumn && <SearchContainer />}
|
||||||
|
|
||||||
<div className='drawer__pager'>
|
<div className='drawer__pager'>
|
||||||
<div className='drawer__inner' onFocus={this.onFocus}>
|
<div className='drawer__inner'>
|
||||||
<ComposeFormContainer autoFocus={!isMobile(window.innerWidth)} />
|
<NavigationContainer />
|
||||||
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- this is not a feature but a visual easter egg */}
|
<ComposeFormContainer />
|
||||||
<div className='drawer__inner__mastodon' onClick={this.cycleElefriend}>
|
|
||||||
<img alt='' draggable='false' src={mascot || elefriend} />
|
<div className='drawer__inner__mastodon'>
|
||||||
|
{mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -193,7 +106,8 @@ class Compose extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column onFocus={this.onFocus}>
|
<Column>
|
||||||
|
<NavigationContainer />
|
||||||
<ComposeFormContainer />
|
<ComposeFormContainer />
|
||||||
|
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@ -205,4 +119,4 @@ class Compose extends PureComponent {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(mapStateToProps)(injectIntl(Compose));
|
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Compose));
|
||||||
|
|||||||
@ -9,8 +9,8 @@ import { NavLink, Switch, Route } from 'react-router-dom';
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
|
||||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
|
||||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||||
|
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||||
import Column from 'flavours/glitch/components/column';
|
import Column from 'flavours/glitch/components/column';
|
||||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||||
import Search from 'flavours/glitch/features/compose/containers/search_container';
|
import Search from 'flavours/glitch/features/compose/containers/search_container';
|
||||||
@ -59,8 +59,8 @@ class Explore extends PureComponent {
|
|||||||
return (
|
return (
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||||
<ColumnHeader
|
<ColumnHeader
|
||||||
icon={isSearching ? 'search' : 'explore'}
|
icon={isSearching ? 'search' : 'hashtag'}
|
||||||
iconComponent={isSearching ? SearchIcon : ExploreIcon}
|
iconComponent={isSearching ? SearchIcon : TagIcon}
|
||||||
title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
|
title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
|
||||||
onClick={this.handleHeaderClick}
|
onClick={this.handleHeaderClick}
|
||||||
multiColumn={multiColumn}
|
multiColumn={multiColumn}
|
||||||
|
|||||||
@ -45,7 +45,6 @@ const mapStateToProps = (state, { params: { acct, id } }) => {
|
|||||||
hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
|
hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
|
||||||
isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
|
isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
|
||||||
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
|
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
|
||||||
hideCollections: state.getIn(['accounts', accountId, 'hide_collections'], false),
|
|
||||||
hidden: getAccountHidden(state, accountId),
|
hidden: getAccountHidden(state, accountId),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -118,7 +117,7 @@ class Followers extends ImmutablePureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl, hideCollections } = this.props;
|
const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
|
||||||
|
|
||||||
if (!isAccount) {
|
if (!isAccount) {
|
||||||
return (
|
return (
|
||||||
@ -142,8 +141,6 @@ class Followers extends ImmutablePureComponent {
|
|||||||
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
|
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
|
||||||
} else if (hidden) {
|
} else if (hidden) {
|
||||||
emptyMessage = <LimitedAccountHint accountId={accountId} />;
|
emptyMessage = <LimitedAccountHint accountId={accountId} />;
|
||||||
} else if (hideCollections && accountIds.isEmpty()) {
|
|
||||||
emptyMessage = <FormattedMessage id='empty_column.account_hides_collections' defaultMessage='This user has chosen to not make this information available' />;
|
|
||||||
} else if (remote && accountIds.isEmpty()) {
|
} else if (remote && accountIds.isEmpty()) {
|
||||||
emptyMessage = <RemoteHint url={remoteUrl} />;
|
emptyMessage = <RemoteHint url={remoteUrl} />;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -45,7 +45,6 @@ const mapStateToProps = (state, { params: { acct, id } }) => {
|
|||||||
hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
|
hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
|
||||||
isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
|
isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
|
||||||
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
|
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
|
||||||
hideCollections: state.getIn(['accounts', accountId, 'hide_collections'], false),
|
|
||||||
hidden: getAccountHidden(state, accountId),
|
hidden: getAccountHidden(state, accountId),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -118,7 +117,7 @@ class Following extends ImmutablePureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl, hideCollections } = this.props;
|
const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
|
||||||
|
|
||||||
if (!isAccount) {
|
if (!isAccount) {
|
||||||
return (
|
return (
|
||||||
@ -142,8 +141,6 @@ class Following extends ImmutablePureComponent {
|
|||||||
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
|
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
|
||||||
} else if (hidden) {
|
} else if (hidden) {
|
||||||
emptyMessage = <LimitedAccountHint accountId={accountId} />;
|
emptyMessage = <LimitedAccountHint accountId={accountId} />;
|
||||||
} else if (hideCollections && accountIds.isEmpty()) {
|
|
||||||
emptyMessage = <FormattedMessage id='empty_column.account_hides_collections' defaultMessage='This user has chosen to not make this information available' />;
|
|
||||||
} else if (remote && accountIds.isEmpty()) {
|
} else if (remote && accountIds.isEmpty()) {
|
||||||
emptyMessage = <RemoteHint url={remoteUrl} />;
|
emptyMessage = <RemoteHint url={remoteUrl} />;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -32,7 +32,7 @@ import { preferencesLink } from 'flavours/glitch/utils/backend_links';
|
|||||||
|
|
||||||
|
|
||||||
import { me, showTrends } from '../../initial_state';
|
import { me, showTrends } from '../../initial_state';
|
||||||
import { NavigationBar } from '../compose/components/navigation_bar';
|
import NavigationBar from '../compose/components/navigation_bar';
|
||||||
import ColumnLink from '../ui/components/column_link';
|
import ColumnLink from '../ui/components/column_link';
|
||||||
import ColumnSubheading from '../ui/components/column_subheading';
|
import ColumnSubheading from '../ui/components/column_subheading';
|
||||||
|
|
||||||
@ -158,7 +158,7 @@ class GettingStarted extends ImmutablePureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showTrends) {
|
if (showTrends) {
|
||||||
navItems.push(<ColumnLink key='explore' icon='explore' iconComponent={TagIcon} text={intl.formatMessage(messages.explore)} to='/explore' />);
|
navItems.push(<ColumnLink key='explore' icon='hashtag' iconComponent={TagIcon} text={intl.formatMessage(messages.explore)} to='/explore' />);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signedIn) {
|
if (signedIn) {
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import background from '@/images/friends-cropped.png';
|
||||||
|
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
|
||||||
|
|
||||||
|
export const ExplorePrompt = () => (
|
||||||
|
<DismissableBanner id='home.explore_prompt'>
|
||||||
|
<img
|
||||||
|
src={background}
|
||||||
|
alt=''
|
||||||
|
className='dismissable-banner__background-image'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.explore_prompt.title'
|
||||||
|
defaultMessage='This is your home base within Mastodon.'
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.explore_prompt.body'
|
||||||
|
defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. If that feels too quiet, you may want to:"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='dismissable-banner__message__wrapper'>
|
||||||
|
<div className='dismissable-banner__message__actions'>
|
||||||
|
<Link to='/explore' className='button'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.actions.go_to_explore'
|
||||||
|
defaultMessage="See what's trending"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Link to='/explore/suggestions' className='button button-tertiary'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='home.actions.go_to_suggestions'
|
||||||
|
defaultMessage='Find people to follow'
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DismissableBanner>
|
||||||
|
);
|
||||||
@ -1,218 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
|
||||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
|
||||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
|
||||||
import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts';
|
|
||||||
import { changeSetting } from 'flavours/glitch/actions/settings';
|
|
||||||
import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
|
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
|
||||||
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
|
||||||
import { domain } from 'flavours/glitch/initial_state';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
|
||||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
|
||||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
|
||||||
dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
|
|
||||||
friendsOfFriendsHint: { id: 'follow_suggestions.hints.friends_of_friends', defaultMessage: 'This profile is popular among the people you follow.' },
|
|
||||||
similarToRecentlyFollowedHint: { id: 'follow_suggestions.hints.similar_to_recently_followed', defaultMessage: 'This profile is similar to the profiles you have most recently followed.' },
|
|
||||||
featuredHint: { id: 'follow_suggestions.hints.featured', defaultMessage: 'This profile has been hand-picked by the {domain} team.' },
|
|
||||||
mostFollowedHint: { id: 'follow_suggestions.hints.most_followed', defaultMessage: 'This profile is one of the most followed on {domain}.'},
|
|
||||||
mostInteractionsHint: { id: 'follow_suggestions.hints.most_interactions', defaultMessage: 'This profile has been recently getting a lot of attention on {domain}.' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const Source = ({ id }) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
let label, hint;
|
|
||||||
|
|
||||||
switch (id) {
|
|
||||||
case 'friends_of_friends':
|
|
||||||
hint = intl.formatMessage(messages.friendsOfFriendsHint);
|
|
||||||
label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
|
|
||||||
break;
|
|
||||||
case 'similar_to_recently_followed':
|
|
||||||
hint = intl.formatMessage(messages.similarToRecentlyFollowedHint);
|
|
||||||
label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
|
|
||||||
break;
|
|
||||||
case 'featured':
|
|
||||||
hint = intl.formatMessage(messages.featuredHint, { domain });
|
|
||||||
label = <FormattedMessage id='follow_suggestions.curated_suggestion' defaultMessage='Staff pick' />;
|
|
||||||
break;
|
|
||||||
case 'most_followed':
|
|
||||||
hint = intl.formatMessage(messages.mostFollowedHint, { domain });
|
|
||||||
label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
|
|
||||||
break;
|
|
||||||
case 'most_interactions':
|
|
||||||
hint = intl.formatMessage(messages.mostInteractionsHint, { domain });
|
|
||||||
label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='inline-follow-suggestions__body__scrollable__card__text-stack__source' title={hint}>
|
|
||||||
<Icon icon={InfoIcon} />
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Source.propTypes = {
|
|
||||||
id: PropTypes.oneOf(['friends_of_friends', 'similar_to_recently_followed', 'featured', 'most_followed', 'most_interactions']),
|
|
||||||
};
|
|
||||||
|
|
||||||
const Card = ({ id, sources }) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const account = useSelector(state => state.getIn(['accounts', id]));
|
|
||||||
const relationship = useSelector(state => state.getIn(['relationships', id]));
|
|
||||||
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const following = relationship?.get('following') ?? relationship?.get('requested');
|
|
||||||
|
|
||||||
const handleFollow = useCallback(() => {
|
|
||||||
if (following) {
|
|
||||||
dispatch(unfollowAccount(id));
|
|
||||||
} else {
|
|
||||||
dispatch(followAccount(id));
|
|
||||||
}
|
|
||||||
}, [id, following, dispatch]);
|
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
|
||||||
dispatch(dismissSuggestion(id));
|
|
||||||
}, [id, dispatch]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='inline-follow-suggestions__body__scrollable__card'>
|
|
||||||
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
|
|
||||||
|
|
||||||
<div className='inline-follow-suggestions__body__scrollable__card__avatar'>
|
|
||||||
<Link to={`/@${account.get('acct')}`}><Avatar account={account} size={72} /></Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='inline-follow-suggestions__body__scrollable__card__text-stack'>
|
|
||||||
<Link to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
|
|
||||||
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Card.propTypes = {
|
|
||||||
id: PropTypes.string.isRequired,
|
|
||||||
sources: ImmutablePropTypes.list,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DISMISSIBLE_ID = 'home/follow-suggestions';
|
|
||||||
|
|
||||||
export const InlineFollowSuggestions = ({ hidden }) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const suggestions = useSelector(state => state.getIn(['suggestions', 'items']));
|
|
||||||
const isLoading = useSelector(state => state.getIn(['suggestions', 'isLoading']));
|
|
||||||
const dismissed = useSelector(state => state.getIn(['settings', 'dismissed_banners', DISMISSIBLE_ID]));
|
|
||||||
const bodyRef = useRef();
|
|
||||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
|
||||||
const [canScrollRight, setCanScrollRight] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchSuggestions());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!bodyRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
|
||||||
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
|
|
||||||
}, [setCanScrollRight, setCanScrollLeft, bodyRef, suggestions]);
|
|
||||||
|
|
||||||
const handleLeftNav = useCallback(() => {
|
|
||||||
bodyRef.current.scrollLeft -= 200;
|
|
||||||
}, [bodyRef]);
|
|
||||||
|
|
||||||
const handleRightNav = useCallback(() => {
|
|
||||||
bodyRef.current.scrollLeft += 200;
|
|
||||||
}, [bodyRef]);
|
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
if (!bodyRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
|
||||||
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
|
|
||||||
}, [setCanScrollRight, setCanScrollLeft, bodyRef]);
|
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
|
||||||
dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true));
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
if (dismissed || (!isLoading && suggestions.isEmpty())) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hidden) {
|
|
||||||
return (
|
|
||||||
<div className='inline-follow-suggestions' />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='inline-follow-suggestions'>
|
|
||||||
<div className='inline-follow-suggestions__header'>
|
|
||||||
<h3><FormattedMessage id='follow_suggestions.who_to_follow' defaultMessage='Who to follow' /></h3>
|
|
||||||
|
|
||||||
<div className='inline-follow-suggestions__header__actions'>
|
|
||||||
<button className='link-button' onClick={handleDismiss}><FormattedMessage id='follow_suggestions.dismiss' defaultMessage="Don't show again" /></button>
|
|
||||||
<Link to='/explore/suggestions' className='link-button'><FormattedMessage id='follow_suggestions.view_all' defaultMessage='View all' /></Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='inline-follow-suggestions__body'>
|
|
||||||
<div className='inline-follow-suggestions__body__scrollable' ref={bodyRef} onScroll={handleScroll}>
|
|
||||||
{suggestions.map(suggestion => (
|
|
||||||
<Card
|
|
||||||
key={suggestion.get('account')}
|
|
||||||
id={suggestion.get('account')}
|
|
||||||
sources={suggestion.get('sources')}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canScrollLeft && (
|
|
||||||
<button className='inline-follow-suggestions__body__scroll-button left' onClick={handleLeftNav} aria-label={intl.formatMessage(messages.previous)}>
|
|
||||||
<div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronLeftIcon} /></div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canScrollRight && (
|
|
||||||
<button className='inline-follow-suggestions__body__scroll-button right' onClick={handleRightNav} aria-label={intl.formatMessage(messages.next)}>
|
|
||||||
<div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronRightIcon} /></div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
InlineFollowSuggestions.propTypes = {
|
|
||||||
hidden: PropTypes.bool,
|
|
||||||
};
|
|
||||||
@ -6,6 +6,8 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
|
|
||||||
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
|
||||||
@ -15,7 +17,7 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/act
|
|||||||
import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge';
|
import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge';
|
||||||
import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator';
|
import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator';
|
||||||
import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container';
|
import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container';
|
||||||
import { criticalUpdatesPending } from 'flavours/glitch/initial_state';
|
import { me, criticalUpdatesPending } from 'flavours/glitch/initial_state';
|
||||||
|
|
||||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||||
import { expandHomeTimeline } from '../../actions/timelines';
|
import { expandHomeTimeline } from '../../actions/timelines';
|
||||||
@ -25,6 +27,7 @@ import StatusListContainer from '../ui/containers/status_list_container';
|
|||||||
|
|
||||||
import { ColumnSettings } from './components/column_settings';
|
import { ColumnSettings } from './components/column_settings';
|
||||||
import { CriticalUpdateBanner } from './components/critical_update_banner';
|
import { CriticalUpdateBanner } from './components/critical_update_banner';
|
||||||
|
import { ExplorePrompt } from './components/explore_prompt';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||||
@ -32,12 +35,51 @@ const messages = defineMessages({
|
|||||||
hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
|
hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getHomeFeedSpeed = createSelector([
|
||||||
|
state => state.getIn(['timelines', 'home', 'items'], ImmutableList()),
|
||||||
|
state => state.getIn(['timelines', 'home', 'pendingItems'], ImmutableList()),
|
||||||
|
state => state.get('statuses'),
|
||||||
|
], (statusIds, pendingStatusIds, statusMap) => {
|
||||||
|
const recentStatusIds = pendingStatusIds.concat(statusIds);
|
||||||
|
const statuses = recentStatusIds.filter(id => id !== null).map(id => statusMap.get(id)).filter(status => status?.get('account') !== me).take(20);
|
||||||
|
|
||||||
|
if (statuses.isEmpty()) {
|
||||||
|
return {
|
||||||
|
gap: 0,
|
||||||
|
newest: new Date(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const datetimes = statuses.map(status => status.get('created_at', 0));
|
||||||
|
const oldest = new Date(datetimes.min());
|
||||||
|
const newest = new Date(datetimes.max());
|
||||||
|
const averageGap = (newest - oldest) / (1000 * (statuses.size + 1)); // Average gap between posts on first page in seconds
|
||||||
|
|
||||||
|
return {
|
||||||
|
gap: averageGap,
|
||||||
|
newest,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const homeTooSlow = createSelector([
|
||||||
|
state => state.getIn(['timelines', 'home', 'isLoading']),
|
||||||
|
state => state.getIn(['timelines', 'home', 'isPartial']),
|
||||||
|
getHomeFeedSpeed,
|
||||||
|
], (isLoading, isPartial, speed) =>
|
||||||
|
!isLoading && !isPartial // Only if the home feed has finished loading
|
||||||
|
&& (
|
||||||
|
(speed.gap > (30 * 60) // If the average gap between posts is more than 30 minutes
|
||||||
|
|| (Date.now() - speed.newest) > (1000 * 3600)) // If the most recent post is from over an hour ago
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
|
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
|
||||||
isPartial: state.getIn(['timelines', 'home', 'isPartial']),
|
isPartial: state.getIn(['timelines', 'home', 'isPartial']),
|
||||||
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
|
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
|
||||||
unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
|
unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
|
||||||
showAnnouncements: state.getIn(['announcements', 'show']),
|
showAnnouncements: state.getIn(['announcements', 'show']),
|
||||||
|
tooSlow: homeTooSlow(state),
|
||||||
regex: state.getIn(['settings', 'home', 'regex', 'body']),
|
regex: state.getIn(['settings', 'home', 'regex', 'body']),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -57,6 +99,7 @@ class HomeTimeline extends PureComponent {
|
|||||||
hasAnnouncements: PropTypes.bool,
|
hasAnnouncements: PropTypes.bool,
|
||||||
unreadAnnouncements: PropTypes.number,
|
unreadAnnouncements: PropTypes.number,
|
||||||
showAnnouncements: PropTypes.bool,
|
showAnnouncements: PropTypes.bool,
|
||||||
|
tooSlow: PropTypes.bool,
|
||||||
regex: PropTypes.string,
|
regex: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -127,7 +170,7 @@ class HomeTimeline extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
|
const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
|
||||||
const pinned = !!columnId;
|
const pinned = !!columnId;
|
||||||
const { signedIn } = this.context.identity;
|
const { signedIn } = this.context.identity;
|
||||||
const banners = [];
|
const banners = [];
|
||||||
@ -151,6 +194,10 @@ class HomeTimeline extends PureComponent {
|
|||||||
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
|
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tooSlow) {
|
||||||
|
banners.push(<ExplorePrompt key='explore-prompt' />);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||||
<ColumnHeader
|
<ColumnHeader
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user