Merge branch 'main' of https://github.com/glitch-soc/mastodon
This commit is contained in:
commit
cf5ac16762
@ -70,7 +70,7 @@ services:
|
|||||||
hard: -1
|
hard: -1
|
||||||
|
|
||||||
libretranslate:
|
libretranslate:
|
||||||
image: libretranslate/libretranslate:v1.5.4
|
image: libretranslate/libretranslate:v1.5.5
|
||||||
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': 'warn',
|
'jsx-a11y/no-onchange': 'off',
|
||||||
// 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@v3
|
- uses: actions/cache@v4
|
||||||
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 }}
|
||||||
|
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@v3
|
uses: codecov/codecov-action@v4
|
||||||
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/screenshots/
|
path: tmp/capybara/
|
||||||
|
|
||||||
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/screenshots/
|
path: tmp/capybara/
|
||||||
|
@ -1,4 +1 @@
|
|||||||
#!/bin/sh
|
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
yarn lint-staged
|
yarn lint-staged
|
||||||
|
26
.rubocop.yml
26
.rubocop.yml
@ -96,13 +96,6 @@ 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:
|
||||||
@ -135,6 +128,11 @@ 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:
|
||||||
@ -175,6 +173,15 @@ 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:
|
||||||
@ -203,11 +210,6 @@ 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: 22
|
Max: 20 # Override default of 5
|
||||||
|
|
||||||
RSpec/MultipleExpectations:
|
RSpec/MultipleExpectations:
|
||||||
Max: 8
|
Max: 7
|
||||||
|
|
||||||
# Configuration parameters: AllowSubject.
|
# Configuration parameters: AllowSubject.
|
||||||
RSpec/MultipleMemoizedHelpers:
|
RSpec/MultipleMemoizedHelpers:
|
||||||
|
12
Gemfile
12
Gemfile
@ -125,12 +125,6 @@ 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'
|
||||||
|
|
||||||
@ -182,6 +176,12 @@ 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
|
||||||
|
@ -793,7 +793,7 @@ GEM
|
|||||||
webfinger (1.2.0)
|
webfinger (1.2.0)
|
||||||
activesupport
|
activesupport
|
||||||
httpclient (>= 2.4)
|
httpclient (>= 2.4)
|
||||||
webmock (3.21.2)
|
webmock (3.22.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)
|
||||||
|
@ -62,11 +62,10 @@ 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
|
||||||
tree = SignatureParamsParser.new.parse(raw_params)
|
params = SignatureParser.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 Parslet::ParseFailed
|
rescue SignatureParser::ParsingError
|
||||||
Rails.logger.warn 'Error parsing Collection-Synchronization header'
|
Rails.logger.warn 'Error parsing Collection-Synchronization header'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -35,6 +35,7 @@ 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,13 +72,9 @@ class Api::V1::StatusesController < Api::BaseController
|
|||||||
with_rate_limit: true
|
with_rate_limit: true
|
||||||
)
|
)
|
||||||
|
|
||||||
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
|
render json: @status, serializer: serializer_for_status
|
||||||
rescue PostStatusService::UnexpectedMentionsError => e
|
rescue PostStatusService::UnexpectedMentionsError => e
|
||||||
unexpected_accounts = ActiveModel::Serializer::CollectionSerializer.new(
|
render json: unexpected_accounts_error_json(e), status: 422
|
||||||
e.accounts,
|
|
||||||
serializer: REST::AccountSerializer
|
|
||||||
)
|
|
||||||
render json: { error: e.message, unexpected_accounts: unexpected_accounts }, status: 422
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@ -158,6 +154,21 @@ 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
|
||||||
|
@ -188,7 +188,9 @@ 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.set("2fa_failure_notification:#{user.id}", '1', ex: 1.hour, get: true).present?
|
return if redis.get("2fa_failure_notification:#{user.id}").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,39 +12,6 @@ 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
|
||||||
@ -135,12 +102,8 @@ module SignatureVerification
|
|||||||
end
|
end
|
||||||
|
|
||||||
def signature_params
|
def signature_params
|
||||||
@signature_params ||= begin
|
@signature_params ||= SignatureParser.parse(request.headers['Signature'])
|
||||||
raw_signature = request.headers['Signature']
|
rescue SignatureParser::ParsingError
|
||||||
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.where(highlighted: true).where.not(color: [nil, ''])
|
@user_roles = UserRole.providing_styles
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -1,27 +1,26 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class IntentsController < ApplicationController
|
class IntentsController < ApplicationController
|
||||||
before_action :check_uri
|
EXPECTED_SCHEME = 'web+mastodon'
|
||||||
|
|
||||||
|
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
|
||||||
if uri.scheme == 'web+mastodon'
|
case uri.host
|
||||||
case uri.host
|
when 'follow'
|
||||||
when 'follow'
|
redirect_to authorize_interaction_path(uri: uri.query_values['uri'].delete_prefix('acct:'))
|
||||||
return redirect_to authorize_interaction_path(uri: uri.query_values['uri'].delete_prefix('acct:'))
|
when 'share'
|
||||||
when 'share'
|
redirect_to share_path(text: uri.query_values['text'])
|
||||||
return redirect_to share_path(text: uri.query_values['text'])
|
else
|
||||||
end
|
handle_invalid_uri
|
||||||
end
|
end
|
||||||
|
|
||||||
not_found
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def check_uri
|
def valid_uri?
|
||||||
not_found if uri.blank?
|
uri.present? && uri.scheme == EXPECTED_SCHEME
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_invalid_uri
|
def handle_invalid_uri
|
||||||
|
@ -15,9 +15,20 @@ 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
|
||||||
|
@ -144,6 +144,10 @@ 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,7 +21,6 @@ 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';
|
||||||
@ -59,7 +58,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_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
|
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_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';
|
||||||
|
|
||||||
@ -117,12 +116,6 @@ 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']);
|
||||||
@ -148,13 +141,13 @@ export function resetCompose() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const focusCompose = (routerHistory, defaultText) => dispatch => {
|
export const focusCompose = (routerHistory, defaultText) => (dispatch, getState) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: COMPOSE_FOCUS,
|
type: COMPOSE_FOCUS,
|
||||||
defaultText,
|
defaultText,
|
||||||
});
|
});
|
||||||
|
|
||||||
ensureComposeIsVisible(routerHistory);
|
ensureComposeIsVisible(getState, routerHistory);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function mentionCompose(account, routerHistory) {
|
export function mentionCompose(account, routerHistory) {
|
||||||
@ -179,7 +172,7 @@ export function directCompose(account, routerHistory) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submitCompose(routerHistory) {
|
export function submitCompose(routerHistory, overridePrivacy = null) {
|
||||||
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']);
|
||||||
@ -228,7 +221,7 @@ export function submitCompose(routerHistory) {
|
|||||||
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: getState().getIn(['compose', 'privacy']),
|
visibility: overridePrivacy || 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']),
|
||||||
},
|
},
|
||||||
@ -246,11 +239,6 @@ export function submitCompose(routerHistory) {
|
|||||||
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 => {
|
||||||
@ -660,15 +648,19 @@ 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;
|
let completion, startPosition;
|
||||||
|
|
||||||
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
|
||||||
@ -676,7 +668,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,
|
position: startPosition,
|
||||||
token,
|
token,
|
||||||
completion,
|
completion,
|
||||||
path,
|
path,
|
||||||
@ -684,7 +676,7 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
|
|||||||
} else {
|
} else {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: COMPOSE_SUGGESTION_IGNORE,
|
type: COMPOSE_SUGGESTION_IGNORE,
|
||||||
position,
|
position: startPosition,
|
||||||
token,
|
token,
|
||||||
completion,
|
completion,
|
||||||
path,
|
path,
|
||||||
@ -786,18 +778,26 @@ export function changeComposeVisibility(value) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changeComposeContentType(value) {
|
export function insertEmojiCompose(position, emoji, needsSpace) {
|
||||||
return {
|
|
||||||
type: COMPOSE_CONTENT_TYPE_CHANGE,
|
|
||||||
value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function insertEmojiCompose(position, emoji) {
|
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_EMOJI_INSERT,
|
type: COMPOSE_EMOJI_INSERT,
|
||||||
position,
|
position,
|
||||||
emoji,
|
emoji,
|
||||||
|
needsSpace,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changeComposing(value) {
|
||||||
|
return {
|
||||||
|
type: COMPOSE_COMPOSING_CHANGE,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changeComposeContentType(value) {
|
||||||
|
return {
|
||||||
|
type: COMPOSE_CONTENT_TYPE_CHANGE,
|
||||||
|
value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,12 +54,5 @@ export const dismissSuggestion = accountId => (dispatch, getState) => {
|
|||||||
id: accountId,
|
id: accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
api(getState).delete(`/api/v1/suggestions/${accountId}`).then(() => {
|
api(getState).delete(`/api/v1/suggestions/${accountId}`).catch(() => {});
|
||||||
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,6 +22,10 @@ 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,
|
||||||
@ -123,9 +127,19 @@ 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());
|
||||||
}
|
}
|
||||||
@ -233,3 +247,10 @@ 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,
|
||||||
|
});
|
||||||
|
@ -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.isRequired,
|
onFollow: PropTypes.func,
|
||||||
onBlock: PropTypes.func.isRequired,
|
onBlock: PropTypes.func,
|
||||||
onMute: PropTypes.func.isRequired,
|
onMute: PropTypes.func,
|
||||||
onMuteNotifications: PropTypes.func.isRequired,
|
onMuteNotifications: PropTypes.func,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
hidden: PropTypes.bool,
|
hidden: PropTypes.bool,
|
||||||
minimal: PropTypes.bool,
|
minimal: PropTypes.bool,
|
||||||
|
@ -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,
|
rule_ids: category === 'violation' ? 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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{emoji.colons}
|
<div className='autosuggest-emoji__name'>{emoji.colons}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
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 {
|
||||||
@ -16,27 +14,18 @@ interface Props {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => {
|
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => (
|
||||||
const weeklyUses = tag.history && (
|
<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>
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='autosuggest-hashtag'>
|
|
||||||
<div className='autosuggest-hashtag__name'>
|
|
||||||
#<strong>{tag.name}</strong>
|
|
||||||
</div>
|
|
||||||
{tag.history !== undefined && (
|
|
||||||
<div className='autosuggest-hashtag__uses'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='autosuggest_hashtag.per_week'
|
|
||||||
defaultMessage='{count} per week'
|
|
||||||
values={{ count: weeklyUses }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
};
|
{tag.history !== undefined && (
|
||||||
|
<div className='autosuggest-hashtag__uses'>
|
||||||
|
<ShortNumber
|
||||||
|
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
@ -5,6 +5,8 @@ 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';
|
||||||
@ -13,8 +15,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\u200B]+$/);
|
let left = str.slice(0, caretPosition).search(/\S+$/);
|
||||||
let right = str.slice(caretPosition).search(/[\s\u200B]/);
|
let right = str.slice(caretPosition).search(/\s/);
|
||||||
|
|
||||||
if (right < 0) {
|
if (right < 0) {
|
||||||
word = str.slice(left);
|
word = str.slice(left);
|
||||||
@ -29,7 +31,7 @@ const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
|||||||
word = word.trim().toLowerCase();
|
word = word.trim().toLowerCase();
|
||||||
|
|
||||||
if (word.length > 0) {
|
if (word.length > 0) {
|
||||||
return [left, word];
|
return [left + 1, word];
|
||||||
} else {
|
} else {
|
||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
}
|
||||||
@ -195,34 +197,37 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='autosuggest-input'>
|
<div className='autosuggest-input'>
|
||||||
<label>
|
<input
|
||||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
type='text'
|
||||||
|
ref={this.setInput}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
value={value}
|
||||||
|
onChange={this.onChange}
|
||||||
|
onKeyDown={this.onKeyDown}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
onFocus={this.onFocus}
|
||||||
|
onBlur={this.onBlur}
|
||||||
|
dir='auto'
|
||||||
|
aria-autocomplete='list'
|
||||||
|
aria-label={placeholder}
|
||||||
|
id={id}
|
||||||
|
className={className}
|
||||||
|
maxLength={maxLength}
|
||||||
|
lang={lang}
|
||||||
|
spellCheck={spellCheck}
|
||||||
|
/>
|
||||||
|
|
||||||
<input
|
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={this.input} popperConfig={{ strategy: 'fixed' }}>
|
||||||
type='text'
|
{({ props }) => (
|
||||||
ref={this.setInput}
|
<div {...props}>
|
||||||
disabled={disabled}
|
<div className='autosuggest-textarea__suggestions' style={{ width: this.input?.clientWidth }}>
|
||||||
placeholder={placeholder}
|
{suggestions.map(this.renderSuggestion)}
|
||||||
autoFocus={autoFocus}
|
</div>
|
||||||
value={value}
|
</div>
|
||||||
onChange={this.onChange}
|
)}
|
||||||
onKeyDown={this.onKeyDown}
|
</Overlay>
|
||||||
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,6 +5,7 @@ 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';
|
||||||
@ -15,8 +16,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\u200B]+$/);
|
let left = str.slice(0, caretPosition).search(/\S+$/);
|
||||||
let right = str.slice(caretPosition).search(/[\s\u200B]/);
|
let right = str.slice(caretPosition).search(/\s/);
|
||||||
|
|
||||||
if (right < 0) {
|
if (right < 0) {
|
||||||
word = str.slice(left);
|
word = str.slice(left);
|
||||||
@ -31,7 +32,7 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
|
|||||||
word = word.trim().toLowerCase();
|
word = word.trim().toLowerCase();
|
||||||
|
|
||||||
if (word.length > 0) {
|
if (word.length > 0) {
|
||||||
return [left, word];
|
return [left + 1, word];
|
||||||
} else {
|
} else {
|
||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
}
|
||||||
@ -52,7 +53,6 @@ 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,40 +183,38 @@ const AutosuggestTextarea = forwardRef(({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return [
|
return (
|
||||||
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
|
<div className='autosuggest-textarea'>
|
||||||
<div className='autosuggest-textarea'>
|
<Textarea
|
||||||
<label>
|
ref={textareaRef}
|
||||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
className='autosuggest-textarea__textarea'
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
<Textarea
|
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
|
||||||
ref={textareaRef}
|
{({ props }) => (
|
||||||
className='autosuggest-textarea__textarea'
|
<div {...props}>
|
||||||
disabled={disabled}
|
<div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
|
||||||
placeholder={placeholder}
|
{suggestions.map(renderSuggestion)}
|
||||||
autoFocus={autoFocus}
|
</div>
|
||||||
value={value}
|
</div>
|
||||||
onChange={handleChange}
|
)}
|
||||||
onKeyDown={handleKeyDown}
|
</Overlay>
|
||||||
onKeyUp={onKeyUp}
|
</div>
|
||||||
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 = {
|
||||||
@ -232,7 +230,6 @@ 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,
|
||||||
};
|
};
|
||||||
|
@ -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]).isRequired,
|
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]),
|
||||||
loading: PropTypes.bool,
|
loading: PropTypes.bool,
|
||||||
size: PropTypes.number,
|
size: PropTypes.number,
|
||||||
title: PropTypes.string,
|
title: PropTypes.string,
|
||||||
|
@ -5,7 +5,9 @@ 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';
|
||||||
|
|
||||||
@ -92,24 +94,37 @@ export default class StatusList extends ImmutablePureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let scrollableContent = (isLoading || statusIds.size > 0) ? (
|
let scrollableContent = (isLoading || statusIds.size > 0) ? (
|
||||||
statusIds.map((statusId, index) => statusId === null ? (
|
statusIds.map((statusId, index) => {
|
||||||
<LoadGap
|
switch(statusId) {
|
||||||
key={'gap:' + statusIds.get(index + 1)}
|
case TIMELINE_SUGGESTIONS:
|
||||||
disabled={isLoading}
|
return (
|
||||||
maxId={index > 0 ? statusIds.get(index - 1) : null}
|
<InlineFollowSuggestions
|
||||||
onClick={onLoadMore}
|
key='inline-follow-suggestions'
|
||||||
/>
|
/>
|
||||||
) : (
|
);
|
||||||
<StatusContainer
|
case TIMELINE_GAP:
|
||||||
key={statusId}
|
return (
|
||||||
id={statusId}
|
<LoadGap
|
||||||
onMoveUp={this.handleMoveUp}
|
key={'gap:' + statusIds.get(index + 1)}
|
||||||
onMoveDown={this.handleMoveDown}
|
disabled={isLoading}
|
||||||
contextType={timelineId}
|
maxId={index > 0 ? statusIds.get(index - 1) : null}
|
||||||
scrollKey={this.props.scrollKey}
|
onClick={onLoadMore}
|
||||||
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) {
|
||||||
|
@ -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,14 +11,17 @@ 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: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
unlisted_short: {
|
||||||
|
id: 'privacy.unlisted.short',
|
||||||
|
defaultMessage: 'Quiet public',
|
||||||
|
},
|
||||||
private_short: {
|
private_short: {
|
||||||
id: 'privacy.private.short',
|
id: 'privacy.private.short',
|
||||||
defaultMessage: 'Followers only',
|
defaultMessage: 'Followers',
|
||||||
},
|
},
|
||||||
direct_short: {
|
direct_short: {
|
||||||
id: 'privacy.direct.short',
|
id: 'privacy.direct.short',
|
||||||
defaultMessage: 'Mentioned people only',
|
defaultMessage: 'Specific people',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -35,7 +38,7 @@ export const VisibilityIcon: React.FC<{ visibility: Visibility }> = ({
|
|||||||
},
|
},
|
||||||
unlisted: {
|
unlisted: {
|
||||||
icon: 'unlock',
|
icon: 'unlock',
|
||||||
iconComponent: LockOpenIcon,
|
iconComponent: QuietTimeIcon,
|
||||||
text: intl.formatMessage(messages.unlisted_short),
|
text: intl.formatMessage(messages.unlisted_short),
|
||||||
},
|
},
|
||||||
private: {
|
private: {
|
||||||
|
@ -1,14 +1,12 @@
|
|||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
import { fetchCustomEmojis } from '../actions/custom_emojis';
|
import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
|
||||||
import { hydrateStore } from '../actions/store';
|
import { hydrateStore } from 'flavours/glitch/actions/store';
|
||||||
import Compose from '../features/standalone/compose';
|
import { Router } from 'flavours/glitch/components/router';
|
||||||
import initialState from '../initial_state';
|
import Compose from 'flavours/glitch/features/standalone/compose';
|
||||||
import { IntlProvider } from '../locales';
|
import initialState from 'flavours/glitch/initial_state';
|
||||||
import { store } from '../store';
|
import { IntlProvider } from 'flavours/glitch/locales';
|
||||||
|
import { store } from 'flavours/glitch/store';
|
||||||
|
|
||||||
if (initialState) {
|
if (initialState) {
|
||||||
store.dispatch(hydrateStore(initialState));
|
store.dispatch(hydrateStore(initialState));
|
||||||
@ -16,16 +14,14 @@ if (initialState) {
|
|||||||
|
|
||||||
store.dispatch(fetchCustomEmojis());
|
store.dispatch(fetchCustomEmojis());
|
||||||
|
|
||||||
export default class ComposeContainer extends PureComponent {
|
const ComposeContainer = () => (
|
||||||
|
<IntlProvider>
|
||||||
|
<Provider store={store}>
|
||||||
|
<Router>
|
||||||
|
<Compose />
|
||||||
|
</Router>
|
||||||
|
</Provider>
|
||||||
|
</IntlProvider>
|
||||||
|
);
|
||||||
|
|
||||||
render () {
|
export default ComposeContainer;
|
||||||
return (
|
|
||||||
<IntlProvider>
|
|
||||||
<Provider store={store}>
|
|
||||||
<Compose />
|
|
||||||
</Provider>
|
|
||||||
</IntlProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
@ -1,15 +1,13 @@
|
|||||||
import PropTypes from 'prop-types';
|
import { useCallback } from 'react';
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import { useDispatch } from 'react-redux';
|
||||||
|
|
||||||
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import { preferencesLink, profileLink } from 'flavours/glitch/utils/backend_links';
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
|
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' },
|
||||||
@ -25,51 +23,52 @@ 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' },
|
||||||
});
|
});
|
||||||
|
|
||||||
class ActionBar extends PureComponent {
|
export const ActionBar = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
static propTypes = {
|
const handleLogoutClick = useCallback(() => {
|
||||||
account: ImmutablePropTypes.record.isRequired,
|
dispatch(openModal({
|
||||||
onLogout: PropTypes.func.isRequired,
|
modalType: 'CONFIRM',
|
||||||
intl: PropTypes.object.isRequired,
|
modalProps: {
|
||||||
};
|
message: intl.formatMessage(messages.logoutMessage),
|
||||||
|
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||||
|
closeWhenConfirm: false,
|
||||||
|
onConfirm: () => logOut(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [dispatch, intl]);
|
||||||
|
|
||||||
handleLogout = () => {
|
let menu = [];
|
||||||
this.props.onLogout();
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
|
||||||
const { intl } = this.props;
|
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
|
||||||
|
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 });
|
||||||
|
|
||||||
let menu = [];
|
return (
|
||||||
|
<DropdownMenuContainer
|
||||||
menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
|
items={menu}
|
||||||
menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
|
icon='bars'
|
||||||
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
|
iconComponent={MoreHorizIcon}
|
||||||
menu.push(null);
|
size={24}
|
||||||
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
|
direction='right'
|
||||||
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')}>
|
||||||
<div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div>
|
<Avatar account={account} size={24} />
|
||||||
<DisplayName account={account} inline />
|
<DisplayName account={account} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,26 +1,18 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { length } from 'stringz';
|
import { length } from 'stringz';
|
||||||
|
|
||||||
export default class CharacterCounter extends PureComponent {
|
export const CharacterCounter = ({ text, max }) => {
|
||||||
|
const diff = max - length(text);
|
||||||
|
|
||||||
static propTypes = {
|
if (diff < 0) {
|
||||||
text: PropTypes.string.isRequired,
|
return <span className='character-counter character-counter--over'>{diff}</span>;
|
||||||
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>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
return <span className='character-counter'>{diff}</span>;
|
||||||
const diff = this.props.max - length(this.props.text);
|
};
|
||||||
return this.checkRemainingText(diff);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
CharacterCounter.propTypes = {
|
||||||
|
text: PropTypes.string.isRequired,
|
||||||
|
max: PropTypes.number.isRequired,
|
||||||
|
};
|
||||||
|
@ -10,35 +10,39 @@ 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 OptionsContainer from '../containers/options_container';
|
import LanguageDropdown from '../containers/language_dropdown_container';
|
||||||
import PollFormContainer from '../containers/poll_form_container';
|
import PollButtonContainer from '../containers/poll_button_container';
|
||||||
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
|
import PrivacyDropdownContainer from '../containers/privacy_dropdown_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 Publisher from './publisher';
|
import { ContentTypeButton } from './content_type_button';
|
||||||
import TextareaIcons from './textarea_icons';
|
import { EditIndicator } from './edit_indicator';
|
||||||
|
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?' },
|
||||||
missingDescriptionMessage: {
|
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning (optional)' },
|
||||||
id: 'confirmations.missing_media_description.message',
|
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
|
||||||
defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.',
|
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Update' },
|
||||||
},
|
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 {
|
||||||
@ -47,11 +51,14 @@ 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,
|
||||||
@ -64,26 +71,21 @@ 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,
|
||||||
showSearch: PropTypes.bool,
|
autoFocus: 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,
|
||||||
advancedOptions: ImmutablePropTypes.map,
|
maxChars: PropTypes.number,
|
||||||
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 = {
|
||||||
showSearch: false,
|
autoFocus: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
@ -101,30 +103,27 @@ 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();
|
this.handleSubmit(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.keyCode === 13 && e.altKey) {
|
if (e.keyCode === 13 && e.altKey) {
|
||||||
this.handleSecondarySubmit();
|
this.handleSecondarySubmit(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
getFulltextForCharacterCounting = () => {
|
getFulltextForCharacterCounting = () => {
|
||||||
return [
|
return [this.props.spoiler? this.props.spoilerText: '', countableText(this.props.text)].join('');
|
||||||
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 } = this.props;
|
const { isSubmitting, isChangingUpload, isUploading, anyMedia, maxChars } = 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 || (!fulltext.trim().length && !anyMedia));
|
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (isOnlyWhitespace && !anyMedia));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleSubmit = (e, overriddenVisibility = null) => {
|
handleSubmit = (e, overridePrivacy = 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
|
||||||
@ -142,19 +141,15 @@ 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'), overriddenVisibility);
|
this.props.onMediaDescriptionConfirm(this.props.history || null, firstWithoutDescription.get('id'), overridePrivacy);
|
||||||
} else {
|
} else {
|
||||||
if (overriddenVisibility) {
|
this.props.onSubmit(this.props.history || null, overridePrivacy);
|
||||||
this.props.onChangeVisibility(overriddenVisibility);
|
|
||||||
}
|
|
||||||
this.props.onSubmit(this.props.history || null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handles the secondary submit button.
|
handleSecondarySubmit = (e) => {
|
||||||
handleSecondarySubmit = () => {
|
|
||||||
const { sideArm } = this.props;
|
const { sideArm } = this.props;
|
||||||
this.handleSubmit(null, sideArm === 'none' ? null : sideArm);
|
this.handleSubmit(e, sideArm === 'none' ? null : sideArm);
|
||||||
};
|
};
|
||||||
|
|
||||||
onSuggestionsClearRequested = () => {
|
onSuggestionsClearRequested = () => {
|
||||||
@ -224,7 +219,6 @@ 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);
|
||||||
@ -248,103 +242,109 @@ class ComposeForm extends ImmutablePureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleEmojiPick = (data) => {
|
handleEmojiPick = (data) => {
|
||||||
const position = this.textareaRef.current.selectionStart;
|
const { text } = this.props;
|
||||||
|
const position = this.textareaRef.current.selectionStart;
|
||||||
|
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
|
||||||
|
|
||||||
this.props.onPickEmoji(position, data);
|
this.props.onPickEmoji(position, data, needsSpace);
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const {
|
const { intl, onPaste, autoFocus, withoutNavigation, maxChars } = this.props;
|
||||||
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 />
|
||||||
|
|
||||||
<ReplyIndicatorContainer />
|
<div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
|
||||||
|
<div className='compose-form__scrollable'>
|
||||||
|
<EditIndicator />
|
||||||
|
|
||||||
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef} aria-hidden={!this.props.spoiler}>
|
{this.props.spoiler && (
|
||||||
<AutosuggestInput
|
<div className='spoiler-input'>
|
||||||
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
<div className='spoiler-input__border' />
|
||||||
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>
|
|
||||||
|
|
||||||
<div className={classNames('compose-form__highlightable', { active: highlighted })}>
|
<AutosuggestInput
|
||||||
<AutosuggestTextarea
|
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
||||||
ref={this.textareaRef}
|
value={this.props.spoilerText}
|
||||||
placeholder={intl.formatMessage(messages.placeholder)}
|
disabled={disabled}
|
||||||
disabled={disabled}
|
onChange={this.handleChangeSpoilerText}
|
||||||
value={this.props.text}
|
onKeyDown={this.handleKeyDown}
|
||||||
onChange={this.handleChange}
|
ref={this.setSpoilerText}
|
||||||
suggestions={this.props.suggestions}
|
suggestions={this.props.suggestions}
|
||||||
onFocus={this.handleFocus}
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||||
onKeyDown={this.handleKeyDown}
|
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
onSuggestionSelected={this.onSpoilerSuggestionSelected}
|
||||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
searchTokens={[':']}
|
||||||
onSuggestionSelected={this.onSuggestionSelected}
|
id='cw-spoiler-input'
|
||||||
onPaste={onPaste}
|
className='spoiler-input__input'
|
||||||
autoFocus={!showSearch && !isMobile(window.innerWidth)}
|
lang={this.props.lang}
|
||||||
lang={this.props.lang}
|
spellCheck
|
||||||
>
|
/>
|
||||||
<TextareaIcons advancedOptions={advancedOptions} />
|
|
||||||
<div className='compose-form__modifiers'>
|
|
||||||
<UploadFormContainer />
|
|
||||||
<PollFormContainer />
|
|
||||||
</div>
|
|
||||||
</AutosuggestTextarea>
|
|
||||||
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
|
|
||||||
|
|
||||||
<div className='compose-form__buttons-wrapper'>
|
<div className='spoiler-input__border' />
|
||||||
<OptionsContainer
|
</div>
|
||||||
advancedOptions={advancedOptions}
|
)}
|
||||||
disabled={isSubmitting}
|
|
||||||
onToggleSpoiler={this.props.spoilersAlwaysOn ? null : onChangeSpoilerness}
|
<AutosuggestTextarea
|
||||||
onUpload={onPaste}
|
ref={this.textareaRef}
|
||||||
isEditing={isEditing}
|
placeholder={intl.formatMessage(messages.placeholder)}
|
||||||
sensitive={sensitive || (spoilersAlwaysOn && this.props.spoilerText && this.props.spoilerText.length > 0)}
|
disabled={disabled}
|
||||||
spoiler={spoilersAlwaysOn ? (this.props.spoilerText && this.props.spoilerText.length > 0) : this.props.spoiler}
|
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 className='character-counter__wrapper'>
|
</div>
|
||||||
<CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} />
|
|
||||||
|
<UploadFormContainer />
|
||||||
|
<PollForm />
|
||||||
|
|
||||||
|
<div className='compose-form__footer'>
|
||||||
|
<div className='compose-form__dropdowns'>
|
||||||
|
<PrivacyDropdownContainer disabled={this.props.isEditing} />
|
||||||
|
<LanguageDropdown />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='compose-form__actions'>
|
||||||
|
<div className='compose-form__buttons'>
|
||||||
|
<UploadButtonContainer />
|
||||||
|
<PollButtonContainer />
|
||||||
|
{!this.props.spoilerAlwaysOn && <SpoilerButtonContainer />}
|
||||||
|
<ContentTypeButton />
|
||||||
|
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
|
||||||
|
<FederationButton />
|
||||||
|
<ThreadModeButton />
|
||||||
|
<CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,69 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -1,243 +0,0 @@
|
|||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,78 @@
|
|||||||
|
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 DropdownMenu from './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}`}>
|
||||||
|
<DropdownMenu
|
||||||
|
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,
|
||||||
|
};
|
@ -1,4 +1,3 @@
|
|||||||
// Package imports.
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
|
|
||||||
@ -6,101 +5,34 @@ import classNames from 'classnames';
|
|||||||
|
|
||||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||||
|
|
||||||
// Components.
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
|
||||||
|
|
||||||
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
||||||
|
|
||||||
// The component.
|
// copied from PrivacyDropdown; will require refactor with upstream down the line
|
||||||
export default class ComposerOptionsDropdownContent extends PureComponent {
|
class DropdownMenu extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
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,
|
style: PropTypes.object,
|
||||||
value: PropTypes.string,
|
items: PropTypes.array.isRequired,
|
||||||
renderItemContents: PropTypes.func,
|
value: PropTypes.string.isRequired,
|
||||||
openedViaKeyboard: PropTypes.bool,
|
onClose: PropTypes.func.isRequired,
|
||||||
closeOnChange: PropTypes.bool,
|
onChange: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
handleDocumentClick = e => {
|
||||||
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)) {
|
if (this.node && !this.node.contains(e.target)) {
|
||||||
this.props.onClose();
|
this.props.onClose();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stores our node in `this.node`.
|
handleKeyDown = e => {
|
||||||
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;
|
const { items } = this.props;
|
||||||
|
const value = e.currentTarget.getAttribute('data-index');
|
||||||
|
const index = items.findIndex(item => {
|
||||||
|
return (item.value === value);
|
||||||
|
});
|
||||||
let element = null;
|
let element = null;
|
||||||
|
|
||||||
switch(e.key) {
|
switch(e.key) {
|
||||||
@ -108,7 +40,6 @@ export default class ComposerOptionsDropdownContent extends PureComponent {
|
|||||||
this.props.onClose();
|
this.props.onClose();
|
||||||
break;
|
break;
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
case ' ':
|
|
||||||
this.handleClick(e);
|
this.handleClick(e);
|
||||||
break;
|
break;
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
@ -134,72 +65,61 @@ export default class ComposerOptionsDropdownContent extends PureComponent {
|
|||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
element.focus();
|
element.focus();
|
||||||
this.handleChange(items[Number(element.getAttribute('data-index'))].name);
|
this.props.onChange(element.getAttribute('data-index'));
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleClick = e => {
|
||||||
|
const value = e.currentTarget.getAttribute('data-index');
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
this.props.onClose();
|
||||||
|
this.props.onChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
document.addEventListener('click', this.handleDocumentClick, { capture: true });
|
||||||
|
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||||
|
if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
|
||||||
|
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRef = c => {
|
||||||
|
this.node = c;
|
||||||
|
};
|
||||||
|
|
||||||
setFocusRef = c => {
|
setFocusRef = c => {
|
||||||
this.focusedItem = 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 && (
|
|
||||||
<div className='privacy-dropdown__option__icon'>
|
|
||||||
<Icon className='icon' id={icon} icon={iconComponent} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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 () {
|
render () {
|
||||||
const {
|
const { style, items, value } = this.props;
|
||||||
items,
|
|
||||||
style,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
// The result.
|
|
||||||
return (
|
return (
|
||||||
<div style={{ ...style }} role='listbox' ref={this.setRef}>
|
<div style={{ ...style }} role='listbox' ref={this.setRef}>
|
||||||
{!!items && items.map((item, i) => this.renderItem(item, i))}
|
{items.map(item => (
|
||||||
|
<div role='option' tabIndex={0} key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : 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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default DropdownMenu;
|
||||||
|
@ -0,0 +1,61 @@
|
|||||||
|
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,6 +10,8 @@ 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';
|
||||||
|
|
||||||
@ -163,6 +165,7 @@ 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 = {
|
||||||
@ -177,7 +180,7 @@ class EmojiPickerMenuImpl extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleDocumentClick = e => {
|
handleDocumentClick = e => {
|
||||||
if (this.node && !this.node.contains(e.target)) {
|
if (this.node && !this.node.contains(e.target) && !this.props.pickerButtonRef.contains(e.target)) {
|
||||||
this.props.onClose();
|
this.props.onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -232,6 +235,7 @@ 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);
|
||||||
@ -323,8 +327,6 @@ 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,
|
|
||||||
disabled: PropTypes.bool,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
@ -382,23 +384,24 @@ class EmojiPickerDropdown extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
|
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = 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}>
|
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown} ref={this.setTargetRef}>
|
||||||
<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}>
|
<IconButton
|
||||||
{button || <img
|
title={title}
|
||||||
className={classNames('emojione', { 'pulse-loading': active && loading })}
|
aria-expanded={active}
|
||||||
alt='🙂'
|
active={active}
|
||||||
src={`${assetHost}/emoji/1f642.svg`}
|
iconComponent={MoodIcon}
|
||||||
/>}
|
onClick={this.onToggle}
|
||||||
</div>
|
inverted
|
||||||
|
/>
|
||||||
|
|
||||||
<Overlay show={active} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
|
<Overlay show={active} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
|
||||||
{({ props, placement })=> (
|
{({ props, placement })=> (
|
||||||
<div {...props} style={{ ...props.style, width: 299 }}>
|
<div {...props} style={{ ...props.style }}>
|
||||||
<div className={`dropdown-animation ${placement}`}>
|
<div className={`dropdown-animation ${placement}`}>
|
||||||
<EmojiPickerMenu
|
<EmojiPickerMenu
|
||||||
custom_emojis={this.props.custom_emojis}
|
custom_emojis={this.props.custom_emojis}
|
||||||
@ -408,6 +411,7 @@ class EmojiPickerDropdown extends PureComponent {
|
|||||||
onSkinTone={onSkinTone}
|
onSkinTone={onSkinTone}
|
||||||
skinTone={skinTone}
|
skinTone={skinTone}
|
||||||
frequentlyUsedEmojis={frequentlyUsedEmojis}
|
frequentlyUsedEmojis={frequentlyUsedEmojis}
|
||||||
|
pickerButtonRef={this.target}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -0,0 +1,49 @@
|
|||||||
|
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'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -1,149 +0,0 @@
|
|||||||
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,10 +9,11 @@ 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' },
|
||||||
@ -231,7 +232,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}>{!isSearching ? loupeIcon : deleteIcon}</button>
|
<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>
|
||||||
</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}>
|
||||||
@ -297,20 +298,24 @@ 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 className={classNames('privacy-dropdown', placement, { active: open })}>
|
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
|
||||||
<div className='privacy-dropdown__value' ref={this.setTargetRef} >
|
<button
|
||||||
<TextIconButton
|
type='button'
|
||||||
className='privacy-dropdown__value-icon'
|
title={intl.formatMessage(messages.changeLanguage)}
|
||||||
label={value && value.toUpperCase()}
|
aria-expanded={open}
|
||||||
title={intl.formatMessage(messages.changeLanguage)}
|
onClick={this.handleToggle}
|
||||||
active={open}
|
onMouseDown={this.handleMouseDown}
|
||||||
onClick={this.handleToggle}
|
onKeyDown={this.handleButtonKeyDown}
|
||||||
/>
|
className={classNames('dropdown-button', { active: open })}
|
||||||
</div>
|
>
|
||||||
|
<Icon icon={TranslateIcon} />
|
||||||
|
<span className='dropdown-button__label'>{current[2] ?? value}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<Overlay show={open} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
|
<Overlay show={open} offset={[5, 5]} placement={placement} 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,54 +1,36 @@
|
|||||||
import PropTypes from 'prop-types';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
|
|
||||||
import { Permalink } from 'flavours/glitch/components/permalink';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import { profileLink } from 'flavours/glitch/utils/backend_links';
|
import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
|
||||||
|
import Account from 'flavours/glitch/components/account';
|
||||||
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
|
|
||||||
import { Avatar } from '../../../components/avatar';
|
import { ActionBar } from './action_bar';
|
||||||
|
|
||||||
import ActionBar from './action_bar';
|
|
||||||
|
|
||||||
export default class NavigationBar extends ImmutablePureComponent {
|
const messages = defineMessages({
|
||||||
|
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
|
||||||
|
});
|
||||||
|
|
||||||
static propTypes = {
|
export const NavigationBar = () => {
|
||||||
account: ImmutablePropTypes.record.isRequired,
|
const dispatch = useDispatch();
|
||||||
onLogout: PropTypes.func.isRequired,
|
const intl = useIntl();
|
||||||
onClose: PropTypes.func,
|
const account = useSelector(state => state.getIn(['accounts', me]));
|
||||||
};
|
const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to']));
|
||||||
|
|
||||||
render () {
|
const handleCancelClick = useCallback(() => {
|
||||||
const username = this.props.account.get('acct');
|
dispatch(cancelReplyCompose());
|
||||||
return (
|
}, [dispatch]);
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className='navigation-bar__profile'>
|
return (
|
||||||
<span>
|
<div className='navigation-bar'>
|
||||||
<Permalink className='acct' href={this.props.account.get('url')} to={`/@${username}`}>
|
<Account account={account} minimal />
|
||||||
<strong className='navigation-bar__profile-account'>@{username}</strong>
|
{isReplying ? <IconButton title={intl.formatMessage(messages.cancel)} iconComponent={CloseIcon} onClick={handleCancelClick} /> : <ActionBar />}
|
||||||
</Permalink>
|
</div>
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
@ -1,330 +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 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 (
|
|
||||||
<>
|
|
||||||
<div className='privacy-dropdown__option__icon'>
|
|
||||||
<Toggle checked={checked} onChange={this.handleChange} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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);
|
|
@ -0,0 +1,55 @@
|
|||||||
|
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,179 +1,161 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { PureComponent } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
|
|
||||||
|
import {
|
||||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
changePollSettings,
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
changePollOption,
|
||||||
|
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: 'Choice {number}' },
|
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Option {number}' },
|
||||||
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' },
|
duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll length' },
|
||||||
remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' },
|
type: { id: 'compose_form.poll.type', defaultMessage: 'Style' },
|
||||||
poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' },
|
switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' },
|
||||||
single_choice: { id: 'compose_form.poll.single_choice', defaultMessage: 'Allow one choice' },
|
switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single 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' },
|
||||||
});
|
});
|
||||||
|
|
||||||
class OptionIntl extends PureComponent {
|
const Select = ({ label, options, value, onChange }) => {
|
||||||
|
return (
|
||||||
|
<label className='compose-form__poll__select'>
|
||||||
|
<span className='compose-form__poll__select__label'>{label}</span>
|
||||||
|
|
||||||
static propTypes = {
|
<select className='compose-form__poll__select__value' value={value} onChange={onChange}>
|
||||||
title: PropTypes.string.isRequired,
|
{options.map((option, i) => (
|
||||||
lang: PropTypes.string,
|
<option key={i} value={option.value}>{option.label}</option>
|
||||||
index: PropTypes.number.isRequired,
|
))}
|
||||||
isPollMultiple: PropTypes.bool,
|
</select>
|
||||||
autoFocus: PropTypes.bool,
|
</label>
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleOptionTitleChange = e => {
|
Select.propTypes = {
|
||||||
this.props.onChange(this.props.index, e.target.value);
|
label: PropTypes.node,
|
||||||
};
|
value: PropTypes.any,
|
||||||
|
onChange: PropTypes.func,
|
||||||
|
options: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
label: PropTypes.node,
|
||||||
|
value: PropTypes.any,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
handleOptionRemove = () => {
|
const Option = ({ multipleChoice, index, title, autoFocus }) => {
|
||||||
this.props.onRemove(this.props.index);
|
const intl = useIntl();
|
||||||
};
|
const dispatch = useDispatch();
|
||||||
|
const suggestions = useSelector(state => state.getIn(['compose', 'suggestions']));
|
||||||
|
const lang = useSelector(state => state.getIn(['compose', 'language']));
|
||||||
|
|
||||||
onSuggestionsClearRequested = () => {
|
const handleChange = useCallback(({ target: { value } }) => {
|
||||||
this.props.onClearSuggestions();
|
dispatch(changePollOption(index, value));
|
||||||
};
|
}, [dispatch, index]);
|
||||||
|
|
||||||
onSuggestionsFetchRequested = (token) => {
|
const handleSuggestionsFetchRequested = useCallback(token => {
|
||||||
this.props.onFetchSuggestions(token);
|
dispatch(fetchComposeSuggestions(token));
|
||||||
};
|
}, [dispatch]);
|
||||||
|
|
||||||
onSuggestionSelected = (tokenStart, token, value) => {
|
const handleSuggestionsClearRequested = useCallback(() => {
|
||||||
this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]);
|
dispatch(clearComposeSuggestions());
|
||||||
};
|
}, [dispatch]);
|
||||||
|
|
||||||
render () {
|
const handleSuggestionSelected = useCallback((tokenStart, token, value) => {
|
||||||
const { isPollMultiple, title, lang, index, autoFocus, intl } = this.props;
|
dispatch(selectComposeSuggestion(tokenStart, token, value, ['poll', 'options', index]));
|
||||||
|
}, [dispatch, index]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<label className={classNames('poll__option editable', { empty: index > 1 && title.length === 0 })}>
|
||||||
<label className='poll__option editable'>
|
<span className={classNames('poll__input', { checkbox: multipleChoice })} />
|
||||||
<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={this.handleOptionTitleChange}
|
onChange={handleChange}
|
||||||
suggestions={this.props.suggestions}
|
suggestions={suggestions}
|
||||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
|
||||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
onSuggestionsClearRequested={handleSuggestionsClearRequested}
|
||||||
onSuggestionSelected={this.onSuggestionSelected}
|
onSuggestionSelected={handleSuggestionSelected}
|
||||||
searchTokens={[':']}
|
searchTokens={[':']}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
<div className='poll__cancel'>
|
Option.propTypes = {
|
||||||
<IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' iconComponent={CloseIcon} onClick={this.handleOptionRemove} />
|
title: PropTypes.string.isRequired,
|
||||||
</div>
|
index: PropTypes.number.isRequired,
|
||||||
</li>
|
multipleChoice: PropTypes.bool,
|
||||||
);
|
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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
const Option = injectIntl(OptionIntl);
|
<div className='compose-form__poll__footer'>
|
||||||
|
<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} />
|
||||||
|
|
||||||
class PollForm extends ImmutablePureComponent {
|
<div className='compose-form__poll__footer__sep' />
|
||||||
|
|
||||||
static propTypes = {
|
<Select label={intl.formatMessage(messages.type)} options={[
|
||||||
options: ImmutablePropTypes.list,
|
{ value: false, label: intl.formatMessage(messages.singleChoice) },
|
||||||
lang: PropTypes.string,
|
{ value: true, label: intl.formatMessage(messages.multipleChoice) },
|
||||||
expiresIn: PropTypes.number,
|
]} value={isMultiple} onChange={handleTypeChange} />
|
||||||
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,25 +3,151 @@ import { PureComponent } from 'react';
|
|||||||
|
|
||||||
import { injectIntl, defineMessages } from 'react-intl';
|
import { injectIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
import classNames from 'classnames';
|
||||||
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 Dropdown from './dropdown';
|
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||||
|
import Overlay from 'react-overlays/Overlay';
|
||||||
|
|
||||||
|
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||||
|
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||||
|
import LockIcon from '@/material-icons/400-24px/lock.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';
|
||||||
|
|
||||||
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: 'Visible for all' },
|
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
||||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
|
||||||
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' },
|
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Fewer algorithmic fanfares' },
|
||||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
|
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
|
||||||
private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
|
private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
|
||||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
|
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
|
||||||
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
|
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Everyone mentioned in the post' },
|
||||||
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
|
change_privacy: { id: 'privacy.change', defaultMessage: 'Change post 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.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
||||||
|
|
||||||
|
class PrivacyDropdownMenu extends PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
style: PropTypes.object,
|
||||||
|
items: PropTypes.array.isRequired,
|
||||||
|
value: PropTypes.string.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDocumentClick = e => {
|
||||||
|
if (this.node && !this.node.contains(e.target)) {
|
||||||
|
this.props.onClose();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleKeyDown = e => {
|
||||||
|
const { items } = this.props;
|
||||||
|
const value = e.currentTarget.getAttribute('data-index');
|
||||||
|
const index = items.findIndex(item => {
|
||||||
|
return (item.value === value);
|
||||||
|
});
|
||||||
|
let element = null;
|
||||||
|
|
||||||
|
switch(e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
this.props.onClose();
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
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.props.onChange(element.getAttribute('data-index'));
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClick = e => {
|
||||||
|
const value = e.currentTarget.getAttribute('data-index');
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
this.props.onClose();
|
||||||
|
this.props.onChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
document.addEventListener('click', this.handleDocumentClick, { capture: true });
|
||||||
|
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||||
|
if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
|
||||||
|
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRef = c => {
|
||||||
|
this.node = c;
|
||||||
|
};
|
||||||
|
|
||||||
|
setFocusRef = c => {
|
||||||
|
this.focusedItem = c;
|
||||||
|
};
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { style, items, value } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ ...style }} role='listbox' ref={this.setRef}>
|
||||||
|
{items.map(item => (
|
||||||
|
<div role='option' tabIndex={0} key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : 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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
class PrivacyDropdown extends PureComponent {
|
class PrivacyDropdown extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
@ -36,62 +162,118 @@ class PrivacyDropdown extends PureComponent {
|
|||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
state = {
|
||||||
const { value, onChange, onModalOpen, onModalClose, disabled, noDirect, container, isUserTouching, intl: { formatMessage } } = this.props;
|
open: false,
|
||||||
|
placement: 'bottom',
|
||||||
|
};
|
||||||
|
|
||||||
// We predefine our privacy items so that we can easily pick the
|
handleToggle = () => {
|
||||||
// dropdown icon later.
|
if (this.state.open && this.activeElement) {
|
||||||
const privacyItems = {
|
this.activeElement.focus({ preventScroll: true });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 () {
|
||||||
|
const { value, container, disabled, intl } = this.props;
|
||||||
|
const { open, placement } = this.state;
|
||||||
|
|
||||||
|
const valueOption = this.options.find(item => item.value === value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
|
||||||
disabled={disabled}
|
<button
|
||||||
icon={(privacyItems[value] || {}).icon}
|
type='button'
|
||||||
iconComponent={(privacyItems[value] || {}).iconComponent}
|
title={intl.formatMessage(messages.change_privacy)}
|
||||||
items={items}
|
aria-expanded={open}
|
||||||
onChange={onChange}
|
onClick={this.handleToggle}
|
||||||
isUserTouching={isUserTouching}
|
onMouseDown={this.handleMouseDown}
|
||||||
onModalClose={onModalClose}
|
onKeyDown={this.handleButtonKeyDown}
|
||||||
onModalOpen={onModalOpen}
|
disabled={disabled}
|
||||||
title={formatMessage(messages.change_privacy)}
|
className={classNames('dropdown-button', { active: open })}
|
||||||
container={container}
|
>
|
||||||
value={value}
|
<Icon id={valueOption.icon} icon={valueOption.iconComponent} />
|
||||||
/>
|
<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,114 +0,0 @@
|
|||||||
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,75 +1,47 @@
|
|||||||
import PropTypes from 'prop-types';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
|
||||||
|
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')]));
|
||||||
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
if (!status) {
|
||||||
import AttachmentList from 'flavours/glitch/components/attachment_list';
|
return null;
|
||||||
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router';
|
}
|
||||||
|
|
||||||
import { Avatar } from '../../../components/avatar';
|
const content = { __html: status.get('contentHtml') };
|
||||||
import { DisplayName } from '../../../components/display_name';
|
|
||||||
import { IconButton } from '../../../components/icon_button';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
return (
|
||||||
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
|
<div className='reply-indicator'>
|
||||||
});
|
<div className='reply-indicator__line' />
|
||||||
|
|
||||||
class ReplyIndicator extends ImmutablePureComponent {
|
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-avatar'>
|
||||||
|
<Avatar account={account} size={46} />
|
||||||
|
</Permalink>
|
||||||
|
|
||||||
static propTypes = {
|
<div className='reply-indicator__main'>
|
||||||
status: ImmutablePropTypes.map,
|
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
|
||||||
onCancel: PropTypes.func.isRequired,
|
<DisplayName account={account} />
|
||||||
intl: PropTypes.object.isRequired,
|
</Permalink>
|
||||||
...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('media_attachments').size > 0 && (
|
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
||||||
<AttachmentList
|
<div className='reply-indicator__attachments'>
|
||||||
compact
|
{status.get('poll') && <><Icon icon={BarChart4BarsIcon} /><FormattedMessage id='reply_indicator.poll' defaultMessage='Poll' /></>}
|
||||||
media={status.get('media_attachments')}
|
{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>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default withOptionalRouter(injectIntl(ReplyIndicator));
|
|
||||||
|
@ -8,7 +8,6 @@ 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';
|
||||||
@ -186,9 +185,9 @@ class Search extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleURLClick = () => {
|
handleURLClick = () => {
|
||||||
const { onOpenURL, history } = this.props;
|
const { value, onOpenURL, history } = this.props;
|
||||||
|
|
||||||
onOpenURL(history);
|
onOpenURL(value, history);
|
||||||
this._unfocus();
|
this._unfocus();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -331,7 +330,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}
|
||||||
@ -339,8 +338,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' : ''} />
|
<Icon id='times-circle' icon={CancelIcon} className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='search__popout'>
|
<div className='search__popout'>
|
||||||
|
@ -13,7 +13,6 @@ 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';
|
||||||
@ -77,10 +76,10 @@ class SearchResults extends ImmutablePureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='search-results'>
|
<div className='search-results'>
|
||||||
<header className='search-results__header'>
|
<div 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' />
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
{accounts}
|
{accounts}
|
||||||
{hashtags}
|
{hashtags}
|
||||||
|
@ -0,0 +1,45 @@
|
|||||||
|
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,
|
||||||
|
};
|
@ -0,0 +1,58 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
@ -1,59 +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 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);
|
|
@ -0,0 +1,41 @@
|
|||||||
|
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,23 +2,26 @@ 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-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
|
||||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
import WarningIcon from '@/material-icons/400-24px/warning.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,
|
||||||
};
|
};
|
||||||
@ -34,7 +37,7 @@ export default class Upload extends ImmutablePureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media } = this.props;
|
const { media, sensitive } = this.props;
|
||||||
|
|
||||||
if (!media) {
|
if (!media) {
|
||||||
return null;
|
return null;
|
||||||
@ -44,22 +47,26 @@ 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: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
|
<div className='compose-form__upload__thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: !sensitive ? `url(${media.get('preview_url')})` : null, 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' onClick={this.handleUndoClick}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
|
<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.handleFocalPointClick}><Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
|
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(media.get('description') || '').length === 0 && (
|
<div className='compose-form__upload__warning'>
|
||||||
<div className='compose-form__upload__warning'>
|
<button type='button' className={classNames('icon-button', { active: missingDescription })} onClick={this.handleFocalPointClick}>{missingDescription && <Icon icon={WarningIcon} />} ALT</button>
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Motion>
|
</Motion>
|
||||||
|
@ -0,0 +1,82 @@
|
|||||||
|
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 { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
upload: { id: 'upload_button.label', defaultMessage: 'Add images, a video or an audio file' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeMapStateToProps = () => {
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapStateToProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconStyle = {
|
||||||
|
height: null,
|
||||||
|
lineHeight: '27px',
|
||||||
|
};
|
||||||
|
|
||||||
|
class UploadButton extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
onSelectFile: 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClick = () => {
|
||||||
|
this.fileElement.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
setRef = (c) => {
|
||||||
|
this.fileElement = c;
|
||||||
|
};
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
|
||||||
|
|
||||||
|
const message = intl.formatMessage(messages.upload);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='compose-form__upload-button'>
|
||||||
|
<IconButton icon='paperclip' iconComponent={PhotoLibraryIcon} title={message} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
|
||||||
|
<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,10 +1,11 @@
|
|||||||
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 = {
|
||||||
@ -15,17 +16,19 @@ export default class UploadForm extends ImmutablePureComponent {
|
|||||||
const { mediaIds } = this.props;
|
const { mediaIds } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='compose-form__upload-wrapper'>
|
<>
|
||||||
<UploadProgressContainer />
|
<UploadProgressContainer />
|
||||||
|
|
||||||
<div className='compose-form__uploads-wrapper'>
|
{mediaIds.size > 0 && (
|
||||||
{mediaIds.map(id => (
|
<div className='compose-form__uploads'>
|
||||||
<UploadContainer id={id} key={id} />
|
{mediaIds.map(id => (
|
||||||
))}
|
<UploadContainer id={id} key={id} />
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!mediaIds.isEmpty() && <SensitiveButtonContainer />}
|
{!mediaIds.isEmpty() && <SensitiveButton />}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -35,9 +35,7 @@ export default class UploadProgress extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='upload-progress'>
|
<div className='upload-progress'>
|
||||||
<div className='upload-progress__icon'>
|
<Icon id='upload' icon={UploadFileIcon} />
|
||||||
<Icon id='upload' icon={UploadFileIcon} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='upload-progress__message'>
|
<div className='upload-progress__message'>
|
||||||
{message}
|
{message}
|
||||||
|
@ -11,8 +11,6 @@ import {
|
|||||||
fetchComposeSuggestions,
|
fetchComposeSuggestions,
|
||||||
selectComposeSuggestion,
|
selectComposeSuggestion,
|
||||||
changeComposeSpoilerText,
|
changeComposeSpoilerText,
|
||||||
changeComposeSpoilerness,
|
|
||||||
changeComposeVisibility,
|
|
||||||
insertEmojiCompose,
|
insertEmojiCompose,
|
||||||
uploadCompose,
|
uploadCompose,
|
||||||
} from '../../../actions/compose';
|
} from '../../../actions/compose';
|
||||||
@ -58,11 +56,13 @@ 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,14 +70,10 @@ 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']),
|
||||||
advancedOptions: state.getIn(['compose', 'advanced_options']),
|
|
||||||
media: state.getIn(['compose', 'media_attachments']),
|
|
||||||
sideArm: sideArmPrivacy(state),
|
sideArm: sideArmPrivacy(state),
|
||||||
sensitive: state.getIn(['compose', 'sensitive']),
|
media: state.getIn(['compose', 'media_attachments']),
|
||||||
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']),
|
||||||
preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
|
maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
@ -86,8 +82,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||||||
dispatch(changeCompose(text));
|
dispatch(changeCompose(text));
|
||||||
},
|
},
|
||||||
|
|
||||||
onSubmit (router) {
|
onSubmit (router, overridePrivacy = null) {
|
||||||
dispatch(submitCompose(router));
|
dispatch(submitCompose(router, overridePrivacy));
|
||||||
},
|
},
|
||||||
|
|
||||||
onClearSuggestions () {
|
onClearSuggestions () {
|
||||||
@ -102,37 +98,26 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||||||
dispatch(selectComposeSuggestion(position, token, suggestion, path));
|
dispatch(selectComposeSuggestion(position, token, suggestion, path));
|
||||||
},
|
},
|
||||||
|
|
||||||
onChangeSpoilerText (text) {
|
onChangeSpoilerText (checked) {
|
||||||
dispatch(changeComposeSpoilerText(text));
|
dispatch(changeComposeSpoilerText(checked));
|
||||||
},
|
},
|
||||||
|
|
||||||
onPaste (files) {
|
onPaste (files) {
|
||||||
dispatch(uploadCompose(files));
|
dispatch(uploadCompose(files));
|
||||||
},
|
},
|
||||||
|
|
||||||
onPickEmoji (position, emoji) {
|
onPickEmoji (position, data, needsSpace) {
|
||||||
dispatch(insertEmojiCompose(position, emoji));
|
dispatch(insertEmojiCompose(position, data, needsSpace));
|
||||||
},
|
},
|
||||||
|
|
||||||
onChangeSpoilerness() {
|
onMediaDescriptionConfirm (routerHistory, mediaId, overridePrivacy = null) {
|
||||||
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: () => {
|
||||||
if (overriddenVisibility) {
|
dispatch(submitCompose(routerHistory, overridePrivacy));
|
||||||
dispatch(changeComposeVisibility(overriddenVisibility));
|
|
||||||
}
|
|
||||||
dispatch(submitCompose(routerHistory));
|
|
||||||
},
|
},
|
||||||
secondary: intl.formatMessage(messages.missingDescriptionEdit),
|
secondary: intl.formatMessage(messages.missingDescriptionEdit),
|
||||||
onSecondary: () => dispatch(openModal({
|
onSecondary: () => dispatch(openModal({
|
||||||
|
@ -1,14 +0,0 @@
|
|||||||
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);
|
|
@ -1,42 +0,0 @@
|
|||||||
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));
|
|
@ -1,36 +0,0 @@
|
|||||||
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));
|
|
@ -1,56 +0,0 @@
|
|||||||
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);
|
|
@ -0,0 +1,25 @@
|
|||||||
|
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);
|
@ -1,53 +0,0 @@
|
|||||||
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);
|
|
@ -1,36 +0,0 @@
|
|||||||
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 (routerHistory) {
|
onOpenURL (q, routerHistory) {
|
||||||
dispatch(openURL(routerHistory));
|
dispatch(openURL(q, routerHistory));
|
||||||
},
|
},
|
||||||
|
|
||||||
onClickSearchResult (q, type) {
|
onClickSearchResult (q, type) {
|
||||||
|
@ -1,77 +0,0 @@
|
|||||||
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));
|
|
@ -0,0 +1,32 @@
|
|||||||
|
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));
|
@ -0,0 +1,19 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { uploadCompose } from '../../../actions/compose';
|
||||||
|
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));
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);
|
@ -5,6 +5,7 @@ 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,7 +5,6 @@ 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';
|
||||||
@ -18,7 +17,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={profileLink}><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='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hashtagWarning) {
|
if (hashtagWarning) {
|
||||||
@ -28,7 +27,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.' /> {!!privacyPolicyLink && <a href={privacyPolicyLink} 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.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -3,93 +3,180 @@ 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 { mountCompose, unmountCompose, cycleElefriendCompose } from 'flavours/glitch/actions/compose';
|
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-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) => ({
|
||||||
elefriend: state.getIn(['compose', 'elefriend']),
|
columns: state.getIn(['settings', 'columns']),
|
||||||
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']),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
// ~4% chance you'll end up with an unexpected friend
|
||||||
onClickElefriend () {
|
// glitch-soc/mastodon repo created_at date: 2017-04-20T21:55:28Z
|
||||||
dispatch(cycleElefriendCompose());
|
const glitchProbability = 1 - 0.0420215528;
|
||||||
},
|
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,
|
||||||
elefriend: PropTypes.number,
|
unreadNotifications: PropTypes.number,
|
||||||
onClickElefriend: PropTypes.func,
|
showNotificationsBadge: PropTypes.bool,
|
||||||
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 () {
|
||||||
this.props.onMount();
|
const { dispatch } = this.props;
|
||||||
|
dispatch(mountCompose());
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
this.props.onUnmount();
|
const { dispatch } = this.props;
|
||||||
|
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 {
|
const { multiColumn, showSearch, showNotificationsBadge, unreadNotifications, intl } = this.props;
|
||||||
elefriend,
|
|
||||||
intl,
|
const elefriend = [glitchedElephant1, glitchedElephant2, glitchedElephant3, elephantUIPlane][this.state.elefriend];
|
||||||
multiColumn,
|
|
||||||
onClickElefriend,
|
|
||||||
showSearch,
|
|
||||||
} = this.props;
|
|
||||||
const computedClass = classNames('drawer', `mbstobon-${elefriend}`);
|
|
||||||
|
|
||||||
if (multiColumn) {
|
if (multiColumn) {
|
||||||
return (
|
const { columns } = this.props;
|
||||||
<div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}>
|
|
||||||
<HeaderContainer />
|
|
||||||
|
|
||||||
{multiColumn && <SearchContainer />}
|
return (
|
||||||
|
<div className='drawer' role='region' aria-label={intl.formatMessage(messages.compose)}>
|
||||||
|
<nav className='drawer__header'>
|
||||||
|
<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 /> }
|
||||||
|
|
||||||
<div className='drawer__pager'>
|
<div className='drawer__pager'>
|
||||||
<div className='drawer__inner'>
|
<div className='drawer__inner' onFocus={this.onFocus}>
|
||||||
<NavigationContainer />
|
<ComposeFormContainer autoFocus={!isMobile(window.innerWidth)} />
|
||||||
|
|
||||||
<ComposeFormContainer />
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- this is not a feature but a visual easter egg */}
|
||||||
|
<div className='drawer__inner__mastodon' onClick={this.cycleElefriend}>
|
||||||
<div className='drawer__inner__mastodon'>
|
<img alt='' draggable='false' src={mascot || elefriend} />
|
||||||
{mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -106,8 +193,7 @@ class Compose extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column>
|
<Column onFocus={this.onFocus}>
|
||||||
<NavigationContainer />
|
|
||||||
<ComposeFormContainer />
|
<ComposeFormContainer />
|
||||||
|
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@ -119,4 +205,4 @@ class Compose extends PureComponent {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Compose));
|
export default connect(mapStateToProps)(injectIntl(Compose));
|
||||||
|
@ -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';
|
||||||
|
|
||||||
|
@ -1,46 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
@ -0,0 +1,218 @@
|
|||||||
|
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,8 +6,6 @@ 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';
|
||||||
|
|
||||||
|
|
||||||
@ -17,7 +15,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 { me, criticalUpdatesPending } from 'flavours/glitch/initial_state';
|
import { 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';
|
||||||
@ -27,7 +25,6 @@ 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' },
|
||||||
@ -35,51 +32,12 @@ 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']),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -99,7 +57,6 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -170,7 +127,7 @@ class HomeTimeline extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
|
const { intl, hasUnread, columnId, multiColumn, 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 = [];
|
||||||
@ -194,10 +151,6 @@ 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
|
||||||
|
@ -10,9 +10,9 @@ import ExpandLessIcon from '@/material-icons/400-24px/expand_less.svg?react';
|
|||||||
import ImageIcon from '@/material-icons/400-24px/image.svg?react';
|
import ImageIcon from '@/material-icons/400-24px/image.svg?react';
|
||||||
import ManufacturingIcon from '@/material-icons/400-24px/manufacturing.svg?react';
|
import ManufacturingIcon from '@/material-icons/400-24px/manufacturing.svg?react';
|
||||||
import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react';
|
import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react';
|
||||||
|
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
||||||
import { preferencesLink } from 'flavours/glitch/utils/backend_links';
|
import { preferencesLink } from 'flavours/glitch/utils/backend_links';
|
||||||
|
|
||||||
|
|
||||||
import LocalSettingsNavigationItem from './item';
|
import LocalSettingsNavigationItem from './item';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@ -60,7 +60,8 @@ class LocalSettingsNavigation extends PureComponent {
|
|||||||
active={index === 2}
|
active={index === 2}
|
||||||
index={2}
|
index={2}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
textIcon='CW'
|
icon='warning'
|
||||||
|
iconComponent={WarningIcon}
|
||||||
title={intl.formatMessage(messages.content_warnings)}
|
title={intl.formatMessage(messages.content_warnings)}
|
||||||
/>
|
/>
|
||||||
<LocalSettingsNavigationItem
|
<LocalSettingsNavigationItem
|
||||||
|
@ -13,7 +13,6 @@ export default class LocalSettingsPage extends PureComponent {
|
|||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
href: PropTypes.string,
|
href: PropTypes.string,
|
||||||
icon: PropTypes.string,
|
icon: PropTypes.string,
|
||||||
textIcon: PropTypes.string,
|
|
||||||
iconComponent: PropTypes.func,
|
iconComponent: PropTypes.func,
|
||||||
index: PropTypes.number.isRequired,
|
index: PropTypes.number.isRequired,
|
||||||
onNavigate: PropTypes.func,
|
onNavigate: PropTypes.func,
|
||||||
@ -36,7 +35,6 @@ export default class LocalSettingsPage extends PureComponent {
|
|||||||
href,
|
href,
|
||||||
icon,
|
icon,
|
||||||
iconComponent,
|
iconComponent,
|
||||||
textIcon,
|
|
||||||
onNavigate,
|
onNavigate,
|
||||||
title,
|
title,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
@ -45,7 +43,7 @@ export default class LocalSettingsPage extends PureComponent {
|
|||||||
active,
|
active,
|
||||||
}, className);
|
}, className);
|
||||||
|
|
||||||
const iconElem = icon ? <Icon id={icon} icon={iconComponent} /> : (textIcon ? <span className='text-icon-button'>{textIcon}</span> : null);
|
const iconElem = icon ? <Icon id={icon} icon={iconComponent} /> : null;
|
||||||
|
|
||||||
if (href) return (
|
if (href) return (
|
||||||
<a
|
<a
|
||||||
|
@ -28,9 +28,9 @@ const messages = defineMessages({
|
|||||||
pop_in_left: { id: 'settings.pop_in_left', defaultMessage: 'Left' },
|
pop_in_left: { id: 'settings.pop_in_left', defaultMessage: 'Left' },
|
||||||
pop_in_right: { id: 'settings.pop_in_right', defaultMessage: 'Right' },
|
pop_in_right: { id: 'settings.pop_in_right', defaultMessage: 'Right' },
|
||||||
public: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
public: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||||
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
|
||||||
private: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
|
private: { id: 'privacy.private.short', defaultMessage: 'Followers' },
|
||||||
direct: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
|
direct: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
|
||||||
});
|
});
|
||||||
|
|
||||||
class LocalSettingsPage extends PureComponent {
|
class LocalSettingsPage extends PureComponent {
|
||||||
|
@ -1,21 +1,15 @@
|
|||||||
import { PureComponent } from 'react';
|
import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
|
||||||
|
import LoadingBarContainer from 'flavours/glitch/features/ui/containers/loading_bar_container';
|
||||||
|
import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container';
|
||||||
|
import NotificationsContainer from 'flavours/glitch/features/ui/containers/notifications_container';
|
||||||
|
|
||||||
import ComposeFormContainer from '../../compose/containers/compose_form_container';
|
const Compose = () => (
|
||||||
import LoadingBarContainer from '../../ui/containers/loading_bar_container';
|
<>
|
||||||
import ModalContainer from '../../ui/containers/modal_container';
|
<ComposeFormContainer autoFocus withoutNavigation />
|
||||||
import NotificationsContainer from '../../ui/containers/notifications_container';
|
<NotificationsContainer />
|
||||||
|
<ModalContainer />
|
||||||
|
<LoadingBarContainer className='loading-bar' />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
export default class Compose extends PureComponent {
|
export default Compose;
|
||||||
|
|
||||||
render () {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ComposeFormContainer autoFocus />
|
|
||||||
<NotificationsContainer />
|
|
||||||
<ModalContainer />
|
|
||||||
<LoadingBarContainer className='loading-bar' />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
@ -6,7 +6,6 @@ import { connect } from 'react-redux';
|
|||||||
import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose';
|
import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose';
|
||||||
import ServerBanner from 'flavours/glitch/components/server_banner';
|
import ServerBanner from 'flavours/glitch/components/server_banner';
|
||||||
import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
|
import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
|
||||||
import NavigationContainer from 'flavours/glitch/features/compose/containers/navigation_container';
|
|
||||||
import SearchContainer from 'flavours/glitch/features/compose/containers/search_container';
|
import SearchContainer from 'flavours/glitch/features/compose/containers/search_container';
|
||||||
|
|
||||||
import LinkFooter from './link_footer';
|
import LinkFooter from './link_footer';
|
||||||
@ -46,10 +45,7 @@ class ComposePanel extends PureComponent {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{signedIn && (
|
{signedIn && (
|
||||||
<>
|
<ComposeFormContainer singleColumn />
|
||||||
<NavigationContainer />
|
|
||||||
<ComposeFormContainer singleColumn />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<LinkFooter />
|
<LinkFooter />
|
||||||
|
@ -21,7 +21,7 @@ import { Button } from 'flavours/glitch/components/button';
|
|||||||
import { GIFV } from 'flavours/glitch/components/gifv';
|
import { GIFV } from 'flavours/glitch/components/gifv';
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
import Audio from 'flavours/glitch/features/audio';
|
import Audio from 'flavours/glitch/features/audio';
|
||||||
import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter';
|
import { CharacterCounter } from 'flavours/glitch/features/compose/components/character_counter';
|
||||||
import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress';
|
import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress';
|
||||||
import { Tesseract as fetchTesseract } from 'flavours/glitch/features/ui/util/async-components';
|
import { Tesseract as fetchTesseract } from 'flavours/glitch/features/ui/util/async-components';
|
||||||
import { me } from 'flavours/glitch/initial_state';
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
|
@ -108,7 +108,6 @@ class MuteModal extends PureComponent {
|
|||||||
<div>
|
<div>
|
||||||
<span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span>
|
<span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span>
|
||||||
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-onchange */}
|
|
||||||
<select value={muteDuration} onChange={this.changeMuteDuration}>
|
<select value={muteDuration} onChange={this.changeMuteDuration}>
|
||||||
<option value={0}>{intl.formatMessage(messages.indefinite)}</option>
|
<option value={0}>{intl.formatMessage(messages.indefinite)}</option>
|
||||||
<option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
|
<option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
|
||||||
|
@ -28,7 +28,7 @@ const makeGetStatusIds = (pending = false) => createSelector([
|
|||||||
getRegex,
|
getRegex,
|
||||||
], (columnSettings, statusIds, statuses, regex) => {
|
], (columnSettings, statusIds, statuses, regex) => {
|
||||||
return statusIds.filter(id => {
|
return statusIds.filter(id => {
|
||||||
if (id === null) return true;
|
if (id === null || id === 'inline-follow-suggestions') return true;
|
||||||
|
|
||||||
const statusForId = statuses.get(id);
|
const statusForId = statuses.get(id);
|
||||||
let showStatus = true;
|
let showStatus = true;
|
||||||
|
@ -483,7 +483,7 @@ class UI extends PureComponent {
|
|||||||
handleHotkeyNew = e => {
|
handleHotkeyNew = e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
|
const element = this.node.querySelector('.autosuggest-textarea__textarea');
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
element.focus();
|
element.focus();
|
||||||
|
@ -68,7 +68,6 @@ export const hasMultiColumnPath = initialPath === '/'
|
|||||||
* @property {boolean=} critical_updates_pending
|
* @property {boolean=} critical_updates_pending
|
||||||
* @property {InitialStateMeta} meta
|
* @property {InitialStateMeta} meta
|
||||||
* @property {object} local_settings
|
* @property {object} local_settings
|
||||||
* @property {number} max_toot_chars
|
|
||||||
* @property {number} max_feed_hashtags
|
* @property {number} max_feed_hashtags
|
||||||
* @property {number} poll_limits
|
* @property {number} poll_limits
|
||||||
* @property {number} max_reactions
|
* @property {number} max_reactions
|
||||||
@ -134,8 +133,7 @@ export const criticalUpdatesPending = initialState?.critical_updates_pending;
|
|||||||
export const statusPageUrl = getMeta('status_page_url');
|
export const statusPageUrl = getMeta('status_page_url');
|
||||||
export const sso_redirect = getMeta('sso_redirect');
|
export const sso_redirect = getMeta('sso_redirect');
|
||||||
|
|
||||||
// Glitch-YRYR-specific settings
|
// Glitch-soc-specific settings
|
||||||
export const maxChars = (initialState && initialState.max_toot_chars) || 500;
|
|
||||||
export const maxFeedHashtags = (initialState && initialState.max_feed_hashtags) || 4;
|
export const maxFeedHashtags = (initialState && initialState.max_feed_hashtags) || 4;
|
||||||
export const favouriteModal = getMeta('favourite_modal');
|
export const favouriteModal = getMeta('favourite_modal');
|
||||||
export const pollLimits = (initialState && initialState.poll_limits);
|
export const pollLimits = (initialState && initialState.poll_limits);
|
||||||
|
@ -5,13 +5,6 @@
|
|||||||
"account.follows_you": "Follows you",
|
"account.follows_you": "Follows you",
|
||||||
"account.suspended_disclaimer_full": "This user has been suspended by a moderator.",
|
"account.suspended_disclaimer_full": "This user has been suspended by a moderator.",
|
||||||
"account.view_full_profile": "View full profile",
|
"account.view_full_profile": "View full profile",
|
||||||
"advanced_options.icon_title": "Advanced options",
|
|
||||||
"advanced_options.local-only.long": "Do not post to other instances",
|
|
||||||
"advanced_options.local-only.short": "Local-only",
|
|
||||||
"advanced_options.local-only.tooltip": "This post is local-only",
|
|
||||||
"advanced_options.threaded_mode.long": "Automatically opens a reply on posting",
|
|
||||||
"advanced_options.threaded_mode.short": "Threaded mode",
|
|
||||||
"advanced_options.threaded_mode.tooltip": "Threaded mode enabled",
|
|
||||||
"boost_modal.missing_description": "This toot contains some media without description",
|
"boost_modal.missing_description": "This toot contains some media without description",
|
||||||
"column.favourited_by": "Favourited by",
|
"column.favourited_by": "Favourited by",
|
||||||
"column.heading": "Misc",
|
"column.heading": "Misc",
|
||||||
@ -21,15 +14,19 @@
|
|||||||
"column_subheading.lists": "Lists",
|
"column_subheading.lists": "Lists",
|
||||||
"column_subheading.navigation": "Navigation",
|
"column_subheading.navigation": "Navigation",
|
||||||
"community.column_settings.allow_local_only": "Show local-only toots",
|
"community.column_settings.allow_local_only": "Show local-only toots",
|
||||||
"compose.attach": "Attach...",
|
"compose.change_federation": "Change federation settings",
|
||||||
"compose.attach.doodle": "Draw something",
|
"compose.content-type.change": "Change advanced formatting options",
|
||||||
"compose.attach.upload": "Upload a file",
|
|
||||||
"compose.content-type.html": "HTML",
|
"compose.content-type.html": "HTML",
|
||||||
|
"compose.content-type.html_meta": "Format your posts using HTML",
|
||||||
"compose.content-type.markdown": "Markdown",
|
"compose.content-type.markdown": "Markdown",
|
||||||
|
"compose.content-type.markdown_meta": "Format your posts using Markdown",
|
||||||
"compose.content-type.plain": "Plain text",
|
"compose.content-type.plain": "Plain text",
|
||||||
"compose_form.poll.multiple_choices": "Allow multiple choices",
|
"compose.content-type.plain_meta": "Write with no advanced formatting",
|
||||||
"compose_form.poll.single_choice": "Allow one choice",
|
"compose.disable_threaded_mode": "Disable threaded mode",
|
||||||
"compose_form.spoiler": "Hide text behind warning",
|
"compose.enable_threaded_mode": "Enable threaded mode",
|
||||||
|
"compose_form.sensitive.hide": "{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}",
|
||||||
|
"compose_form.sensitive.marked": "{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}",
|
||||||
|
"compose_form.sensitive.unmarked": "{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}",
|
||||||
"confirmation_modal.do_not_ask_again": "Do not ask for confirmation again",
|
"confirmation_modal.do_not_ask_again": "Do not ask for confirmation again",
|
||||||
"confirmations.deprecated_settings.confirm": "Use Mastodon preferences",
|
"confirmations.deprecated_settings.confirm": "Use Mastodon preferences",
|
||||||
"confirmations.deprecated_settings.message": "Some of the glitch-soc device-specific {app_settings} you are using have been replaced by Mastodon {preferences} and will be overriden:",
|
"confirmations.deprecated_settings.message": "Some of the glitch-soc device-specific {app_settings} you are using have been replaced by Mastodon {preferences} and will be overriden:",
|
||||||
@ -40,10 +37,13 @@
|
|||||||
"confirmations.unfilter.confirm": "Show",
|
"confirmations.unfilter.confirm": "Show",
|
||||||
"confirmations.unfilter.edit_filter": "Edit filter",
|
"confirmations.unfilter.edit_filter": "Edit filter",
|
||||||
"confirmations.unfilter.filters": "Matching {count, plural, one {filter} other {filters}}",
|
"confirmations.unfilter.filters": "Matching {count, plural, one {filter} other {filters}}",
|
||||||
"content-type.change": "Content type",
|
|
||||||
"direct.group_by_conversations": "Group by conversation",
|
"direct.group_by_conversations": "Group by conversation",
|
||||||
"endorsed_accounts_editor.endorsed_accounts": "Featured accounts",
|
"endorsed_accounts_editor.endorsed_accounts": "Featured accounts",
|
||||||
"favourite_modal.combo": "You can press {combo} to skip this next time",
|
"favourite_modal.combo": "You can press {combo} to skip this next time",
|
||||||
|
"federation.federated.long": "Allow this post to reach other servers",
|
||||||
|
"federation.federated.short": "Federated",
|
||||||
|
"federation.local_only.long": "Prevent this post from reaching other servers",
|
||||||
|
"federation.local_only.short": "Local-only",
|
||||||
"firehose.column_settings.allow_local_only": "Show local-only posts in \"All\"",
|
"firehose.column_settings.allow_local_only": "Show local-only posts in \"All\"",
|
||||||
"home.column_settings.advanced": "Advanced",
|
"home.column_settings.advanced": "Advanced",
|
||||||
"home.column_settings.filter_regex": "Filter out by regular expressions",
|
"home.column_settings.filter_regex": "Filter out by regular expressions",
|
||||||
|
@ -10,10 +10,12 @@ function loaded() {
|
|||||||
|
|
||||||
if (mountNode) {
|
if (mountNode) {
|
||||||
const attr = mountNode.getAttribute('data-props');
|
const attr = mountNode.getAttribute('data-props');
|
||||||
if(!attr) return;
|
|
||||||
|
if (!attr) return;
|
||||||
|
|
||||||
const props = JSON.parse(attr);
|
const props = JSON.parse(attr);
|
||||||
const root = createRoot(mountNode);
|
const root = createRoot(mountNode);
|
||||||
|
|
||||||
root.render(<ComposeContainer {...props} />);
|
root.render(<ComposeContainer {...props} />);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,6 @@ import {
|
|||||||
COMPOSE_MOUNT,
|
COMPOSE_MOUNT,
|
||||||
COMPOSE_UNMOUNT,
|
COMPOSE_UNMOUNT,
|
||||||
COMPOSE_CHANGE,
|
COMPOSE_CHANGE,
|
||||||
COMPOSE_CYCLE_ELEFRIEND,
|
|
||||||
COMPOSE_REPLY,
|
COMPOSE_REPLY,
|
||||||
COMPOSE_REPLY_CANCEL,
|
COMPOSE_REPLY_CANCEL,
|
||||||
COMPOSE_DIRECT,
|
COMPOSE_DIRECT,
|
||||||
@ -34,6 +33,7 @@ import {
|
|||||||
COMPOSE_SPOILER_TEXT_CHANGE,
|
COMPOSE_SPOILER_TEXT_CHANGE,
|
||||||
COMPOSE_VISIBILITY_CHANGE,
|
COMPOSE_VISIBILITY_CHANGE,
|
||||||
COMPOSE_LANGUAGE_CHANGE,
|
COMPOSE_LANGUAGE_CHANGE,
|
||||||
|
COMPOSE_COMPOSING_CHANGE,
|
||||||
COMPOSE_CONTENT_TYPE_CHANGE,
|
COMPOSE_CONTENT_TYPE_CHANGE,
|
||||||
COMPOSE_EMOJI_INSERT,
|
COMPOSE_EMOJI_INSERT,
|
||||||
COMPOSE_UPLOAD_CHANGE_REQUEST,
|
COMPOSE_UPLOAD_CHANGE_REQUEST,
|
||||||
@ -43,9 +43,7 @@ import {
|
|||||||
COMPOSE_RESET,
|
COMPOSE_RESET,
|
||||||
COMPOSE_POLL_ADD,
|
COMPOSE_POLL_ADD,
|
||||||
COMPOSE_POLL_REMOVE,
|
COMPOSE_POLL_REMOVE,
|
||||||
COMPOSE_POLL_OPTION_ADD,
|
|
||||||
COMPOSE_POLL_OPTION_CHANGE,
|
COMPOSE_POLL_OPTION_CHANGE,
|
||||||
COMPOSE_POLL_OPTION_REMOVE,
|
|
||||||
COMPOSE_POLL_SETTINGS_CHANGE,
|
COMPOSE_POLL_SETTINGS_CHANGE,
|
||||||
INIT_MEDIA_EDIT_MODAL,
|
INIT_MEDIA_EDIT_MODAL,
|
||||||
COMPOSE_CHANGE_MEDIA_DESCRIPTION,
|
COMPOSE_CHANGE_MEDIA_DESCRIPTION,
|
||||||
@ -56,19 +54,13 @@ import {
|
|||||||
import { REDRAFT } from '../actions/statuses';
|
import { REDRAFT } from '../actions/statuses';
|
||||||
import { STORE_HYDRATE } from '../actions/store';
|
import { STORE_HYDRATE } from '../actions/store';
|
||||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||||
import { me, defaultContentType } from '../initial_state';
|
import { me, defaultContentType, pollLimits } from '../initial_state';
|
||||||
import { recoverHashtags } from '../utils/hashtag';
|
import { recoverHashtags } from '../utils/hashtag';
|
||||||
import { unescapeHTML } from '../utils/html';
|
import { unescapeHTML } from '../utils/html';
|
||||||
import { overwrite } from '../utils/js_helpers';
|
import { overwrite } from '../utils/js_helpers';
|
||||||
import { privacyPreference } from '../utils/privacy_preference';
|
import { privacyPreference } from '../utils/privacy_preference';
|
||||||
import { uuid } from '../uuid';
|
import { uuid } from '../uuid';
|
||||||
|
|
||||||
const totalElefriends = 3;
|
|
||||||
|
|
||||||
// ~4% chance you'll end up with an unexpected friend
|
|
||||||
// funamitech/mastodon repo created_at date: 2017-04-20T21:55:28Z
|
|
||||||
const glitchProbability = 1 - 0.0420215528;
|
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
const initialState = ImmutableMap({
|
||||||
mounted: 0,
|
mounted: 0,
|
||||||
advanced_options: ImmutableMap({
|
advanced_options: ImmutableMap({
|
||||||
@ -76,7 +68,6 @@ const initialState = ImmutableMap({
|
|||||||
threaded_mode: false,
|
threaded_mode: false,
|
||||||
}),
|
}),
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
elefriend: Math.random() < glitchProbability ? Math.floor(Math.random() * totalElefriends) : totalElefriends,
|
|
||||||
spoiler: false,
|
spoiler: false,
|
||||||
spoiler_text: '',
|
spoiler_text: '',
|
||||||
privacy: null,
|
privacy: null,
|
||||||
@ -87,9 +78,10 @@ const initialState = ImmutableMap({
|
|||||||
caretPosition: null,
|
caretPosition: null,
|
||||||
preselectDate: null,
|
preselectDate: null,
|
||||||
in_reply_to: null,
|
in_reply_to: null,
|
||||||
|
is_composing: false,
|
||||||
is_submitting: false,
|
is_submitting: false,
|
||||||
is_uploading: false,
|
|
||||||
is_changing_upload: false,
|
is_changing_upload: false,
|
||||||
|
is_uploading: false,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
isUploadingThumbnail: false,
|
isUploadingThumbnail: false,
|
||||||
thumbnailProgress: 0,
|
thumbnailProgress: 0,
|
||||||
@ -252,7 +244,7 @@ function removeMedia(state, mediaId) {
|
|||||||
|
|
||||||
const insertSuggestion = (state, position, token, completion, path) => {
|
const insertSuggestion = (state, position, token, completion, path) => {
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.updateIn(path, oldText => `${oldText.slice(0, position)}${completion}${completion[0] === ':' ? '\u200B' : ' '}${oldText.slice(position + token.length)}`);
|
map.updateIn(path, oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
|
||||||
map.set('suggestion_token', null);
|
map.set('suggestion_token', null);
|
||||||
map.set('suggestions', ImmutableList());
|
map.set('suggestions', ImmutableList());
|
||||||
if (path.length === 1 && path[0] === 'text') {
|
if (path.length === 1 && path[0] === 'text') {
|
||||||
@ -294,14 +286,15 @@ const sortHashtagsByUse = (state, tags) => {
|
|||||||
return sorted;
|
return sorted;
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertEmoji = (state, position, emojiData) => {
|
const insertEmoji = (state, position, emojiData, needsSpace) => {
|
||||||
const emoji = emojiData.native;
|
const oldText = state.get('text');
|
||||||
|
const emoji = needsSpace ? ' ' + emojiData.native : emojiData.native;
|
||||||
|
|
||||||
return state.withMutations(map => {
|
return state.merge({
|
||||||
map.update('text', oldText => `${oldText.slice(0, position)}${emoji}\u200B${oldText.slice(position)}`);
|
text: `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`,
|
||||||
map.set('focusDate', new Date());
|
focusDate: new Date(),
|
||||||
map.set('caretPosition', position + emoji.length + 1);
|
caretPosition: position + emoji.length + 1,
|
||||||
map.set('idempotencyKey', uuid());
|
idempotencyKey: uuid(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -363,6 +356,18 @@ const updateSuggestionTags = (state, token) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updatePoll = (state, index, value) => state.updateIn(['poll', 'options'], options => {
|
||||||
|
const tmp = options.set(index, value).filterNot(x => x.trim().length === 0);
|
||||||
|
|
||||||
|
if (tmp.size === 0) {
|
||||||
|
return tmp.push('').push('');
|
||||||
|
} else if (tmp.size < pollLimits.max_options) {
|
||||||
|
return tmp.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmp;
|
||||||
|
});
|
||||||
|
|
||||||
export default function compose(state = initialState, action) {
|
export default function compose(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case STORE_HYDRATE:
|
case STORE_HYDRATE:
|
||||||
@ -370,7 +375,9 @@ export default function compose(state = initialState, action) {
|
|||||||
case COMPOSE_MOUNT:
|
case COMPOSE_MOUNT:
|
||||||
return state.set('mounted', state.get('mounted') + 1);
|
return state.set('mounted', state.get('mounted') + 1);
|
||||||
case COMPOSE_UNMOUNT:
|
case COMPOSE_UNMOUNT:
|
||||||
return state.set('mounted', Math.max(state.get('mounted') - 1, 0));
|
return state
|
||||||
|
.set('mounted', Math.max(state.get('mounted') - 1, 0))
|
||||||
|
.set('is_composing', false);
|
||||||
case COMPOSE_ADVANCED_OPTIONS_CHANGE:
|
case COMPOSE_ADVANCED_OPTIONS_CHANGE:
|
||||||
return state
|
return state
|
||||||
.set('advanced_options', state.get('advanced_options').set(action.option, !!overwrite(!state.getIn(['advanced_options', action.option]), action.value)))
|
.set('advanced_options', state.get('advanced_options').set(action.option, !!overwrite(!state.getIn(['advanced_options', action.option]), action.value)))
|
||||||
@ -388,8 +395,8 @@ export default function compose(state = initialState, action) {
|
|||||||
map.set('spoiler', !state.get('spoiler'));
|
map.set('spoiler', !state.get('spoiler'));
|
||||||
map.set('idempotencyKey', uuid());
|
map.set('idempotencyKey', uuid());
|
||||||
|
|
||||||
if (!state.get('sensitive') && state.get('media_attachments').size >= 1) {
|
if (state.get('media_attachments').size >= 1 && !state.get('default_sensitive')) {
|
||||||
map.set('sensitive', true);
|
map.set('sensitive', !state.get('spoiler'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
case COMPOSE_SPOILER_TEXT_CHANGE:
|
case COMPOSE_SPOILER_TEXT_CHANGE:
|
||||||
@ -408,9 +415,8 @@ export default function compose(state = initialState, action) {
|
|||||||
return state
|
return state
|
||||||
.set('text', action.text)
|
.set('text', action.text)
|
||||||
.set('idempotencyKey', uuid());
|
.set('idempotencyKey', uuid());
|
||||||
case COMPOSE_CYCLE_ELEFRIEND:
|
case COMPOSE_COMPOSING_CHANGE:
|
||||||
return state
|
return state.set('is_composing', action.value);
|
||||||
.set('elefriend', (state.get('elefriend') + 1) % totalElefriends);
|
|
||||||
case COMPOSE_REPLY:
|
case COMPOSE_REPLY:
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.set('id', null);
|
map.set('id', null);
|
||||||
@ -553,7 +559,7 @@ export default function compose(state = initialState, action) {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
case COMPOSE_EMOJI_INSERT:
|
case COMPOSE_EMOJI_INSERT:
|
||||||
return insertEmoji(state, action.position, action.emoji);
|
return insertEmoji(state, action.position, action.emoji, action.needsSpace);
|
||||||
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
|
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
|
||||||
return state
|
return state
|
||||||
.set('is_changing_upload', false)
|
.set('is_changing_upload', false)
|
||||||
@ -642,12 +648,8 @@ export default function compose(state = initialState, action) {
|
|||||||
return state.set('poll', initialPoll);
|
return state.set('poll', initialPoll);
|
||||||
case COMPOSE_POLL_REMOVE:
|
case COMPOSE_POLL_REMOVE:
|
||||||
return state.set('poll', null);
|
return state.set('poll', null);
|
||||||
case COMPOSE_POLL_OPTION_ADD:
|
|
||||||
return state.updateIn(['poll', 'options'], options => options.push(action.title));
|
|
||||||
case COMPOSE_POLL_OPTION_CHANGE:
|
case COMPOSE_POLL_OPTION_CHANGE:
|
||||||
return state.setIn(['poll', 'options', action.index], action.title);
|
return updatePoll(state, action.index, action.title);
|
||||||
case COMPOSE_POLL_OPTION_REMOVE:
|
|
||||||
return state.updateIn(['poll', 'options'], options => options.delete(action.index));
|
|
||||||
case COMPOSE_POLL_SETTINGS_CHANGE:
|
case COMPOSE_POLL_SETTINGS_CHANGE:
|
||||||
return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple));
|
return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple));
|
||||||
case COMPOSE_LANGUAGE_CHANGE:
|
case COMPOSE_LANGUAGE_CHANGE:
|
||||||
|
@ -119,7 +119,7 @@ const initialState = ImmutableMap({
|
|||||||
dismissed_banners: ImmutableMap({
|
dismissed_banners: ImmutableMap({
|
||||||
'public_timeline': false,
|
'public_timeline': false,
|
||||||
'community_timeline': false,
|
'community_timeline': false,
|
||||||
'home.explore_prompt': false,
|
'home/follow-suggestions': false,
|
||||||
'explore/links': false,
|
'explore/links': false,
|
||||||
'explore/statuses': false,
|
'explore/statuses': false,
|
||||||
'explore/tags': false,
|
'explore/tags': false,
|
||||||
|
@ -28,12 +28,12 @@ export default function suggestionsReducer(state = initialState, action) {
|
|||||||
case SUGGESTIONS_FETCH_FAIL:
|
case SUGGESTIONS_FETCH_FAIL:
|
||||||
return state.set('isLoading', false);
|
return state.set('isLoading', false);
|
||||||
case SUGGESTIONS_DISMISS:
|
case SUGGESTIONS_DISMISS:
|
||||||
return state.update('items', list => list.filterNot(x => x.account === action.id));
|
return state.update('items', list => list.filterNot(x => x.get('account') === action.id));
|
||||||
case blockAccountSuccess.type:
|
case blockAccountSuccess.type:
|
||||||
case muteAccountSuccess.type:
|
case muteAccountSuccess.type:
|
||||||
return state.update('items', list => list.filterNot(x => x.account === action.payload.relationship.id));
|
return state.update('items', list => list.filterNot(x => x.get('account') === action.payload.relationship.id));
|
||||||
case blockDomainSuccess.type:
|
case blockDomainSuccess.type:
|
||||||
return state.update('items', list => list.filterNot(x => action.payload.accounts.includes(x.account)));
|
return state.update('items', list => list.filterNot(x => action.payload.accounts.includes(x.get('account'))));
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,9 @@ import {
|
|||||||
TIMELINE_DISCONNECT,
|
TIMELINE_DISCONNECT,
|
||||||
TIMELINE_LOAD_PENDING,
|
TIMELINE_LOAD_PENDING,
|
||||||
TIMELINE_MARK_AS_PARTIAL,
|
TIMELINE_MARK_AS_PARTIAL,
|
||||||
|
TIMELINE_INSERT,
|
||||||
|
TIMELINE_GAP,
|
||||||
|
TIMELINE_SUGGESTIONS,
|
||||||
} from '../actions/timelines';
|
} from '../actions/timelines';
|
||||||
import { compareId } from '../compare_id';
|
import { compareId } from '../compare_id';
|
||||||
|
|
||||||
@ -32,6 +35,8 @@ const initialTimeline = ImmutableMap({
|
|||||||
items: ImmutableList(),
|
items: ImmutableList(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isPlaceholder = value => value === TIMELINE_GAP || value === TIMELINE_SUGGESTIONS;
|
||||||
|
|
||||||
const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => {
|
const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => {
|
||||||
// This method is pretty tricky because:
|
// This method is pretty tricky because:
|
||||||
// - existing items in the timeline might be out of order
|
// - existing items in the timeline might be out of order
|
||||||
@ -63,20 +68,20 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
|
|||||||
// First, find the furthest (if properly sorted, oldest) item in the timeline that is
|
// First, find the furthest (if properly sorted, oldest) item in the timeline that is
|
||||||
// newer than the oldest fetched one, as it's most likely that it delimits the gap.
|
// newer than the oldest fetched one, as it's most likely that it delimits the gap.
|
||||||
// Start the gap *after* that item.
|
// Start the gap *after* that item.
|
||||||
const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1;
|
const lastIndex = oldIds.findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.last()) >= 0) + 1;
|
||||||
|
|
||||||
// Then, try to find the furthest (if properly sorted, oldest) item in the timeline that
|
// Then, try to find the furthest (if properly sorted, oldest) item in the timeline that
|
||||||
// is newer than the most recent fetched one, as it delimits a section comprised of only
|
// is newer than the most recent fetched one, as it delimits a section comprised of only
|
||||||
// items older or within `newIds` (or that were deleted from the server, so should be removed
|
// items older or within `newIds` (or that were deleted from the server, so should be removed
|
||||||
// anyway).
|
// anyway).
|
||||||
// Stop the gap *after* that item.
|
// Stop the gap *after* that item.
|
||||||
const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0) + 1;
|
const firstIndex = oldIds.take(lastIndex).findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.first()) > 0) + 1;
|
||||||
|
|
||||||
let insertedIds = ImmutableOrderedSet(newIds).withMutations(insertedIds => {
|
let insertedIds = ImmutableOrderedSet(newIds).withMutations(insertedIds => {
|
||||||
// It is possible, though unlikely, that the slice we are replacing contains items older
|
// It is possible, though unlikely, that the slice we are replacing contains items older
|
||||||
// than the elements we got from the API. Get them and add them back at the back of the
|
// than the elements we got from the API. Get them and add them back at the back of the
|
||||||
// slice.
|
// slice.
|
||||||
const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => id !== null && compareId(id, newIds.last()) < 0);
|
const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => !isPlaceholder(id) && compareId(id, newIds.last()) < 0);
|
||||||
insertedIds.union(olderIds);
|
insertedIds.union(olderIds);
|
||||||
|
|
||||||
// Make sure we aren't inserting duplicates
|
// Make sure we aren't inserting duplicates
|
||||||
@ -84,8 +89,8 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
|
|||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// Finally, insert a gap marker if the data is marked as partial by the server
|
// Finally, insert a gap marker if the data is marked as partial by the server
|
||||||
if (isPartial && (firstIndex === 0 || oldIds.get(firstIndex - 1) !== null)) {
|
if (isPartial && (firstIndex === 0 || oldIds.get(firstIndex - 1) !== TIMELINE_GAP)) {
|
||||||
insertedIds = insertedIds.unshift(null);
|
insertedIds = insertedIds.unshift(TIMELINE_GAP);
|
||||||
}
|
}
|
||||||
|
|
||||||
return oldIds.take(firstIndex).concat(
|
return oldIds.take(firstIndex).concat(
|
||||||
@ -184,7 +189,7 @@ const reconnectTimeline = (state, usePendingItems) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return state.withMutations(mMap => {
|
return state.withMutations(mMap => {
|
||||||
mMap.update(usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items);
|
mMap.update(usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(TIMELINE_GAP) : items);
|
||||||
mMap.set('online', true);
|
mMap.set('online', true);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -219,7 +224,7 @@ export default function timelines(state = initialState, action) {
|
|||||||
return state.update(
|
return state.update(
|
||||||
action.timeline,
|
action.timeline,
|
||||||
initialTimeline,
|
initialTimeline,
|
||||||
map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items),
|
map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(TIMELINE_GAP) : items),
|
||||||
);
|
);
|
||||||
case TIMELINE_MARK_AS_PARTIAL:
|
case TIMELINE_MARK_AS_PARTIAL:
|
||||||
return state.update(
|
return state.update(
|
||||||
@ -227,6 +232,18 @@ export default function timelines(state = initialState, action) {
|
|||||||
initialTimeline,
|
initialTimeline,
|
||||||
map => map.set('isPartial', true).set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('unread', 0),
|
map => map.set('isPartial', true).set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('unread', 0),
|
||||||
);
|
);
|
||||||
|
case TIMELINE_INSERT:
|
||||||
|
return state.update(
|
||||||
|
action.timeline,
|
||||||
|
initialTimeline,
|
||||||
|
map => map.update('items', ImmutableList(), list => {
|
||||||
|
if (!list.includes(action.key)) {
|
||||||
|
return list.insert(action.index, action.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
})
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
@ -24,13 +24,14 @@
|
|||||||
outline: 0;
|
outline: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 0;
|
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background: $ui-base-color;
|
background: $ui-base-color;
|
||||||
color: $darker-text-color;
|
color: $darker-text-color;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 14px;
|
border: 1px solid lighten($ui-base-color, 8%);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: normal;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1336,6 +1336,9 @@ a.sparkline {
|
|||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__rules {
|
&__rules {
|
||||||
@ -1346,6 +1349,9 @@ a.sparkline {
|
|||||||
&__rule {
|
&__rule {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: $font-sans-serif, sans-serif;
|
font-family: $font-sans-serif, sans-serif;
|
||||||
background: darken($ui-base-color, 7%);
|
background: darken($ui-base-color, 8%);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user