Merge remote-tracking branch 'parent/main' into upstream-20240126

This commit is contained in:
KMY 2024-01-26 11:14:21 +09:00
commit 3dd9591a4b
163 changed files with 3000 additions and 2777 deletions

View file

@ -165,7 +165,7 @@ module.exports = defineConfig({
// },
// ],
'jsx-a11y/no-noninteractive-tabindex': 'off',
'jsx-a11y/no-onchange': 'warn',
'jsx-a11y/no-onchange': 'off',
// recommended is full 'error'
'jsx-a11y/no-static-element-interactions': [
'warn',

View file

@ -78,23 +78,8 @@ jobs:
- name: Create database
run: './bin/rails db:create'
- name: Run migrations up to v2.0.0
run: './bin/rails db:migrate VERSION=20171010025614'
- name: Populate database with test data
run: './bin/rails tests:migrations:populate_v2'
- name: Run migrations up to v2.4.0
run: './bin/rails db:migrate VERSION=20180514140000'
- name: Populate database with test data
run: './bin/rails tests:migrations:populate_v2_4'
- name: Run migrations up to v2.4.3
run: './bin/rails db:migrate VERSION=20180707154237'
- name: Populate database with test data
run: './bin/rails tests:migrations:populate_v2_4_3'
- name: Run historical migrations with data population
run: './bin/rails tests:migrations:prepare_database'
- name: Run all remaining migrations
run: './bin/rails db:migrate'

View file

@ -45,6 +45,7 @@ jobs:
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
@ -77,28 +78,11 @@ jobs:
- name: Create database
run: './bin/rails db:create'
- name: Run migrations up to v2.0.0
run: './bin/rails db:migrate VERSION=20171010025614'
- name: Populate database with test data
run: './bin/rails tests:migrations:populate_v2'
- name: Run pre-deployment migrations up to v2.4.0
run: './bin/rails db:migrate VERSION=20180514140000'
- name: Run historical migrations with data population
run: './bin/rails tests:migrations:prepare_database'
env:
SKIP_POST_DEPLOYMENT_MIGRATIONS: true
- name: Populate database with test data
run: './bin/rails tests:migrations:populate_v2_4'
- name: Run migrations up to v2.4.3
run: './bin/rails db:migrate VERSION=20180707154237'
env:
SKIP_POST_DEPLOYMENT_MIGRATIONS: true
- name: Populate database with test data
run: './bin/rails tests:migrations:populate_v2_4_3'
- name: Run all remaining pre-deployment migrations
run: './bin/rails db:migrate'
env:

View file

@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.59.0.
# using RuboCop version 1.60.2.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
@ -73,21 +73,6 @@ Rails/UniqueValidationWithoutIndex:
- 'app/models/identity.rb'
- 'app/models/webauthn_credential.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: EnforcedStyle.
# SupportedStyles: exists, where
Rails/WhereExists:
Exclude:
- 'app/controllers/activitypub/inboxes_controller.rb'
- 'app/controllers/admin/email_domain_blocks_controller.rb'
- 'app/policies/status_policy.rb'
- 'app/serializers/rest/announcement_serializer.rb'
- 'app/workers/move_worker.rb'
- 'spec/models/account_spec.rb'
- 'spec/services/activitypub/process_collection_service_spec.rb'
- 'spec/services/purge_domain_service_spec.rb'
- 'spec/services/unallow_domain_service_spec.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: AllowedMethods, AllowedPatterns.
# AllowedMethods: ==, equal?, eql?
@ -139,10 +124,6 @@ Style/GlobalStdStream:
# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals.
Style/GuardClause:
Exclude:
- 'app/controllers/admin/confirmations_controller.rb'
- 'app/controllers/auth/confirmations_controller.rb'
- 'app/controllers/auth/passwords_controller.rb'
- 'app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb'
- 'app/lib/activitypub/activity/block.rb'
- 'app/lib/request.rb'
- 'app/lib/request_pool.rb'
@ -298,13 +279,6 @@ Style/StringLiterals:
- 'config/routes.rb'
- 'db/schema.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle, AllowSafeAssignment.
# SupportedStyles: require_parentheses, require_no_parentheses, require_parentheses_when_complex
Style/TernaryParentheses:
Exclude:
- 'config/environments/development.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyleForMultiline.
# SupportedStylesForMultiline: comma, consistent_comma, no_comma

View file

@ -123,7 +123,7 @@ group :test do
gem 'database_cleaner-active_record'
# Used to mock environment variables
gem 'climate_control', '~> 0.2'
gem 'climate_control'
# Generating fake data for specs
gem 'faker', '~> 3.2'

View file

@ -185,7 +185,7 @@ GEM
elasticsearch (>= 7.12.0, < 7.14.0)
elasticsearch-dsl
chunky_png (1.4.0)
climate_control (0.2.0)
climate_control (1.2.0)
cocoon (1.2.15)
color_diff (0.1)
concurrent-ruby (1.2.3)
@ -360,7 +360,7 @@ GEM
rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1)
idn-ruby (0.1.5)
io-console (0.7.1)
io-console (0.7.2)
irb (1.11.1)
rdoc
reline (>= 0.4.2)
@ -445,7 +445,7 @@ GEM
mime-types-data (3.2023.1205)
mini_mime (1.1.5)
mini_portile2 (2.8.5)
minitest (5.21.1)
minitest (5.21.2)
msgpack (1.7.2)
multi_json (1.15.0)
multipart-post (2.3.0)
@ -636,7 +636,7 @@ GEM
rspec-mocks (3.12.6)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-rails (6.1.0)
rspec-rails (6.1.1)
actionpack (>= 6.1)
activesupport (>= 6.1)
railties (>= 6.1)
@ -748,8 +748,8 @@ GEM
temple (0.10.3)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
terrapin (0.6.0)
climate_control (>= 0.0.3, < 1.0)
terrapin (1.0.1)
climate_control
test-prof (1.3.1)
thor (1.3.0)
tilt (2.3.0)
@ -838,7 +838,7 @@ DEPENDENCIES
capybara (~> 3.39)
charlock_holmes (~> 0.7.7)
chewy (~> 7.3)
climate_control (~> 0.2)
climate_control
cocoon (~> 1.2)
color_diff (~> 0.1)
concurrent-ruby

View file

@ -24,7 +24,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
def unknown_affected_account?
json = Oj.load(body, mode: :strict)
json.is_a?(Hash) && %w(Delete Update).include?(json['type']) && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists?
json.is_a?(Hash) && %w(Delete Update).include?(json['type']) && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.exists?(uri: json['actor'])
rescue Oj::ParseError
false
end

View file

@ -3,7 +3,7 @@
module Admin
class ConfirmationsController < BaseController
before_action :set_user
before_action :check_confirmation, only: [:resend]
before_action :redirect_confirmed_user, only: [:resend], if: :user_confirmed?
def create
authorize @user, :confirm?
@ -25,11 +25,13 @@ module Admin
private
def check_confirmation
if @user.confirmed?
flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed')
redirect_to admin_accounts_path
end
def redirect_confirmed_user
flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed')
redirect_to admin_accounts_path
end
def user_confirmed?
@user.confirmed?
end
end
end

View file

@ -38,7 +38,7 @@ module Admin
log_action :create, @email_domain_block
(@email_domain_block.other_domains || []).uniq.each do |domain|
next if EmailDomainBlock.where(domain: domain).exists?
next if EmailDomainBlock.exists?(domain: domain)
other_email_domain_block = EmailDomainBlock.create!(domain: domain, allow_with_approval: @email_domain_block.allow_with_approval, parent: @email_domain_block)
log_action :create, other_email_domain_block

View file

@ -49,7 +49,7 @@ module Admin
next
end
@warning_domains = Instance.where(domain: @domain_blocks.map(&:domain)).where('EXISTS (SELECT 1 FROM follows JOIN accounts ON follows.account_id = accounts.id OR follows.target_account_id = accounts.id WHERE accounts.domain = instances.domain)').pluck(:domain)
@warning_domains = instances_from_imported_blocks.pluck(:domain)
rescue ActionController::ParameterMissing
flash.now[:alert] = I18n.t('admin.export_domain_blocks.no_file')
set_dummy_import!
@ -58,6 +58,10 @@ module Admin
private
def instances_from_imported_blocks
Instance.with_domain_follows(@domain_blocks.map(&:domain))
end
def export_filename
'domain_blocks.csv'
end

View file

@ -7,7 +7,7 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
before_action :set_body_classes
before_action :set_confirmation_user!, only: [:show, :confirm_captcha]
before_action :require_unconfirmed!
before_action :redirect_confirmed_user, if: :signed_in_confirmed_user?
before_action :extend_csp_for_captcha!, only: [:show, :confirm_captcha]
before_action :require_captcha_if_needed!, only: [:show]
@ -65,10 +65,12 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
@confirmation_user.nil? || @confirmation_user.confirmed?
end
def require_unconfirmed!
if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank?
redirect_to(current_user.approved? ? root_path : edit_user_registration_path)
end
def redirect_confirmed_user
redirect_to(current_user.approved? ? root_path : edit_user_registration_path)
end
def signed_in_confirmed_user?
user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank?
end
def set_body_classes

View file

@ -2,7 +2,7 @@
class Auth::PasswordsController < Devise::PasswordsController
skip_before_action :check_self_destruct!
before_action :check_validity_of_reset_password_token, only: :edit
before_action :redirect_invalid_reset_token, only: :edit, unless: :reset_password_token_is_valid?
before_action :set_body_classes
layout 'auth'
@ -19,11 +19,9 @@ class Auth::PasswordsController < Devise::PasswordsController
private
def check_validity_of_reset_password_token
unless reset_password_token_is_valid?
flash[:error] = I18n.t('auth.invalid_reset_password_token')
redirect_to new_password_path(resource_name)
end
def redirect_invalid_reset_token
flash[:error] = I18n.t('auth.invalid_reset_password_token')
redirect_to new_password_path(resource_name)
end
def set_body_classes

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true
class Redirect::AccountsController < ApplicationController
class Redirect::AccountsController < Redirect::BaseController
private
def set_resource

View file

@ -6,8 +6,8 @@ module Settings
skip_before_action :check_self_destruct!
skip_before_action :require_functional!
before_action :require_otp_enabled
before_action :require_webauthn_enabled, only: [:index, :destroy]
before_action :redirect_invalid_otp, unless: -> { current_user.otp_enabled? }
before_action :redirect_invalid_webauthn, only: [:index, :destroy], unless: -> { current_user.webauthn_enabled? }
def index; end
def new; end
@ -85,18 +85,14 @@ module Settings
private
def require_otp_enabled
unless current_user.otp_enabled?
flash[:error] = t('webauthn_credentials.otp_required')
redirect_to settings_two_factor_authentication_methods_path
end
def redirect_invalid_otp
flash[:error] = t('webauthn_credentials.otp_required')
redirect_to settings_two_factor_authentication_methods_path
end
def require_webauthn_enabled
unless current_user.webauthn_enabled?
flash[:error] = t('webauthn_credentials.not_enabled')
redirect_to settings_two_factor_authentication_methods_path
end
def redirect_invalid_webauthn
flash[:error] = t('webauthn_credentials.not_enabled')
redirect_to settings_two_factor_authentication_methods_path
end
end
end

View file

@ -0,0 +1,25 @@
<svg width="5" height="80" viewBox="0 0 5 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_253_1286)">
<rect width="5" height="80" fill="url(#paint0_linear_253_1286)"/>
<line x1="-0.860365" y1="6.80136" x2="10.6078" y2="-1.22871" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="14.8314" x2="10.6078" y2="6.80132" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="22.8615" x2="10.6078" y2="14.8314" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="30.8916" x2="10.6078" y2="22.8615" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="38.9216" x2="10.6078" y2="30.8915" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="46.9517" x2="10.6078" y2="38.9216" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="54.9818" x2="10.6078" y2="46.9517" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="63.0118" x2="10.6078" y2="54.9817" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="71.0419" x2="10.6078" y2="63.0118" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="79.072" x2="10.6078" y2="71.0419" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="87.102" x2="10.6078" y2="79.072" stroke="black" stroke-width="3"/>
</g>
<defs>
<linearGradient id="paint0_linear_253_1286" x1="2.5" y1="0" x2="2.5" y2="80" gradientUnits="userSpaceOnUse">
<stop stop-color="#FEC84B"/>
<stop offset="1" stop-color="#F79009"/>
</linearGradient>
<clipPath id="clip0_253_1286">
<rect width="5" height="80" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -9,7 +9,11 @@ exports[`<AutosuggestEmoji /> renders emoji with custom url 1`] = `
className="emojione"
src="http://example.com/emoji.png"
/>
:foobar:
<div
className="autosuggest-emoji__name"
>
:foobar:
</div>
</div>
`;
@ -22,6 +26,10 @@ exports[`<AutosuggestEmoji /> renders native emoji 1`] = `
className="emojione"
src="/emoji/1f499.svg"
/>
:foobar:
<div
className="autosuggest-emoji__name"
>
:foobar:
</div>
</div>
`;

View file

@ -37,10 +37,10 @@ class Account extends ImmutablePureComponent {
static propTypes = {
size: PropTypes.number,
account: ImmutablePropTypes.record,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
onMuteNotifications: PropTypes.func.isRequired,
onFollow: PropTypes.func,
onBlock: PropTypes.func,
onMute: PropTypes.func,
onMuteNotifications: PropTypes.func,
intl: PropTypes.object.isRequired,
hidden: PropTypes.bool,
hideButtons: PropTypes.bool,

View file

@ -35,7 +35,7 @@ export default class AutosuggestEmoji extends PureComponent {
alt={emoji.native || emoji.colons}
/>
{emoji.colons}
<div className='autosuggest-emoji__name'>{emoji.colons}</div>
</div>
);
}

View file

@ -1,5 +1,3 @@
import { FormattedMessage } from 'react-intl';
import { ShortNumber } from 'mastodon/components/short_number';
interface Props {
@ -16,27 +14,18 @@ interface Props {
};
}
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => {
const weeklyUses = tag.history && (
<ShortNumber
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
/>
);
return (
<div className='autosuggest-hashtag'>
<div className='autosuggest-hashtag__name'>
#<strong>{tag.name}</strong>
</div>
{tag.history !== undefined && (
<div className='autosuggest-hashtag__uses'>
<FormattedMessage
id='autosuggest_hashtag.per_week'
defaultMessage='{count} per week'
values={{ count: weeklyUses }}
/>
</div>
)}
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => (
<div className='autosuggest-hashtag'>
<div className='autosuggest-hashtag__name'>
#<strong>{tag.name}</strong>
</div>
);
};
{tag.history !== undefined && (
<div className='autosuggest-hashtag__uses'>
<ShortNumber
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
/>
</div>
)}
</div>
);

View file

@ -5,6 +5,8 @@ import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Overlay from 'react-overlays/Overlay';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
@ -195,34 +197,37 @@ export default class AutosuggestInput extends ImmutablePureComponent {
return (
<div className='autosuggest-input'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<input
type='text'
ref={this.setInput}
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
dir='auto'
aria-autocomplete='list'
aria-label={placeholder}
id={id}
className={className}
maxLength={maxLength}
lang={lang}
spellCheck={spellCheck}
/>
<input
type='text'
ref={this.setInput}
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
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>
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={this.input} popperConfig={{ strategy: 'fixed' }}>
{({ props }) => (
<div {...props}>
<div className='autosuggest-textarea__suggestions' style={{ width: this.input?.clientWidth }}>
{suggestions.map(this.renderSuggestion)}
</div>
</div>
)}
</Overlay>
</div>
);
}

View file

@ -5,6 +5,7 @@ import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Overlay from 'react-overlays/Overlay';
import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
@ -52,7 +53,6 @@ const AutosuggestTextarea = forwardRef(({
onFocus,
autoFocus = true,
lang,
children,
}, textareaRef) => {
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
@ -183,40 +183,38 @@ const AutosuggestTextarea = forwardRef(({
);
};
return [
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
<div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
return (
<div className='autosuggest-textarea'>
<Textarea
ref={textareaRef}
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
ref={textareaRef}
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'
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>,
];
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
{({ props }) => (
<div {...props}>
<div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
{suggestions.map(renderSuggestion)}
</div>
</div>
)}
</Overlay>
</div>
);
});
AutosuggestTextarea.propTypes = {
@ -232,7 +230,6 @@ AutosuggestTextarea.propTypes = {
onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired,
onFocus:PropTypes.func,
children: PropTypes.node,
autoFocus: PropTypes.bool,
lang: PropTypes.string,
};

View file

@ -165,7 +165,7 @@ class Dropdown extends PureComponent {
children: PropTypes.node,
icon: PropTypes.string,
iconComponent: PropTypes.func,
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]),
loading: PropTypes.bool,
size: PropTypes.number,
title: PropTypes.string,

View file

@ -76,6 +76,10 @@ export const defaultMediaVisibility = (status) => {
};
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
});

View file

@ -6,8 +6,8 @@ import PublicUnlistedIcon from '@/material-icons/400-24px/cloud.svg?react';
import MutualIcon from '@/material-icons/400-24px/compare_arrows.svg?react';
import LoginIcon from '@/material-icons/400-24px/key.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import LockOpenIcon from '@/material-icons/400-24px/no_encryption.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import LimitedIcon from '@/material-icons/400-24px/shield.svg?react';
import PersonalIcon from '@/material-icons/400-24px/sticky_note.svg?react';
@ -33,11 +33,17 @@ const messages = defineMessages({
id: 'privacy.public_unlisted.short',
defaultMessage: 'Public unlisted',
},
login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
login_short: {
id: 'privacy.login.short',
defaultMessage: 'Login only',
},
unlisted_short: {
id: 'privacy.unlisted.short',
defaultMessage: 'Quiet public',
},
private_short: {
id: 'privacy.private.short',
defaultMessage: 'Followers only',
defaultMessage: 'Followers',
},
limited_short: {
id: 'privacy.limited.short',
@ -61,7 +67,7 @@ const messages = defineMessages({
},
direct_short: {
id: 'privacy.direct.short',
defaultMessage: 'Mentioned people only',
defaultMessage: 'Specific people',
},
});
@ -88,7 +94,7 @@ export const VisibilityIcon: React.FC<{ visibility: Visibility }> = ({
},
unlisted: {
icon: 'unlock',
iconComponent: LockOpenIcon,
iconComponent: QuietTimeIcon,
text: intl.formatMessage(messages.unlisted_short),
},
private: {

View file

@ -1,14 +1,12 @@
import { PureComponent } from 'react';
import { Provider } from 'react-redux';
import { fetchCustomEmojis } from '../actions/custom_emojis';
import { hydrateStore } from '../actions/store';
import Compose from '../features/standalone/compose';
import initialState from '../initial_state';
import { IntlProvider } from '../locales';
import { store } from '../store';
import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
import { hydrateStore } from 'mastodon/actions/store';
import { Router } from 'mastodon/components/router';
import Compose from 'mastodon/features/standalone/compose';
import initialState from 'mastodon/initial_state';
import { IntlProvider } from 'mastodon/locales';
import { store } from 'mastodon/store';
if (initialState) {
store.dispatch(hydrateStore(initialState));
@ -16,16 +14,14 @@ if (initialState) {
store.dispatch(fetchCustomEmojis());
export default class ComposeContainer extends PureComponent {
const ComposeContainer = () => (
<IntlProvider>
<Provider store={store}>
<Router>
<Compose />
</Router>
</Provider>
</IntlProvider>
);
render () {
return (
<IntlProvider>
<Provider store={store}>
<Compose />
</Provider>
</IntlProvider>
);
}
}
export default ComposeContainer;

View file

@ -1,13 +1,13 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { useCallback } 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 DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { openModal } from 'mastodon/actions/modal';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { logOut } from 'mastodon/utils/log_out';
const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
@ -25,53 +25,54 @@ const messages = defineMessages({
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
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 = {
account: ImmutablePropTypes.record.isRequired,
onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
const handleLogoutClick = useCallback(() => {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
closeWhenConfirm: false,
onConfirm: () => logOut(),
},
}));
}, [dispatch, intl]);
handleLogout = () => {
this.props.onLogout();
};
let menu = [];
render () {
const { intl } = this.props;
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
menu.push({ text: intl.formatMessage(messages.reaction_deck), to: '/reaction_deck' });
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.emoji_reactions), to: '/emoji_reactions' });
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 = [];
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
menu.push({ text: intl.formatMessage(messages.reaction_deck), to: '/reaction_deck' });
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.bookmarks), to: '/bookmark_categories' });
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.emoji_reactions), to: '/emoji_reactions' });
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);
return (
<DropdownMenuContainer
items={menu}
icon='bars'
iconComponent={MoreHorizIcon}
size={24}
direction='right'
/>
);
};

View file

@ -15,7 +15,7 @@ export default class AutosuggestAccount extends ImmutablePureComponent {
return (
<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} />
</div>
);

View file

@ -1,26 +1,18 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { length } from 'stringz';
export default class CharacterCounter extends PureComponent {
export const CharacterCounter = ({ text, max }) => {
const diff = max - length(text);
static propTypes = {
text: PropTypes.string.isRequired,
max: PropTypes.number.isRequired,
};
checkRemainingText (diff) {
if (diff < 0) {
return <span className='character-counter character-counter--over'>{diff}</span>;
}
return <span className='character-counter'>{diff}</span>;
if (diff < 0) {
return <span className='character-counter character-counter--over'>{diff}</span>;
}
render () {
const diff = this.props.max - length(this.props.text);
return this.checkRemainingText(diff);
}
return <span className='character-counter'>{diff}</span>;
};
}
CharacterCounter.propTypes = {
text: PropTypes.string.isRequired,
max: PropTypes.number.isRequired,
};

View file

@ -0,0 +1,262 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay';
import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import { Icon } from 'mastodon/components/icon';
const messages = defineMessages({
add_expiration: { id: 'status.expiration.add', defaultMessage: 'Set status expiration' },
});
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
class CircleDropdownMenu extends PureComponent {
static propTypes = {
style: PropTypes.object,
items: PropTypes.array.isRequired,
value: PropTypes.string,
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__content'>
<strong>{item.text}</strong>
</div>
</div>
))}
</div>
);
}
}
class CircleDropdown extends PureComponent {
static propTypes = {
isUserTouching: PropTypes.func,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
onChange: PropTypes.func.isRequired,
noDirect: PropTypes.bool,
container: PropTypes.func,
disabled: PropTypes.bool,
circles: ImmutablePropTypes.map,
circleId: PropTypes.string,
unavailable: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
state = {
open: false,
placement: 'bottom',
};
handleToggle = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: !this.state.open });
};
handleModalActionClick = (e) => {
e.preventDefault();
const { value } = this.options[e.currentTarget.getAttribute('data-index')];
this.props.onModalClose();
this.props.onChange(value);
};
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);
};
setTargetRef = c => {
this.target = c;
};
findTarget = () => {
return this.target;
};
handleOverlayEnter = (state) => {
this.setState({ placement: state.placement });
};
render () {
const { container, disabled, intl, unavailable, circles, circleId } = this.props;
const { open, placement } = this.state;
if (unavailable) {
return null;
}
const listOptions = circles.toArray().filter((circle) => circle && circle.length > 1).map((circle) => {
return circle[1] ? { value: circle[1].get('id'), text: circle[1].get('title') } : null;
}).filter((opt) => opt !== null);
const listOption = listOptions.find((opt) => opt.value === circleId) ?? { value: '0', text: 'Unselected' };
return (
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
<button
type='button'
title={intl.formatMessage(messages.add_expiration)}
aria-expanded={open}
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
disabled={disabled}
className={classNames('dropdown-button', { active: open })}
>
<Icon id='clock-o' icon={CircleIcon} />
<span className='dropdown-button__label'>{listOption.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}`}>
<CircleDropdownMenu
items={listOptions}
value={circleId}
onClose={this.handleClose}
onChange={this.handleChange}
/>
</div>
</div>
)}
</Overlay>
</div>
);
}
}
export default injectIntl(CircleDropdown);

View file

@ -1,59 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Select, { NonceProvider } from 'react-select';
class CircleSelect extends PureComponent {
static propTypes = {
unavailable: PropTypes.bool,
intl: PropTypes.object.isRequired,
circles: ImmutablePropTypes.map,
circleId: PropTypes.string,
onChange: PropTypes.func.isRequired,
};
handleClick = value => {
this.props.onChange(value.value);
};
noOptionsMessage = () => '';
render () {
const { unavailable, circles, circleId } = this.props;
if (unavailable) {
return null;
}
const listOptions = circles.toArray().filter((circle) => circle).map((circle) => {
return { value: circle[1].get('id'), label: circle[1].get('title') };
});
const listValue = listOptions.find((opt) => opt.value === circleId);
return (
<div className='compose-form__circle-select'>
<NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content} cacheKey='circles'>
<Select
value={listValue}
options={listOptions}
noOptionsMessage={this.noOptionsMessage}
onChange={this.handleClick}
className='column-content-select__container'
classNamePrefix='column-content-select'
name='circles'
defaultOptions
/>
</NonceProvider>
</div>
);
}
}
export default injectIntl(CircleSelect);

View file

@ -10,23 +10,19 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import LimitedIcon from '@/material-icons/400-24px/shield.svg?react';
import { Icon } from 'mastodon/components/icon';
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router';
import AutosuggestInput from '../../../components/autosuggest_input';
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import { Button } from '../../../components/button';
import CircleSelectContainer from '../containers/circle_select_container';
import CircleDropdownContainer from '../containers/circle_dropdown_container';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import ExpirationDropdownContainer from '../containers/expiration_dropdown_container';
import LanguageDropdown from '../containers/language_dropdown_container';
import MarkdownButtonContainer from '../containers/markdown_button_container';
import PollButtonContainer from '../containers/poll_button_container';
import PollFormContainer from '../containers/poll_form_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import SearchabilityDropdownContainer from '../containers/searchability_dropdown_container';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import UploadButtonContainer from '../containers/upload_button_container';
@ -34,16 +30,20 @@ import UploadFormContainer from '../containers/upload_form_container';
import WarningContainer from '../containers/warning_container';
import { countableText } from '../util/counter';
import CharacterCounter from './character_counter';
import { CharacterCounter } from './character_counter';
import { EditIndicator } from './edit_indicator';
import { NavigationBar } from './navigation_bar';
import { PollForm } from "./poll_form";
import { ReplyIndicator } from './reply_indicator';
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({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
publish: { id: 'compose_form.publish', defaultMessage: 'Publish' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning (optional)' },
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Update' },
reply: { id: 'compose_form.reply', defaultMessage: 'Reply' },
});
class ComposeForm extends ImmutablePureComponent {
@ -72,6 +72,7 @@ class ComposeForm extends ImmutablePureComponent {
onPickEmoji: PropTypes.func.isRequired,
onPickExpiration: PropTypes.func.isRequired,
autoFocus: PropTypes.bool,
withoutNavigation: PropTypes.bool,
anyMedia: PropTypes.bool,
isInReply: PropTypes.bool,
singleColumn: PropTypes.bool,
@ -237,102 +238,100 @@ class ComposeForm extends ImmutablePureComponent {
};
render () {
const { intl, onPaste, autoFocus } = this.props;
const { intl, onPaste, autoFocus, withoutNavigation } = this.props;
const { highlighted } = this.state;
const disabled = this.props.isSubmitting;
let publishText = '';
if (this.props.isEditing) {
publishText = intl.formatMessage(messages.saveChanges);
} else if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
publishText = <><Icon id='lock' icon={LockIcon} /> {intl.formatMessage(messages.publish)}</>;
} else if (['circle', 'mutual', 'limited', 'reply'].includes(this.props.privacy)) {
publishText = <><Icon id='get-pocket' icon={LimitedIcon} /> {intl.formatMessage(messages.publish)}</>;
} else {
publishText = this.props.privacy === 'public' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
}
return (
<form className='compose-form' onSubmit={this.handleSubmit}>
<ReplyIndicator />
{!withoutNavigation && <NavigationBar />}
<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}>
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={this.props.spoilerText}
onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDown}
disabled={!this.props.spoiler}
ref={this.setSpoilerText}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSpoilerSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='spoiler-input__input'
lang={this.props.lang}
spellCheck
/>
</div>
{this.props.spoiler && (
<div className='spoiler-input'>
<div className='spoiler-input__border' />
<div className={classNames('compose-form__highlightable', { active: highlighted })}>
<AutosuggestTextarea
ref={this.textareaRef}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={disabled}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={autoFocus}
lang={this.props.lang}
>
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={this.props.spoilerText}
disabled={disabled}
onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDown}
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}
spellCheck
/>
<div className='compose-form__modifiers'>
<UploadFormContainer />
<PollFormContainer />
</div>
</AutosuggestTextarea>
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
<ExpirationDropdownContainer onPickExpiration={this.handleExpirationPick} />
<div className='spoiler-input__border' />
</div>
)}
<div className='compose-form__buttons-wrapper'>
<div className='compose-form__buttons'>
<UploadButtonContainer />
<PollButtonContainer />
<PrivacyDropdownContainer disabled={this.props.isEditing} />
<SearchabilityDropdownContainer disabled={this.props.isEditing} />
<SpoilerButtonContainer />
<LanguageDropdown />
<MarkdownButtonContainer />
</div>
<div className='character-counter__wrapper'>
<CharacterCounter max={500} text={this.getFulltextForCharacterCounting()} />
</div>
</div>
</div>
<CircleSelectContainer />
<div className='compose-form__publish'>
<div className='compose-form__publish-button-wrapper'>
<Button
type='submit'
text={publishText}
disabled={!this.canSubmit()}
block
<AutosuggestTextarea
ref={this.textareaRef}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={disabled}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={autoFocus}
lang={this.props.lang}
/>
</div>
<UploadFormContainer />
<PollForm />
<div className='compose-form__footer'>
<div className='compose-form__dropdowns'>
<PrivacyDropdownContainer disabled={this.props.isEditing} />
<LanguageDropdown />
</div>
<div className='compose-form__dropdowns compose-form__dropdowns__second'>
<CircleDropdownContainer />
</div>
<div className='compose-form__dropdowns compose-form__dropdowns__second'>
<SearchabilityDropdownContainer disabled={this.props.isEditing} />
<ExpirationDropdownContainer onPickExpiration={this.handleExpirationPick} />
</div>
<div className='compose-form__actions'>
<div className='compose-form__buttons'>
<UploadButtonContainer />
<PollButtonContainer />
<SpoilerButtonContainer />
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
<MarkdownButtonContainer />
<CharacterCounter max={500} text={this.getFulltextForCharacterCounting()} />
</div>
<div className='compose-form__submit'>
<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>
</form>
);

View file

@ -0,0 +1,62 @@
import { useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import BarChart4BarsIcon from 'mastodon/../material-icons/400-24px/bar_chart_4_bars.svg?react';
import CloseIcon from 'mastodon/../material-icons/400-24px/close.svg?react';
import PhotoLibraryIcon from 'mastodon/../material-icons/400-24px/photo_library.svg?react';
import { cancelReplyCompose } from 'mastodon/actions/compose';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { RelativeTimestamp } from 'mastodon/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'>
<Link to={`/@${account.get('acct')}`}>@{account.get('acct')}</Link>
·
<Link to={`/@${account.get('acct')}/${status.get('id')}`}><RelativeTimestamp timestamp={status.get('created_at')} /></Link>
</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>
);
};

View file

@ -10,6 +10,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay';
import MoodIcon from 'mastodon/../material-icons/400-24px/mood.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
import { assetHost } from 'mastodon/utils/config';
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
@ -321,7 +323,6 @@ class EmojiPickerDropdown extends PureComponent {
onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
button: PropTypes.node,
};
state = {
@ -398,23 +399,24 @@ class EmojiPickerDropdown extends PureComponent {
};
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 { active, loading, bottom } = this.state;
return (
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
<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}>
{button || <img
className={classNames('emojione', { 'pulse-loading': active && loading })}
alt='🙂'
src={`${assetHost}/emoji/1f642.svg`}
/>}
</div>
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown} ref={this.setTargetRef}>
<IconButton
title={title}
aria-expanded={active}
active={active}
iconComponent={MoodIcon}
onClick={this.onToggle}
inverted
/>
<Overlay show={active} placement={ bottom ? 'bottom' : 'top' } target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
{({ props, placement })=> (
<div {...props} style={{ ...props.style, width: 299 }}>
<div {...props} style={{ ...props.style }}>
<div className={`dropdown-animation ${placement}`}>
<EmojiPickerMenu
custom_emojis={this.props.custom_emojis}

View file

@ -9,8 +9,7 @@ import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay';
import TimerIcon from '@/material-icons/400-24px/timer.svg?react';
import { IconButton } from '../../../components/icon_button';
import { Icon } from 'mastodon/components/icon';
const messages = defineMessages({
add_expiration: { id: 'status.expiration.add', defaultMessage: 'Set status expiration' },
@ -23,7 +22,6 @@ class ExpirationDropdownMenu extends PureComponent {
static propTypes = {
style: PropTypes.object,
items: PropTypes.array.isRequired,
value: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
@ -108,13 +106,13 @@ class ExpirationDropdownMenu extends PureComponent {
};
render () {
const { style, items, value } = this.props;
const { style, items } = 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='expiration-dropdown__option__content'>
<div role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option')} aria-selected={false} ref={null}>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
</div>
</div>
@ -131,7 +129,6 @@ class ExpirationDropdown extends PureComponent {
isUserTouching: PropTypes.func,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
noDirect: PropTypes.bool,
container: PropTypes.func,
@ -145,21 +142,11 @@ class ExpirationDropdown extends PureComponent {
};
handleToggle = () => {
if (this.props.isUserTouching && this.props.isUserTouching()) {
if (this.state.open) {
this.props.onModalClose();
} else {
this.props.onModalOpen({
actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
onClick: this.handleModalActionClick,
});
}
} else {
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: !this.state.open });
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: !this.state.open });
};
handleModalActionClick = (e) => {
@ -230,36 +217,30 @@ class ExpirationDropdown extends PureComponent {
};
render () {
const { value, container, disabled, intl } = this.props;
const { container, disabled, intl } = this.props;
const { open, placement } = this.state;
return (
<div className={classNames('expiration-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}>
<div className={classNames('expiration-dropdown__value')} ref={this.setTargetRef}>
<IconButton
className='expiration-dropdown__value-icon'
icon='clock-o'
iconComponent={TimerIcon}
title={intl.formatMessage(messages.add_expiration)}
size={18}
expanded={open}
active={open}
inverted
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
style={{ height: null, lineHeight: '27px' }}
disabled={disabled}
/>
</div>
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
<button
type='button'
title={intl.formatMessage(messages.add_expiration)}
aria-expanded={open}
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
disabled={disabled}
className={classNames('dropdown-button', { active: open })}
>
<Icon id='clock-o' icon={TimerIcon} />
</button>
<Overlay show={open} placement={'bottom'} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
<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 expiration-dropdown__dropdown ${placement}`}>
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
<ExpirationDropdownMenu
items={this.options}
value={value}
onClose={this.handleClose}
onChange={this.handleChange}
/>

View file

@ -9,10 +9,11 @@ import { supportsPassiveEvents } from 'detect-passive-events';
import fuzzysort from 'fuzzysort';
import Overlay from 'react-overlays/Overlay';
import CancelIcon from 'mastodon/../material-icons/400-24px/cancel-fill.svg?react';
import SearchIcon from 'mastodon/../material-icons/400-24px/search.svg?react';
import TranslateIcon from 'mastodon/../material-icons/400-24px/translate.svg?react';
import { Icon } from 'mastodon/components/icon';
import { languages as preloadedLanguages } from 'mastodon/initial_state';
import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
import TextIconButton from './text_icon_button';
const messages = defineMessages({
changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' },
@ -231,7 +232,7 @@ class LanguageDropdownMenu extends PureComponent {
<div ref={this.setRef}>
<div className='emoji-mart-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 className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
@ -297,20 +298,24 @@ class LanguageDropdown extends PureComponent {
render () {
const { value, intl, frequentlyUsedLanguages } = this.props;
const { open, placement } = this.state;
const current = preloadedLanguages.find(lang => lang[0] === value) ?? [];
return (
<div className={classNames('privacy-dropdown', placement, { active: open })}>
<div className='privacy-dropdown__value' ref={this.setTargetRef} >
<TextIconButton
className='privacy-dropdown__value-icon'
label={value && value.toUpperCase()}
title={intl.formatMessage(messages.changeLanguage)}
active={open}
onClick={this.handleToggle}
/>
</div>
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
<button
type='button'
title={intl.formatMessage(messages.changeLanguage)}
aria-expanded={open}
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
className={classNames('dropdown-button', { active: open })}
>
<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 }) => (
<div {...props}>
<div className={`dropdown-animation language-dropdown__dropdown ${placement}`} >

View file

@ -1,50 +1,36 @@
import PropTypes from 'prop-types';
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { useIntl, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import CloseIcon from 'mastodon/../material-icons/400-24px/close.svg?react';
import { cancelReplyCompose } from 'mastodon/actions/compose';
import Account from 'mastodon/components/account';
import { IconButton } from 'mastodon/components/icon_button';
import { me } from 'mastodon/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 = {
account: ImmutablePropTypes.record.isRequired,
onLogout: PropTypes.func.isRequired,
onClose: PropTypes.func,
};
export const NavigationBar = () => {
const dispatch = useDispatch();
const intl = useIntl();
const account = useSelector(state => state.getIn(['accounts', me]));
const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to']));
render () {
const username = this.props.account.get('acct');
return (
<div className='navigation-bar'>
<Link to={`/@${username}`}>
<span style={{ display: 'none' }}>{username}</span>
<Avatar account={this.props.account} size={46} />
</Link>
const handleCancelClick = useCallback(() => {
dispatch(cancelReplyCompose());
}, [dispatch]);
<div className='navigation-bar__profile'>
<span>
<Link to={`/@${username}`}>
<strong className='navigation-bar__profile-account'>@{username}</strong>
</Link>
</span>
<span>
<a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
</span>
</div>
<div className='navigation-bar__actions'>
<ActionBar account={this.props.account} onLogout={this.props.onLogout} />
</div>
</div>
);
}
}
return (
<div className='navigation-bar'>
<Account account={account} minimal />
{isReplying ? <IconButton title={intl.formatMessage(messages.cancel)} iconComponent={CloseIcon} onClick={handleCancelClick} /> : <ActionBar />}
</div>
);
};

View file

@ -3,11 +3,10 @@ import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
import BarChart4BarsIcon from '@/material-icons/400-24px/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' },
@ -22,7 +21,6 @@ class PollButton extends PureComponent {
static propTypes = {
disabled: PropTypes.bool,
unavailable: PropTypes.bool,
active: PropTypes.bool,
onClick: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
@ -33,17 +31,13 @@ class PollButton extends PureComponent {
};
render () {
const { intl, active, unavailable, disabled } = this.props;
if (unavailable) {
return null;
}
const { intl, active, disabled } = this.props;
return (
<div className='compose-form__poll-button'>
<IconButton
icon='tasks'
iconComponent={InsertChartIcon}
iconComponent={BarChart4BarsIcon}
title={intl.formatMessage(active ? messages.remove_poll : messages.add_poll)}
disabled={disabled}
onClick={this.handleClick}

View file

@ -1,189 +1,162 @@
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 ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { useDispatch, useSelector } from 'react-redux';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import {
changePollSettings,
changePollOption,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
} from 'mastodon/actions/compose';
import AutosuggestInput from 'mastodon/components/autosuggest_input';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
const messages = defineMessages({
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' },
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' },
remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' },
poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' },
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Option {number}' },
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add option' },
remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this option' },
duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll length' },
type: { id: 'compose_form.poll.type', defaultMessage: 'Style' },
switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' },
switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' },
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
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 = {
title: PropTypes.string.isRequired,
lang: PropTypes.string,
index: PropTypes.number.isRequired,
isPollMultiple: PropTypes.bool,
autoFocus: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
onToggleMultiple: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
<select className='compose-form__poll__select__value' value={value} onChange={onChange}>
{options.map((option, i) => (
<option key={i} value={option.value}>{option.label}</option>
))}
</select>
</label>
);
};
handleOptionTitleChange = e => {
this.props.onChange(this.props.index, e.target.value);
};
Select.propTypes = {
label: PropTypes.node,
value: PropTypes.any,
onChange: PropTypes.func,
options: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.node,
value: PropTypes.any,
})),
};
handleOptionRemove = () => {
this.props.onRemove(this.props.index);
};
const Option = ({ multipleChoice, index, title, autoFocus }) => {
const intl = useIntl();
const dispatch = useDispatch();
const suggestions = useSelector(state => state.getIn(['compose', 'suggestions']));
const lang = useSelector(state => state.getIn(['compose', 'language']));
const handleChange = useCallback(({ target: { value } }) => {
dispatch(changePollOption(index, value));
}, [dispatch, index]);
handleToggleMultiple = e => {
this.props.onToggleMultiple();
e.preventDefault();
e.stopPropagation();
};
const handleSuggestionsFetchRequested = useCallback(token => {
dispatch(fetchComposeSuggestions(token));
}, [dispatch]);
handleCheckboxKeypress = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleToggleMultiple(e);
}
};
const handleSuggestionsClearRequested = useCallback(() => {
dispatch(clearComposeSuggestions());
}, [dispatch]);
onSuggestionsClearRequested = () => {
this.props.onClearSuggestions();
};
const handleSuggestionSelected = useCallback((tokenStart, token, value) => {
dispatch(selectComposeSuggestion(tokenStart, token, value, ['poll', 'options', index]));
}, [dispatch, index]);
onSuggestionsFetchRequested = (token) => {
this.props.onFetchSuggestions(token);
};
return (
<label className={classNames('poll__option editable', { empty: index > 1 && title.length === 0 })}>
<span className={classNames('poll__input', { checkbox: multipleChoice })} />
onSuggestionSelected = (tokenStart, token, value) => {
this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]);
};
<AutosuggestInput
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
maxLength={50}
value={title}
lang={lang}
spellCheck
onChange={handleChange}
suggestions={suggestions}
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
onSuggestionsClearRequested={handleSuggestionsClearRequested}
onSuggestionSelected={handleSuggestionSelected}
searchTokens={[':']}
autoFocus={autoFocus}
/>
</label>
);
};
render () {
const { isPollMultiple, title, lang, index, autoFocus, intl } = this.props;
Option.propTypes = {
title: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
multipleChoice: PropTypes.bool,
autoFocus: PropTypes.bool,
};
return (
<li>
<label className='poll__option editable'>
<span
className={classNames('poll__input', { checkbox: isPollMultiple })}
onClick={this.handleToggleMultiple}
onKeyPress={this.handleCheckboxKeypress}
role='button'
tabIndex={0}
title={intl.formatMessage(isPollMultiple ? messages.switchToSingle : messages.switchToMultiple)}
aria-label={intl.formatMessage(isPollMultiple ? messages.switchToSingle : messages.switchToMultiple)}
/>
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');
<AutosuggestInput
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
maxLength={50}
value={title}
lang={lang}
spellCheck
onChange={this.handleOptionTitleChange}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
searchTokens={[':']}
autoFocus={autoFocus}
/>
</label>
const handleDurationChange = useCallback(({ target: { value } }) => {
dispatch(changePollSettings(value, isMultiple));
}, [dispatch, isMultiple]);
<div className='poll__cancel'>
<IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' iconComponent={CloseIcon} onClick={this.handleOptionRemove} />
</div>
</li>
);
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 = {
options: ImmutablePropTypes.list,
lang: PropTypes.string,
expiresIn: PropTypes.number,
isMultiple: PropTypes.bool,
onChangeOption: PropTypes.func.isRequired,
onAddOption: PropTypes.func.isRequired,
onRemoveOption: PropTypes.func.isRequired,
onChangeSettings: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleAddOption = () => {
this.props.onAddOption('');
};
handleSelectDuration = e => {
this.props.onChangeSettings(e.target.value, this.props.isMultiple);
};
handleToggleMultiple = () => {
this.props.onChangeSettings(this.props.expiresIn, !this.props.isMultiple);
};
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} onToggleMultiple={this.handleToggleMultiple} autoFocus={i === autoFocusIndex} {...other} />)}
</ul>
<div className='poll__footer'>
<button type='button' disabled={options.size >= 8} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' icon={AddIcon} /> <FormattedMessage {...messages.add_option} /></button>
{/* 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>
<Select label={intl.formatMessage(messages.type)} options={[
{ value: false, label: intl.formatMessage(messages.singleChoice) },
{ value: true, label: intl.formatMessage(messages.multipleChoice) },
]} value={isMultiple} onChange={handleTypeChange} />
</div>
);
}
}
export default injectIntl(PollForm);
</div>
);
};

View file

@ -5,7 +5,6 @@ import { injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay';
@ -13,36 +12,38 @@ import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import PublicUnlistedIcon from '@/material-icons/400-24px/cloud.svg?react';
import MutualIcon from '@/material-icons/400-24px/compare_arrows.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import LoginIcon from '@/material-icons/400-24px/key.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import LockOpenIcon from '@/material-icons/400-24px/no_encryption.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import LimitedIcon from '@/material-icons/400-24px/shield.svg?react';
import { Icon } from 'mastodon/components/icon';
import { enableLoginPrivacy, enableLocalPrivacy } from 'mastodon/initial_state';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Fewer algorithmic fanfares' },
public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' },
public_unlisted_long: { id: 'privacy.public_unlisted.long', defaultMessage: 'Visible for all without GTL' },
login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' },
login_long: { id: 'privacy.login.long', defaultMessage: 'Login user only' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual' },
mutual_long: { id: 'privacy.mutual.long', defaultMessage: 'Mutual follows only' },
circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle' },
circle_long: { id: 'privacy.circle.long', defaultMessage: 'Circle members only' },
reply_short: { id: 'privacy.reply.short', defaultMessage: 'Reply' },
reply_long: { id: 'privacy.reply.long', defaultMessage: 'Reply to limited post' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Everyone mentioned in the post' },
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;
@ -151,6 +152,12 @@ class PrivacyDropdownMenu extends PureComponent {
<strong>{item.text}</strong>
{item.meta}
</div>
{item.extra && (
<div className='privacy-dropdown__option__additional' title={item.extra}>
<Icon id='info-circle' icon={item.extraIcomComponent ?? InfoIcon} />
</div>
)}
</div>
))}
</div>
@ -181,30 +188,11 @@ class PrivacyDropdown extends PureComponent {
};
handleToggle = () => {
if (this.props.isUserTouching && this.props.isUserTouching()) {
if (this.state.open) {
this.props.onModalClose();
} else {
this.props.onModalOpen({
actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
onClick: this.handleModalActionClick,
});
}
} else {
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: !this.state.open });
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
};
handleModalActionClick = (e) => {
e.preventDefault();
const { value } = this.options[e.currentTarget.getAttribute('data-index')];
this.props.onModalClose();
this.props.onChange(value);
this.setState({ open: !this.state.open });
};
handleKeyDown = e => {
@ -248,10 +236,10 @@ class PrivacyDropdown extends PureComponent {
{ icon: 'globe', iconComponent: PublicIcon, value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
{ icon: 'cloud', iconComponent: PublicUnlistedIcon, value: 'public_unlisted', text: formatMessage(messages.public_unlisted_short), meta: formatMessage(messages.public_unlisted_long) },
{ icon: 'key', iconComponent: LoginIcon, value: 'login', text: formatMessage(messages.login_short), meta: formatMessage(messages.login_long) },
{ icon: 'unlock', iconComponent: LockOpenIcon, value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_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) },
{ icon: 'exchange', iconComponent: MutualIcon, value: 'mutual', text: formatMessage(messages.mutual_short), meta: formatMessage(messages.mutual_long) },
{ icon: 'user-circle', iconComponent: CircleIcon, value: 'circle', text: formatMessage(messages.circle_short), meta: formatMessage(messages.circle_long) },
{ icon: 'exchange', iconComponent: MutualIcon, value: 'mutual', text: formatMessage(messages.mutual_short), meta: formatMessage(messages.mutual_long), extra: formatMessage(messages.limited_short), extraIcomComponent: LimitedIcon },
{ icon: 'user-circle', iconComponent: CircleIcon, value: 'circle', text: formatMessage(messages.circle_short), meta: formatMessage(messages.circle_long), extra: formatMessage(messages.limited_short), extraIcomComponent: LimitedIcon },
];
if (!this.props.noDirect) {
@ -294,7 +282,7 @@ class PrivacyDropdown extends PureComponent {
if (replyToLimited) {
if (!this.selectableOptions.some((op) => op.value === 'reply')) {
this.selectableOptions.unshift(
{ icon: 'reply', iconComponent: ReplyIcon, value: 'reply', text: intl.formatMessage(messages.reply_short), meta: intl.formatMessage(messages.reply_long) },
{ icon: 'reply', iconComponent: ReplyIcon, value: 'reply', text: intl.formatMessage(messages.reply_short), meta: intl.formatMessage(messages.reply_long), extra: intl.formatMessage(messages.limited_short), extraIcomComponent: LimitedIcon },
);
}
} else {
@ -307,23 +295,21 @@ class PrivacyDropdown extends PureComponent {
return (
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
<IconButton
className='privacy-dropdown__value-icon'
icon={valueOption.icon}
iconComponent={valueOption.iconComponent}
<button
type='button'
title={intl.formatMessage(messages.change_privacy)}
size={18}
expanded={open}
active={open}
inverted
aria-expanded={open}
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
style={{ height: null, lineHeight: '27px' }}
disabled={disabled}
/>
className={classNames('dropdown-button', { active: open })}
>
<Icon id={valueOption.icon} icon={valueOption.iconComponent} />
<span className='dropdown-button__label'>{valueOption.text}</span>
</button>
<Overlay show={open} placement={placement} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
<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}`}>

View file

@ -1,74 +1,48 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { useSelector } from 'react-redux';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import AttachmentList from 'mastodon/components/attachment_list';
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router';
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { Avatar } from 'mastodon/components/avatar';
import { DisplayName } from 'mastodon/components/display_name';
import { Icon } from 'mastodon/components/icon';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { IconButton } from '../../../components/icon_button';
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')]));
const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
});
if (!status) {
return null;
}
class ReplyIndicator extends ImmutablePureComponent {
const content = { __html: status.get('contentHtml') };
static propTypes = {
status: ImmutablePropTypes.map,
onCancel: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
...WithOptionalRouterPropTypes,
};
return (
<div className='reply-indicator'>
<div className='reply-indicator__line' />
handleClick = () => {
this.props.onCancel();
};
<Link to={`/@${account.get('acct')}`} className='detailed-status__display-avatar'>
<Avatar account={account} size={46} />
</Link>
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', 'acct'])}`} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
<DisplayName account={status.get('account')} />
</a>
</div>
<div className='reply-indicator__main'>
<Link to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
<DisplayName account={account} />
</Link>
<div className='reply-indicator__content translate' dangerouslySetInnerHTML={content} />
{status.get('media_attachments').size > 0 && (
<AttachmentList
compact
media={status.get('media_attachments')}
/>
{(status.get('poll') || status.get('media_attachments').size > 0) && (
<div className='reply-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>
);
}
}
export default withOptionalRouter(injectIntl(ReplyIndicator));
</div>
);
};

View file

@ -13,12 +13,9 @@ import PublicUnlistedIcon from '@/material-icons/400-24px/cloud.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import LockOpenIcon from '@/material-icons/400-24px/no_encryption.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import { Icon } from 'mastodon/components/icon';
import { enableLocalPrivacy } from 'mastodon/initial_state';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
public_short: { id: 'searchability.public.short', defaultMessage: 'Public' },
@ -168,30 +165,11 @@ class SearchabilityDropdown extends PureComponent {
};
handleToggle = () => {
if (this.props.isUserTouching && this.props.isUserTouching()) {
if (this.state.open) {
this.props.onModalClose();
} else {
this.props.onModalOpen({
actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
onClick: this.handleModalActionClick,
});
}
} else {
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: !this.state.open });
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
};
handleModalActionClick = (e) => {
e.preventDefault();
const { value } = this.options[e.currentTarget.getAttribute('data-index')];
this.props.onModalClose();
this.props.onChange(value);
this.setState({ open: !this.state.open });
};
handleKeyDown = e => {
@ -263,31 +241,22 @@ class SearchabilityDropdown extends PureComponent {
const valueOption = this.options.find(item => item.value === value);
return (
<div className={classNames('privacy-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}>
<div className={classNames('privacy-dropdown__value', 'searchability', { active: this.options.indexOf(valueOption) === (placement === 'bottom' ? 0 : (this.options.length - 1)) })} ref={this.setTargetRef}>
<IconButton
className='privacy-dropdown__value-icon'
icon={valueOption.icon}
iconComponent={valueOption.iconComponent}
title={intl.formatMessage(messages.change_searchability)}
size={18}
expanded={open}
active={open}
inverted
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
style={{ height: null, lineHeight: '27px' }}
disabled={disabled}
/>
<Icon
className='searchability-dropdown__value-overlay'
id='search'
icon={SearchIcon}
/>
</div>
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
<button
type='button'
title={intl.formatMessage(messages.change_searchability)}
aria-expanded={open}
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
disabled={disabled}
className={classNames('dropdown-button', { active: open })}
>
<Icon id={valueOption.icon} icon={valueOption.iconComponent} />
<span className='dropdown-button__label'>{valueOption.text}</span>
</button>
<Overlay show={open} placement={'bottom'} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
<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}`}>

View file

@ -2,6 +2,8 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -9,7 +11,8 @@ import spring from 'react-motion/lib/spring';
import CloseIcon from '@/material-icons/400-24px/close.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 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
import Motion from '../../ui/util/optional_motion';
@ -18,6 +21,7 @@ export default class Upload extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
sensitive: PropTypes.bool,
onUndo: PropTypes.func.isRequired,
onOpenFocalPoint: PropTypes.func.isRequired,
};
@ -33,7 +37,7 @@ export default class Upload extends ImmutablePureComponent {
};
render () {
const { media } = this.props;
const { media, sensitive } = this.props;
if (!media) {
return null;
@ -43,22 +47,26 @@ export default class Upload extends ImmutablePureComponent {
const focusY = media.getIn(['meta', 'focus', 'y']);
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
const missingDescription = (media.get('description') || '').length === 0;
return (
<div className='compose-form__upload'>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ 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'>
<button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></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 icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
</div>
{(media.get('description') || '').length === 0 && (
<div className='compose-form__upload__warning'>
<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 className='compose-form__upload__warning'>
<button type='button' className={classNames('icon-button', { active: missingDescription })} onClick={this.handleFocalPointClick}>{missingDescription && <Icon icon={WarningIcon} />} ALT</button>
</div>
</div>
)}
</Motion>

View file

@ -6,9 +6,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import AddPhotoAlternateIcon from '@/material-icons/400-24px/add_photo_alternate.svg?react';
import { IconButton } from '../../../components/icon_button';
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
const messages = defineMessages({
upload: { id: 'upload_button.label', defaultMessage: 'Add images, a video or an audio file' },
@ -31,7 +30,6 @@ class UploadButton extends ImmutablePureComponent {
static propTypes = {
disabled: PropTypes.bool,
unavailable: PropTypes.bool,
onSelectFile: PropTypes.func.isRequired,
style: PropTypes.object,
resetFileKey: PropTypes.number,
@ -54,17 +52,13 @@ class UploadButton extends ImmutablePureComponent {
};
render () {
const { intl, resetFileKey, unavailable, disabled, acceptContentTypes } = this.props;
if (unavailable) {
return null;
}
const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
const message = intl.formatMessage(messages.upload);
return (
<div className='compose-form__upload-button'>
<IconButton icon='paperclip' iconComponent={AddPhotoAlternateIcon} title={message} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
<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

View file

@ -1,7 +1,6 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import SensitiveButtonContainer from '../containers/sensitive_button_container';
import UploadContainer from '../containers/upload_container';
import UploadProgressContainer from '../containers/upload_progress_container';
@ -15,17 +14,17 @@ export default class UploadForm extends ImmutablePureComponent {
const { mediaIds } = this.props;
return (
<div className='compose-form__upload-wrapper'>
<>
<UploadProgressContainer />
<div className='compose-form__uploads-wrapper'>
{mediaIds.map(id => (
<UploadContainer id={id} key={id} />
))}
</div>
{!mediaIds.isEmpty() && <SensitiveButtonContainer />}
</div>
{mediaIds.size > 0 && (
<div className='compose-form__uploads'>
{mediaIds.map(id => (
<UploadContainer id={id} key={id} />
))}
</div>
)}
</>
);
}

View file

@ -35,9 +35,7 @@ export default class UploadProgress extends PureComponent {
return (
<div className='upload-progress'>
<div className='upload-progress__icon'>
<Icon id='upload' icon={UploadFileIcon} />
</div>
<Icon id='upload' icon={UploadFileIcon} />
<div className='upload-progress__message'>
{message}

View file

@ -1,10 +1,13 @@
import { connect } from 'react-redux';
import { changeCircle } from '../../../actions/compose';
import CircleSelect from '../components/circle_select';
import { openModal, closeModal } from '../../../actions/modal';
import { isUserTouching } from '../../../is_mobile';
import CircleDropdown from '../components/circle_dropdown';
const mapStateToProps = state => ({
unavailable: state.getIn(['compose', 'privacy']) !== 'circle' || !!state.getIn(['compose', 'id']),
value: state.getIn(['compose', 'searchability']),
circles: state.get('circles'),
circleId: state.getIn(['compose', 'circle_id']),
});
@ -15,6 +18,16 @@ const mapDispatchToProps = dispatch => ({
dispatch(changeCircle(circleId));
},
isUserTouching,
onModalOpen: props => dispatch(openModal({
modalType: 'ACTIONS',
modalProps: props,
})),
onModalClose: () => dispatch(closeModal({
modalType: undefined,
ignoreFocus: false,
})),
});
export default connect(mapStateToProps, mapDispatchToProps)(CircleSelect);
export default connect(mapStateToProps, mapDispatchToProps)(CircleDropdown);

View file

@ -4,8 +4,7 @@ import { openModal, closeModal } from '../../../actions/modal';
import { isUserTouching } from '../../../is_mobile';
import ExpirationDropdown from '../components/expiration_dropdown';
const mapStateToProps = state => ({
value: state.getIn(['compose', 'privacy']),
const mapStateToProps = () => ({
});
const mapDispatchToProps = (dispatch, { onPickExpiration }) => ({

View file

@ -2,8 +2,10 @@ import { injectIntl, defineMessages } from 'react-intl';
import { connect } from 'react-redux';
import MarkdownIcon from '@/material-icons/400-24px/markdown.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
import { changeComposeMarkdown } from '../../../actions/compose';
import TextIconButton from '../components/text_icon_button';
const messages = defineMessages({
marked: { id: 'compose_form.markdown.marked', defaultMessage: 'Markdown is enabled' },
@ -11,10 +13,12 @@ const messages = defineMessages({
});
const mapStateToProps = (state, { intl }) => ({
label: 'MD',
iconComponent: MarkdownIcon,
title: intl.formatMessage(state.getIn(['compose', 'markdown']) ? messages.marked : messages.unmarked),
active: state.getIn(['compose', 'markdown']),
ariaControls: 'cw-markdown-input',
size: 18,
inverted: true,
});
const mapDispatchToProps = dispatch => ({
@ -25,4 +29,4 @@ const mapDispatchToProps = dispatch => ({
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton));
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(IconButton));

View file

@ -1,36 +0,0 @@
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from 'mastodon/actions/modal';
import { logOut } from 'mastodon/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));

View file

@ -4,7 +4,7 @@ import { addPoll, removePoll } from '../../../actions/compose';
import PollButton from '../components/poll_button';
const mapStateToProps = state => ({
unavailable: false,
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 0),
active: state.getIn(['compose', 'poll']) !== null,
});

View file

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

View file

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

View file

@ -1,73 +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 'mastodon/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 => ({
active: state.getIn(['compose', 'sensitive']),
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));

View file

@ -2,8 +2,10 @@ import { injectIntl, defineMessages } from 'react-intl';
import { connect } from 'react-redux';
import WarningIcon from 'mastodon/../material-icons/400-24px/warning.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
import { changeComposeSpoilerness } from '../../../actions/compose';
import TextIconButton from '../components/text_icon_button';
const messages = defineMessages({
marked: { id: 'compose_form.spoiler.marked', defaultMessage: 'Text is hidden behind warning' },
@ -11,10 +13,12 @@ const messages = defineMessages({
});
const mapStateToProps = (state, { intl }) => ({
label: 'CW',
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 => ({
@ -25,4 +29,4 @@ const mapDispatchToProps = dispatch => ({
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton));
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(IconButton));

View file

@ -4,8 +4,7 @@ import { uploadCompose } from '../../../actions/compose';
import UploadButton from '../components/upload_button';
const mapStateToProps = state => ({
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size + state.getIn(['compose', 'pending_media_attachments']) >= 4 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))),
unavailable: false,
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']),
});

View file

@ -5,6 +5,7 @@ import Upload from '../components/upload';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
sensitive: state.getIn(['compose', 'spoiler']),
});
const mapDispatchToProps = dispatch => ({

View file

@ -30,7 +30,6 @@ import { isMobile } from '../../is_mobile';
import Motion from '../ui/util/optional_motion';
import ComposeFormContainer from './containers/compose_form_container';
import NavigationContainer from './containers/navigation_container';
import SearchContainer from './containers/search_container';
import SearchResultsContainer from './containers/search_results_container';
@ -129,8 +128,6 @@ class Compose extends PureComponent {
<div className='drawer__pager'>
<div className='drawer__inner' onFocus={this.onFocus}>
<NavigationContainer onClose={this.onBlur} />
<ComposeFormContainer autoFocus={!isMobile(window.innerWidth)} />
<div className='drawer__inner__mastodon'>
@ -152,7 +149,6 @@ class Compose extends PureComponent {
return (
<Column onFocus={this.onFocus}>
<NavigationContainer onClose={this.onBlur} />
<ComposeFormContainer />
<Helmet>

View file

@ -1,17 +1,24 @@
import PropTypes from 'prop-types';
import { useCallback } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link, withRouter } from 'react-router-dom';
import { Link, useHistory } from 'react-router-dom';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { useDispatch, useSelector } from 'react-redux';
import { HotKeys } from 'react-hotkeys';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import { replyCompose } from 'mastodon/actions/compose';
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
import { openModal } from 'mastodon/actions/modal';
import { muteStatus, unmuteStatus, revealStatus, hideStatus } from 'mastodon/actions/statuses';
import AttachmentList from 'mastodon/components/attachment_list';
import AvatarComposite from 'mastodon/components/avatar_composite';
import { IconButton } from 'mastodon/components/icon_button';
@ -19,7 +26,7 @@ import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import StatusContent from 'mastodon/components/status_content';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { autoPlayGif } from 'mastodon/initial_state';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { makeGetStatus } from 'mastodon/selectors';
const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' },
@ -29,25 +36,31 @@ const messages = defineMessages({
delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
});
class Conversation extends ImmutablePureComponent {
const getAccounts = createSelector(
(state) => state.get('accounts'),
(_, accountIds) => accountIds,
(accounts, accountIds) =>
accountIds.map(id => accounts.get(id))
);
static propTypes = {
conversationId: PropTypes.string.isRequired,
accounts: ImmutablePropTypes.list.isRequired,
lastStatus: ImmutablePropTypes.map,
unread:PropTypes.bool.isRequired,
scrollKey: PropTypes.string,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
markRead: PropTypes.func.isRequired,
delete: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
...WithRouterPropTypes,
};
const getStatus = makeGetStatus();
handleMouseEnter = ({ currentTarget }) => {
export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) => {
const id = conversation.get('id');
const unread = conversation.get('unread');
const lastStatusId = conversation.get('last_status');
const accountIds = conversation.get('accounts');
const intl = useIntl();
const dispatch = useDispatch();
const history = useHistory();
const lastStatus = useSelector(state => getStatus(state, { id: lastStatusId }));
const accounts = useSelector(state => getAccounts(state, accountIds));
const handleMouseEnter = useCallback(({ currentTarget }) => {
if (autoPlayGif) {
return;
}
@ -58,9 +71,9 @@ class Conversation extends ImmutablePureComponent {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-original');
}
};
}, []);
handleMouseLeave = ({ currentTarget }) => {
const handleMouseLeave = useCallback(({ currentTarget }) => {
if (autoPlayGif) {
return;
}
@ -71,136 +84,161 @@ class Conversation extends ImmutablePureComponent {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-static');
}
};
handleClick = () => {
if (!this.props.history) {
return;
}
const { lastStatus, unread, markRead } = this.props;
}, []);
const handleClick = useCallback(() => {
if (unread) {
markRead();
dispatch(markConversationRead(id));
}
this.props.history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
};
history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
}, [dispatch, history, unread, id, lastStatus]);
handleMarkAsRead = () => {
this.props.markRead();
};
const handleMarkAsRead = useCallback(() => {
dispatch(markConversationRead(id));
}, [dispatch, id]);
handleReply = () => {
this.props.reply(this.props.lastStatus, this.props.history);
};
const handleReply = useCallback(() => {
dispatch((_, getState) => {
let state = getState();
handleDelete = () => {
this.props.delete();
};
if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(replyCompose(lastStatus, history)),
},
}));
} else {
dispatch(replyCompose(lastStatus, history));
}
});
}, [dispatch, lastStatus, history, intl]);
handleHotkeyMoveUp = () => {
this.props.onMoveUp(this.props.conversationId);
};
const handleDelete = useCallback(() => {
dispatch(deleteConversation(id));
}, [dispatch, id]);
handleHotkeyMoveDown = () => {
this.props.onMoveDown(this.props.conversationId);
};
const handleHotkeyMoveUp = useCallback(() => {
onMoveUp(id);
}, [id, onMoveUp]);
handleConversationMute = () => {
this.props.onMute(this.props.lastStatus);
};
const handleHotkeyMoveDown = useCallback(() => {
onMoveDown(id);
}, [id, onMoveDown]);
handleShowMore = () => {
this.props.onToggleHidden(this.props.lastStatus);
};
render () {
const { accounts, lastStatus, unread, scrollKey, intl } = this.props;
if (lastStatus === null) {
return null;
const handleConversationMute = useCallback(() => {
if (lastStatus.get('muted')) {
dispatch(unmuteStatus(lastStatus.get('id')));
} else {
dispatch(muteStatus(lastStatus.get('id')));
}
}, [dispatch, lastStatus]);
const menu = [
{ text: intl.formatMessage(messages.open), action: this.handleClick },
null,
];
menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute });
if (unread) {
menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead });
menu.push(null);
const handleShowMore = useCallback(() => {
if (lastStatus.get('hidden')) {
dispatch(revealStatus(lastStatus.get('id')));
} else {
dispatch(hideStatus(lastStatus.get('id')));
}
}, [dispatch, lastStatus]);
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
if (!lastStatus) {
return null;
}
const names = accounts.map(a => <Link to={`/@${a.get('acct')}`} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Link>).reduce((prev, cur) => [prev, ', ', cur]);
const menu = [
{ text: intl.formatMessage(messages.open), action: handleClick },
null,
{ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: handleConversationMute },
];
const handlers = {
reply: this.handleReply,
open: this.handleClick,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleHidden: this.handleShowMore,
};
if (unread) {
menu.push({ text: intl.formatMessage(messages.markAsRead), action: handleMarkAsRead });
menu.push(null);
}
return (
<HotKeys handlers={handlers}>
<div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}>
<div className='conversation__avatar' onClick={this.handleClick} role='presentation'>
<AvatarComposite accounts={accounts} size={48} />
</div>
menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
<div className='conversation__content'>
<div className='conversation__content__info'>
<div className='conversation__content__relative-time'>
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
</div>
const names = accounts.map(a => (
<Link to={`/@${a.get('acct')}`} key={a.get('id')} title={a.get('acct')}>
<bdi>
<strong
className='display-name__html'
dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
/>
</bdi>
</Link>
)).reduce((prev, cur) => [prev, ', ', cur]);
<div className='conversation__content__names' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
</div>
const handlers = {
reply: handleReply,
open: handleClick,
moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown,
toggleHidden: handleShowMore,
};
return (
<HotKeys handlers={handlers}>
<div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}>
<div className='conversation__avatar' onClick={handleClick} role='presentation'>
<AvatarComposite accounts={accounts} size={48} />
</div>
<div className='conversation__content'>
<div className='conversation__content__info'>
<div className='conversation__content__relative-time'>
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
</div>
<StatusContent
status={lastStatus}
onClick={this.handleClick}
expanded={!lastStatus.get('hidden')}
onExpandedToggle={this.handleShowMore}
collapsible
<div className='conversation__content__names' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
</div>
</div>
<StatusContent
status={lastStatus}
onClick={handleClick}
expanded={!lastStatus.get('hidden')}
onExpandedToggle={handleShowMore}
collapsible
/>
{lastStatus.get('media_attachments').size > 0 && (
<AttachmentList
compact
media={lastStatus.get('media_attachments')}
/>
)}
{lastStatus.get('media_attachments').size > 0 && (
<AttachmentList
compact
media={lastStatus.get('media_attachments')}
<div className='status__action-bar'>
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} />
<div className='status__action-bar-dropdown'>
<DropdownMenuContainer
scrollKey={scrollKey}
status={lastStatus}
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
size={18}
direction='right'
title={intl.formatMessage(messages.more)}
/>
)}
<div className='status__action-bar'>
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={this.handleReply} />
<div className='status__action-bar-dropdown'>
<DropdownMenuContainer
scrollKey={scrollKey}
status={lastStatus}
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
size={18}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
</div>
</div>
</div>
</HotKeys>
);
}
</div>
</HotKeys>
);
};
}
export default withRouter(injectIntl(Conversation));
Conversation.propTypes = {
conversation: ImmutablePropTypes.map.isRequired,
scrollKey: PropTypes.string,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
};

View file

@ -1,77 +1,72 @@
import PropTypes from 'prop-types';
import { useRef, useMemo, useCallback } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { useSelector, useDispatch } from 'react-redux';
import { debounce } from 'lodash';
import ScrollableList from '../../../components/scrollable_list';
import ConversationContainer from '../containers/conversation_container';
import { expandConversations } from 'mastodon/actions/conversations';
import ScrollableList from 'mastodon/components/scrollable_list';
export default class ConversationsList extends ImmutablePureComponent {
import { Conversation } from './conversation';
static propTypes = {
conversations: ImmutablePropTypes.list.isRequired,
scrollKey: PropTypes.string.isRequired,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
onLoadMore: PropTypes.func,
};
const focusChild = (node, index, alignTop) => {
const element = node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id);
handleMoveUp = id => {
const elementIndex = this.getCurrentIndex(id) - 1;
this._selectChild(elementIndex, true);
};
handleMoveDown = id => {
const elementIndex = this.getCurrentIndex(id) + 1;
this._selectChild(elementIndex, false);
};
_selectChild (index, align_top) {
const container = this.node.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (align_top && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
if (element) {
if (alignTop && node.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
};
setRef = c => {
this.node = c;
};
export const ConversationsList = ({ scrollKey, ...other }) => {
const listRef = useRef();
const conversations = useSelector(state => state.getIn(['conversations', 'items']));
const isLoading = useSelector(state => state.getIn(['conversations', 'isLoading'], true));
const hasMore = useSelector(state => state.getIn(['conversations', 'hasMore'], false));
const dispatch = useDispatch();
const lastStatusId = conversations.last()?.get('last_status');
handleLoadOlder = debounce(() => {
const last = this.props.conversations.last();
const handleMoveUp = useCallback(id => {
const elementIndex = conversations.findIndex(x => x.get('id') === id) - 1;
focusChild(listRef.current.node, elementIndex, true);
}, [listRef, conversations]);
if (last && last.get('last_status')) {
this.props.onLoadMore(last.get('last_status'));
const handleMoveDown = useCallback(id => {
const elementIndex = conversations.findIndex(x => x.get('id') === id) + 1;
focusChild(listRef.current.node, elementIndex, false);
}, [listRef, conversations]);
const debouncedLoadMore = useMemo(() => debounce(id => {
dispatch(expandConversations({ maxId: id }));
}, 300, { leading: true }), [dispatch]);
const handleLoadMore = useCallback(() => {
if (lastStatusId) {
debouncedLoadMore(lastStatusId);
}
}, 300, { leading: true });
}, [debouncedLoadMore, lastStatusId]);
render () {
const { conversations, isLoading, onLoadMore, ...other } = this.props;
return (
<ScrollableList {...other} scrollKey={scrollKey} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} hasMore={hasMore} onLoadMore={handleLoadMore} ref={listRef}>
{conversations.map(item => (
<Conversation
key={item.get('id')}
conversation={item}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
scrollKey={scrollKey}
/>
))}
</ScrollableList>
);
};
return (
<ScrollableList {...other} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
{conversations.map(item => (
<ConversationContainer
key={item.get('id')}
conversationId={item.get('id')}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
scrollKey={this.props.scrollKey}
/>
))}
</ScrollableList>
);
}
}
ConversationsList.propTypes = {
scrollKey: PropTypes.string.isRequired,
};

View file

@ -1,80 +0,0 @@
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { replyCompose } from 'mastodon/actions/compose';
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
import { openModal } from 'mastodon/actions/modal';
import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'mastodon/actions/statuses';
import { makeGetStatus } from 'mastodon/selectors';
import Conversation from '../components/conversation';
const messages = defineMessages({
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
});
const mapStateToProps = () => {
const getStatus = makeGetStatus();
return (state, { conversationId }) => {
const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
const lastStatusId = conversation.get('last_status', null);
return {
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
unread: conversation.get('unread'),
lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }),
};
};
};
const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({
markRead () {
dispatch(markConversationRead(conversationId));
},
reply (status, router) {
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(replyCompose(status, router)),
},
}));
} else {
dispatch(replyCompose(status, router));
}
});
},
delete () {
dispatch(deleteConversation(conversationId));
},
onMute (status) {
if (status.get('muted')) {
dispatch(unmuteStatus(status.get('id')));
} else {
dispatch(muteStatus(status.get('id')));
}
},
onToggleHidden (status) {
if (status.get('hidden')) {
dispatch(revealStatus(status.get('id')));
} else {
dispatch(hideStatus(status.get('id')));
}
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Conversation));

View file

@ -1,16 +0,0 @@
import { connect } from 'react-redux';
import { expandConversations } from '../../../actions/conversations';
import ConversationsList from '../components/conversations_list';
const mapStateToProps = state => ({
conversations: state.getIn(['conversations', 'items']),
isLoading: state.getIn(['conversations', 'isLoading'], true),
hasMore: state.getIn(['conversations', 'hasMore'], false),
});
const mapDispatchToProps = dispatch => ({
onLoadMore: maxId => dispatch(expandConversations({ maxId })),
});
export default connect(mapStateToProps, mapDispatchToProps)(ConversationsList);

View file

@ -1,11 +1,11 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { useRef, useCallback, useEffect } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { connect } from 'react-redux';
import { useDispatch } from 'react-redux';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
@ -14,103 +14,79 @@ import { connectDirectStream } from 'mastodon/actions/streaming';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import ConversationsListContainer from './containers/conversations_list_container';
import { ConversationsList } from './components/conversations_list';
const messages = defineMessages({
title: { id: 'column.direct', defaultMessage: 'Private mentions' },
});
class DirectTimeline extends PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
const DirectTimeline = ({ columnId, multiColumn }) => {
const columnRef = useRef();
const intl = useIntl();
const dispatch = useDispatch();
const pinned = !!columnId;
const handlePin = useCallback(() => {
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('DIRECT', {}));
}
};
}, [dispatch, columnId]);
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
const handleMove = useCallback((dir) => {
dispatch(moveColumn(columnId, dir));
};
}, [dispatch, columnId]);
handleHeaderClick = () => {
this.column.scrollTop();
};
componentDidMount () {
const { dispatch } = this.props;
const handleHeaderClick = useCallback(() => {
columnRef.current.scrollTop();
}, [columnRef]);
useEffect(() => {
dispatch(mountConversations());
dispatch(expandConversations());
this.disconnect = dispatch(connectDirectStream());
}
componentWillUnmount () {
this.props.dispatch(unmountConversations());
const disconnect = dispatch(connectDirectStream());
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
return () => {
dispatch(unmountConversations());
disconnect();
};
}, [dispatch]);
setRef = c => {
this.column = c;
};
return (
<Column bindToDocument={!multiColumn} ref={columnRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='at'
iconComponent={AlternateEmailIcon}
title={intl.formatMessage(messages.title)}
onPin={handlePin}
onMove={handleMove}
onClick={handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
handleLoadMore = maxId => {
this.props.dispatch(expandConversations({ maxId }));
};
<ConversationsList
trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`}
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />}
bindToDocument={!multiColumn}
prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>}
alwaysPrepend
/>
render () {
const { intl, hasUnread, columnId, multiColumn } = this.props;
const pinned = !!columnId;
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='at'
iconComponent={AlternateEmailIcon}
active={hasUnread}
title={intl.formatMessage(messages.title)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
DirectTimeline.propTypes = {
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
};
<ConversationsListContainer
trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`}
timelineId='direct'
bindToDocument={!multiColumn}
onLoadMore={this.handleLoadMore}
prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>}
alwaysPrepend
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />}
/>
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect()(injectIntl(DirectTimeline));
export default DirectTimeline;

View file

@ -28,7 +28,7 @@ import ColumnHeader from 'mastodon/components/column_header';
import LinkFooter from 'mastodon/features/ui/components/link_footer';
import { me, showTrends } from '../../initial_state';
import NavigationContainer from '../compose/containers/navigation_container';
import { NavigationBar } from '../compose/components/navigation_bar';
import ColumnLink from '../ui/components/column_link';
import ColumnSubheading from '../ui/components/column_subheading';
@ -149,7 +149,7 @@ class GettingStarted extends ImmutablePureComponent {
return (
<Column>
{(signedIn && !multiColumn) ? <NavigationContainer /> : <ColumnHeader title={intl.formatMessage(messages.menu)} icon='bars' iconComponent={MenuIcon} multiColumn={multiColumn} />}
{(signedIn && !multiColumn) ? <NavigationBar /> : <ColumnHeader title={intl.formatMessage(messages.menu)} icon='bars' iconComponent={MenuIcon} multiColumn={multiColumn} />}
<div className='getting-started scrollable scrollable--flex'>
<div className='getting-started__wrapper'>

View file

@ -1,21 +1,15 @@
import { PureComponent } from 'react';
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
import LoadingBarContainer from 'mastodon/features/ui/containers/loading_bar_container';
import ModalContainer from 'mastodon/features/ui/containers/modal_container';
import NotificationsContainer from 'mastodon/features/ui/containers/notifications_container';
import ComposeFormContainer from '../../compose/containers/compose_form_container';
import LoadingBarContainer from '../../ui/containers/loading_bar_container';
import ModalContainer from '../../ui/containers/modal_container';
import NotificationsContainer from '../../ui/containers/notifications_container';
const Compose = () => (
<>
<ComposeFormContainer autoFocus withoutNavigation />
<NotificationsContainer />
<ModalContainer />
<LoadingBarContainer className='loading-bar' />
</>
);
export default class Compose extends PureComponent {
render () {
return (
<div>
<ComposeFormContainer autoFocus />
<NotificationsContainer />
<ModalContainer />
<LoadingBarContainer className='loading-bar' />
</div>
);
}
}
export default Compose;

View file

@ -6,7 +6,6 @@ import { connect } from 'react-redux';
import { changeComposing, mountCompose, unmountCompose } from 'mastodon/actions/compose';
import ServerBanner from 'mastodon/components/server_banner';
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
import NavigationContainer from 'mastodon/features/compose/containers/navigation_container';
import SearchContainer from 'mastodon/features/compose/containers/search_container';
import LinkFooter from './link_footer';
@ -56,10 +55,7 @@ class ComposePanel extends PureComponent {
)}
{signedIn && (
<>
<NavigationContainer onClose={this.onBlur} />
<ComposeFormContainer singleColumn />
</>
<ComposeFormContainer singleColumn />
)}
<LinkFooter />

View file

@ -21,7 +21,7 @@ import { Button } from 'mastodon/components/button';
import { GIFV } from 'mastodon/components/gifv';
import { IconButton } from 'mastodon/components/icon_button';
import Audio from 'mastodon/features/audio';
import CharacterCounter from 'mastodon/features/compose/components/character_counter';
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
import UploadProgress from 'mastodon/features/compose/components/upload_progress';
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
import { me } from 'mastodon/initial_state';

View file

@ -108,7 +108,6 @@ class MuteModal extends PureComponent {
<div>
<span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span>
{/* eslint-disable-next-line jsx-a11y/no-onchange */}
<select value={muteDuration} onChange={this.changeMuteDuration}>
<option value={0}>{intl.formatMessage(messages.indefinite)}</option>
<option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>

View file

@ -94,7 +94,6 @@ class NavigationPanel extends Component {
<div className='navigation-panel'>
<div className='navigation-panel__logo'>
<Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link>
{!banner && <hr />}
</div>
{banner &&

View file

@ -3,6 +3,7 @@
"about.contact": "Kontak:",
"about.disclaimer": "Mastodon is gratis oopbronsagteware en n handelsmerk van Mastodon gGmbH.",
"about.domain_blocks.no_reason_available": "Rede nie beskikbaar nie",
"about.domain_blocks.preamble": "Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.",
"about.domain_blocks.silenced.title": "Beperk",
"about.domain_blocks.suspended.title": "Opgeskort",
"about.not_available": "Hierdie inligting is nie op hierdie bediener beskikbaar gestel nie.",

View file

@ -521,7 +521,7 @@
"poll.total_people": "{count, plural, one {# persona} other {# persones}}",
"poll.total_votes": "{count, plural, one {# vot} other {# vots}}",
"poll.vote": "Vota",
"poll.voted": "Vas votar per aquesta resposta",
"poll.voted": "Vau votar aquesta resposta",
"poll.votes": "{votes, plural, one {# vot} other {# vots}}",
"poll_button.add_poll": "Afegeix una enquesta",
"poll_button.remove_poll": "Elimina l'enquesta",

View file

@ -96,7 +96,6 @@
"announcement.announcement": "Announcement",
"attachments_list.unprocessed": "(unprocessed)",
"audio.hide": "Hide audio",
"autosuggest_hashtag.per_week": "{count} per week",
"boost_modal.combo": "You can press {combo} to skip this next time",
"bundle_column_error.copy_stacktrace": "Copy error report",
"bundle_column_error.error.body": "The requested page could not be rendered. It could be due to a bug in our code, or a browser compatibility issue.",
@ -161,23 +160,23 @@
"compose_form.markdown.unmarked": "Markdown is NOT available",
"compose_form.mention_warning": "When you add a mention to a limited post, the person you are mentioning can also see this post.",
"compose_form.placeholder": "What's on your mind?",
"compose_form.searchability_warning": "Self only searchability is not available other mastodon servers. Others can search your post.",
"compose_form.poll.add_option": "Add a choice",
"compose_form.poll.add_option": "Add option",
"compose_form.poll.duration": "Poll duration",
"compose_form.poll.option_placeholder": "Choice {number}",
"compose_form.poll.remove_option": "Remove this choice",
"compose_form.poll.multiple": "Multiple choice",
"compose_form.poll.option_placeholder": "Option {number}",
"compose_form.poll.remove_option": "Remove this option",
"compose_form.poll.single": "Pick one",
"compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
"compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
"compose_form.publish": "Publish",
"compose_form.poll.type": "Style",
"compose_form.publish": "Post",
"compose_form.publish_form": "New post",
"compose_form.publish_loud": "{publish}!",
"compose_form.save_changes": "Save changes",
"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}}",
"compose_form.reply": "Reply",
"compose_form.save_changes": "Update",
"compose_form.searchability_warning": "Self only searchability is not available other mastodon servers. Others can search your post.",
"compose_form.spoiler.marked": "Remove content warning",
"compose_form.spoiler.unmarked": "Add content warning",
"compose_form.spoiler_placeholder": "Write your warning here",
"compose_form.spoiler_placeholder": "Content warning (optional)",
"confirmation_modal.cancel": "Cancel",
"confirmations.block.block_and_report": "Block & Report",
"confirmations.block.confirm": "Block",
@ -428,7 +427,6 @@
"navigation_bar.direct": "Private mentions",
"navigation_bar.discover": "Discover",
"navigation_bar.domain_blocks": "Blocked domains",
"navigation_bar.edit_profile": "Edit profile",
"navigation_bar.explore": "Explore",
"navigation_bar.favourites": "Favorites",
"navigation_bar.filters": "Muted words",
@ -551,22 +549,23 @@
"poll_button.add_poll": "Add a poll",
"poll_button.remove_poll": "Remove poll",
"privacy.change": "Change post privacy",
"privacy.direct.long": "Visible for mentioned users only",
"privacy.direct.short": "Mentioned people only",
"privacy.direct.long": "Everyone mentioned in the post",
"privacy.direct.short": "Specific people",
"privacy.limited.short": "Limited",
"privacy.login.long": "Visible for login users only",
"privacy.login.short": "Login only",
"privacy.mutual.long": "Mutual followers only",
"privacy.mutual.short": "Mutual only",
"privacy.personal.short": "Yourself only",
"privacy.private.long": "Visible for followers only",
"privacy.private.short": "Followers only",
"privacy.public.long": "Visible for all",
"privacy.private.long": "Only your followers",
"privacy.private.short": "Followers",
"privacy.public.long": "Anyone on and off Mastodon",
"privacy.public.short": "Public",
"privacy.public_unlisted.long": "Visible for all without GTL",
"privacy.public_unlisted.short": "Public unlisted",
"privacy.unlisted.long": "Visible for all, but opted-out of discovery features",
"privacy.unlisted.short": "Unlisted",
"privacy.unlisted.additional": "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.",
"privacy.unlisted.long": "Fewer algorithmic fanfares",
"privacy.unlisted.short": "Quiet public",
"privacy_policy.last_updated": "Last updated {date}",
"privacy_policy.title": "Privacy Policy",
"reaction_deck.add": "Add",
@ -586,7 +585,9 @@
"relative_time.minutes": "{number}m",
"relative_time.seconds": "{number}s",
"relative_time.today": "today",
"reply_indicator.attachments": "{count, plural, one {# attachment} other {# attachments}}",
"reply_indicator.cancel": "Cancel",
"reply_indicator.poll": "Poll",
"report.block": "Block",
"report.block_explanation": "You will not see their posts. They will not be able to see your posts or follow you. They will be able to tell that they are blocked.",
"report.categories.legal": "Legal",
@ -768,10 +769,8 @@
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people who are deaf or hard of hearing",
"upload_form.description": "Describe for people who are blind or have low vision",
"upload_form.description_missing": "No description added",
"upload_form.edit": "Edit",
"upload_form.thumbnail": "Change thumbnail",
"upload_form.undo": "Delete",
"upload_form.video_description": "Describe for people who are deaf, hard of hearing, blind or have low vision",
"upload_modal.analyzing_picture": "Analyzing picture…",
"upload_modal.apply": "Apply",

View file

@ -216,7 +216,7 @@
"compose_form.direct_message_warning_learn_more": "もっと詳しく",
"compose_form.encryption_warning": "Mastodonの投稿はエンドツーエンド暗号化に対応していません。安全に送受信されるべき情報をMastodonで共有しないでください。",
"compose_form.hashtag_warning": "この投稿は公開設定ではないのでハッシュタグの一覧に表示されません。公開投稿だけがハッシュタグで検索できます。",
"compose_form.limited_post_warning": "限定投稿は現状、ごく一部のMastodonサーバーにしか届きません2023年9月時点でFedibird、kmyblueなど一部のみです",
"compose_form.limited_post_warning": "限定投稿は、v4.3.0以降のMastodon、またはkmyblue・Fedibirdを含む一部のMastodonにしか届きません2024年1月時点",
"compose_form.lock_disclaimer": "あなたのアカウントは{locked}になっていません。誰でもあなたをフォローすることができ、フォロワー限定の投稿を見ることができます。",
"compose_form.lock_disclaimer.lock": "承認制",
"compose_form.markdown.marked": "Markdown有効",
@ -634,15 +634,15 @@
"poll_button.add_poll": "アンケートを追加",
"poll_button.remove_poll": "アンケートを削除",
"privacy.change": "公開範囲を変更",
"privacy.circle.long": "サークルメンバーのみ",
"privacy.circle.short": "サークル",
"privacy.circle.long": "サークルメンバーのみ閲覧可",
"privacy.circle.short": "サークル (投稿時点)",
"privacy.direct.long": "指定された相手のみ閲覧可",
"privacy.direct.short": "指定された相手のみ",
"privacy.limited.short": "限定投稿",
"privacy.login.long": "ログインユーザーのみ閲覧可、公開",
"privacy.login.short": "ログインユーザーのみ",
"privacy.mutual.long": "相互フォローさんのみ閲覧可、限定投稿",
"privacy.mutual.short": "相互のみ",
"privacy.mutual.long": "相互フォローのみ閲覧可",
"privacy.mutual.short": "相互 (投稿時点)",
"privacy.personal.short": "自分限定",
"privacy.private.long": "フォロワーのみ閲覧可",
"privacy.private.short": "フォロワーのみ",
@ -650,6 +650,8 @@
"privacy.public.short": "公開",
"privacy.public_unlisted.long": "誰でも閲覧可、ホームローカルTL",
"privacy.public_unlisted.short": "ローカル公開",
"privacy.reply.long": "元投稿と同じメンバーが閲覧可",
"privacy.reply.short": "限定投稿への返信",
"privacy.unlisted.long": "誰でも閲覧可、ホームTL",
"privacy.unlisted.short": "非収載",
"privacy_policy.last_updated": "{date}に更新",

View file

@ -18,6 +18,7 @@
"account.blocked": "Blocat",
"account.browse_more_on_origin_server": "Navigar sul perfil original",
"account.cancel_follow_request": "Retirar la demanda dabonament",
"account.copy": "Copiar lo ligam del perfil",
"account.direct": "Mencionar @{name} en privat",
"account.disable_notifications": "Quitar de mavisar quand @{name} publica quicòm",
"account.domain_blocked": "Domeni amagat",
@ -28,6 +29,7 @@
"account.featured_tags.last_status_never": "Cap de publicacion",
"account.featured_tags.title": "Etiquetas en avant de {name}",
"account.follow": "Sègre",
"account.follow_back": "Sègre en retorn",
"account.followers": "Seguidors",
"account.followers.empty": "Degun sèc pas aqueste utilizaire pel moment.",
"account.followers_counter": "{count, plural, one {{counter} Seguidor} other {{counter} Seguidors}}",
@ -48,6 +50,7 @@
"account.mute_notifications_short": "Amudir las notificacions",
"account.mute_short": "Amudir",
"account.muted": "Mes en silenci",
"account.mutual": "Mutual",
"account.no_bio": "Cap de descripcion pas fornida.",
"account.open_original_page": "Dobrir la pagina dorigina",
"account.posts": "Tuts",
@ -172,6 +175,7 @@
"conversation.mark_as_read": "Marcar coma legida",
"conversation.open": "Veire la conversacion",
"conversation.with": "Amb {names}",
"copy_icon_button.copied": "Copiat al quichapapièr",
"copypaste.copied": "Copiat",
"copypaste.copy_to_clipboard": "Copiar al quichapapièr",
"directory.federated": "Del fediverse conegut",
@ -294,6 +298,8 @@
"keyboard_shortcuts.direct": "to open direct messages column",
"keyboard_shortcuts.down": "far davalar dins la lista",
"keyboard_shortcuts.enter": "dobrir los estatuts",
"keyboard_shortcuts.favourite": "Marcar coma favorit",
"keyboard_shortcuts.favourites": "Dobrir la lista dels favorits",
"keyboard_shortcuts.federated": "dobrir lo flux public global",
"keyboard_shortcuts.heading": "Acorchis clavièr",
"keyboard_shortcuts.home": "dobrir lo flux public local",
@ -339,6 +345,7 @@
"lists.search": "Cercar demest lo mond que seguètz",
"lists.subheading": "Vòstras listas",
"load_pending": "{count, plural, one {# nòu element} other {# nòu elements}}",
"loading_indicator.label": "Cargament…",
"media_gallery.toggle_visible": "Modificar la visibilitat",
"mute_modal.duration": "Durada",
"mute_modal.hide_notifications": "Rescondre las notificacions daquesta persona?",
@ -371,6 +378,7 @@
"not_signed_in_indicator.not_signed_in": "Devètz vos connectar per accedir a aquesta ressorsa.",
"notification.admin.report": "{name} senhalèt {target}",
"notification.admin.sign_up": "{name} se marquèt",
"notification.favourite": "{name} a mes vòstre estatut en favorit",
"notification.follow": "{name} vos sèc",
"notification.follow_request": "{name} a demandat a vos sègre",
"notification.mention": "{name} vos a mencionat",
@ -423,6 +431,8 @@
"onboarding.compose.template": "Adiu #Mastodon !",
"onboarding.follows.lead": "You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!",
"onboarding.follows.title": "Popular on Mastodon",
"onboarding.profile.display_name": "Nom dafichatge",
"onboarding.profile.note": "Biografia",
"onboarding.share.title": "Partejar vòstre perfil",
"onboarding.start.lead": "Your new Mastodon account is ready to go. Here's how you can make the most of it:",
"onboarding.start.skip": "Want to skip right ahead?",
@ -504,6 +514,7 @@
"report_notification.categories.spam": "Messatge indesirable",
"report_notification.categories.violation": "Violacion de las règlas",
"report_notification.open": "Dobrir lo senhalament",
"search.no_recent_searches": "Cap de recèrcas recentas",
"search.placeholder": "Recercar",
"search.search_or_paste": "Recercar o picar una URL",
"search_popout.language_code": "Còdi ISO de lenga",
@ -536,6 +547,7 @@
"status.copy": "Copiar lo ligam de lestatut",
"status.delete": "Escafar",
"status.detailed_status": "Vista detalhada de la convèrsa",
"status.direct": "Mencionar @{name} en privat",
"status.direct_indicator": "Mencion privada",
"status.edit": "Modificar",
"status.edited": "Modificat {date}",
@ -626,6 +638,7 @@
"upload_modal.preview_label": "Apercebut ({ratio})",
"upload_progress.label": "Mandadís…",
"upload_progress.processing": "Tractament…",
"username.taken": "Aqueste nom dutilizaire es pres. Ensajatz-ne un autre",
"video.close": "Tampar la vidèo",
"video.download": "Telecargar lo fichièr",
"video.exit_fullscreen": "Sortir plen ecran",

View file

@ -48,7 +48,7 @@
"account.locked_info": "此帳號的隱私狀態設定為鎖定。該擁有者會手動審核能跟隨此帳號的人。",
"account.media": "媒體",
"account.mention": "提及 @{name}",
"account.moved_to": "{name} 現在的新帳號為:",
"account.moved_to": "{name} 目前的新帳號為:",
"account.mute": "靜音 @{name}",
"account.mute_notifications_short": "靜音推播通知",
"account.mute_short": "靜音",
@ -59,7 +59,7 @@
"account.posts": "嘟文",
"account.posts_with_replies": "嘟文與回覆",
"account.report": "檢舉 @{name}",
"account.requested": "正在等待核准。按一下以取消跟隨請求",
"account.requested": "正在等候審核。按一下以取消跟隨請求",
"account.requested_follow": "{name} 要求跟隨您",
"account.share": "分享 @{name} 的個人檔案",
"account.show_reblogs": "顯示來自 @{name} 的嘟文",
@ -84,7 +84,7 @@
"admin.impact_report.title": "影響總結",
"alert.rate_limited.message": "請於 {retry_time, time, medium} 後重試。",
"alert.rate_limited.title": "已限速",
"alert.unexpected.message": "發生非預期的錯誤。",
"alert.unexpected.message": "發生非預期的錯誤。",
"alert.unexpected.title": "哎呀!",
"announcement.announcement": "公告",
"attachments_list.unprocessed": "(未經處理)",
@ -241,7 +241,7 @@
"empty_column.followed_tags": "您還沒有跟隨任何主題標籤。當您跟隨主題標籤時,它們將於此顯示。",
"empty_column.hashtag": "這個主題標籤下什麼也沒有。",
"empty_column.home": "您的首頁時間軸是空的!跟隨更多人來將它填滿吧!",
"empty_column.list": "這份列表下什麼也沒有。當此列表的成員嘟出新的嘟文時,它們將顯示於此。",
"empty_column.list": "這份列表下什麼也沒有。當此列表的成員嘟出新的嘟文時,它們將顯示於此。",
"empty_column.lists": "您還沒有建立任何列表。當您建立列表時,它將於此顯示。",
"empty_column.mutes": "您尚未靜音任何使用者。",
"empty_column.notifications": "您還沒有收到任何通知,當您與別人開始互動時,它將於此顯示。",
@ -303,8 +303,8 @@
"hashtag.counter_by_accounts": "{count, plural, one {{counter} 名} other {{counter} 名}}參與者",
"hashtag.counter_by_uses": "{count, plural, one {{counter} 則} other {{counter} 則}}嘟文",
"hashtag.counter_by_uses_today": "本日有 {count, plural, one {{counter} 則} other {{counter} 則}}嘟文",
"hashtag.follow": "追蹤主題標籤",
"hashtag.unfollow": "取消追蹤主題標籤",
"hashtag.follow": "跟隨主題標籤",
"hashtag.unfollow": "取消跟隨主題標籤",
"hashtags.and_other": "…及其他 {count, plural, other {# 個}}",
"home.actions.go_to_explore": "看看發生什麼新鮮事",
"home.actions.go_to_suggestions": "尋找一些人來跟隨",

View file

@ -43,9 +43,7 @@ import {
COMPOSE_RESET,
COMPOSE_POLL_ADD,
COMPOSE_POLL_REMOVE,
COMPOSE_POLL_OPTION_ADD,
COMPOSE_POLL_OPTION_CHANGE,
COMPOSE_POLL_OPTION_REMOVE,
COMPOSE_POLL_SETTINGS_CHANGE,
COMPOSE_CIRCLE_CHANGE,
INIT_MEDIA_EDIT_MODAL,
@ -365,6 +363,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 < 8) {
return tmp.push('');
}
return tmp;
});
export default function compose(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
@ -635,12 +645,8 @@ export default function compose(state = initialState, action) {
return state.set('poll', initialPoll);
case COMPOSE_POLL_REMOVE:
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:
return state.setIn(['poll', 'options', action.index], action.title);
case COMPOSE_POLL_OPTION_REMOVE:
return state.updateIn(['poll', 'options'], options => options.delete(action.index));
return updatePoll(state, action.index, action.title);
case COMPOSE_POLL_SETTINGS_CHANGE:
return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple));
case COMPOSE_CIRCLE_CHANGE:

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M80-120v-80h800v80H80Zm40-120v-280h120v280H120Zm200 0v-480h120v480H320Zm200 0v-360h120v360H520Zm200 0v-600h120v600H720Z"/></svg>

After

Width:  |  Height:  |  Size: 225 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M80-120v-80h800v80H80Zm40-120v-280h120v280H120Zm200 0v-480h120v480H320Zm200 0v-360h120v360H520Zm200 0v-600h120v600H720Z"/></svg>

After

Width:  |  Height:  |  Size: 225 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m640-360 120-120-42-43-48 48v-125h-60v125l-48-48-42 43 120 120ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm60-200h60v-180h40v120h60v-120h40v180h60v-200q0-17-11.5-28.5T440-600H260q-17 0-28.5 11.5T220-560v200Z"/></svg>

After

Width:  |  Height:  |  Size: 388 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m640-360 120-120-42-43-48 48v-125h-60v125l-48-48-42 43 120 120ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm0-80h640v-480H160v480Zm0 0v-480 480Zm60-120h60v-180h40v120h60v-120h40v180h60v-200q0-17-11.5-28.5T440-600H260q-17 0-28.5 11.5T220-560v200Z"/></svg>

After

Width:  |  Height:  |  Size: 425 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M360-400h400L622-580l-92 120-62-80-108 140Zm-40 160q-33 0-56.5-23.5T240-320v-480q0-33 23.5-56.5T320-880h480q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H320ZM160-80q-33 0-56.5-23.5T80-160v-560h80v560h560v80H160Z"/></svg>

After

Width:  |  Height:  |  Size: 322 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M360-400h400L622-580l-92 120-62-80-108 140Zm-40 160q-33 0-56.5-23.5T240-320v-480q0-33 23.5-56.5T320-880h480q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H320Zm0-80h480v-480H320v480ZM160-80q-33 0-56.5-23.5T80-160v-560h80v560h560v80H160Zm160-720v480-480Z"/></svg>

After

Width:  |  Height:  |  Size: 362 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M524-40q-84 0-157.5-32t-128-86.5Q184-213 152-286.5T120-444q0-146 93-257.5T450-840q-18 99 11 193.5T561-481q71 71 165.5 100T920-370q-26 144-138 237T524-40Z"/></svg>

After

Width:  |  Height:  |  Size: 259 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M524-40q-84 0-157.5-32t-128-86.5Q184-213 152-286.5T120-444q0-146 93-257.5T450-840q-18 99 11 193.5T561-481q71 71 165.5 100T920-370q-26 144-138 237T524-40Zm0-80q88 0 163-44t118-121q-86-8-163-43.5T504-425q-61-61-97-138t-43-163q-77 43-120.5 118.5T200-444q0 135 94.5 229.5T524-120Zm-20-305Z"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m476-80 182-480h84L924-80h-84l-43-122H603L560-80h-84ZM160-200l-56-56 202-202q-35-35-63.5-80T190-640h84q20 39 40 68t48 58q33-33 68.5-92.5T484-720H40v-80h280v-80h80v80h280v80H564q-21 72-63 148t-83 116l96 98-30 82-122-125-202 201Zm468-72h144l-72-204-72 204Z"/></svg>

After

Width:  |  Height:  |  Size: 360 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m476-80 182-480h84L924-80h-84l-43-122H603L560-80h-84ZM160-200l-56-56 202-202q-35-35-63.5-80T190-640h84q20 39 40 68t48 58q33-33 68.5-92.5T484-720H40v-80h280v-80h80v80h280v80H564q-21 72-63 148t-83 116l96 98-30 82-122-125-202 201Zm468-72h144l-72-204-72 204Z"/></svg>

After

Width:  |  Height:  |  Size: 360 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m40-120 440-760 440 760H40Zm440-120q17 0 28.5-11.5T520-280q0-17-11.5-28.5T480-320q-17 0-28.5 11.5T440-280q0 17 11.5 28.5T480-240Zm-40-120h80v-200h-80v200Z"/></svg>

After

Width:  |  Height:  |  Size: 260 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m40-120 440-760 440 760H40Zm138-80h604L480-720 178-200Zm302-40q17 0 28.5-11.5T520-280q0-17-11.5-28.5T480-320q-17 0-28.5 11.5T440-280q0 17 11.5 28.5T480-240Zm-40-120h80v-200h-80v200Zm40-100Z"/></svg>

After

Width:  |  Height:  |  Size: 295 B

View file

@ -13,10 +13,12 @@ function loaded() {
if (mountNode) {
const attr = mountNode.getAttribute('data-props');
if(!attr) return;
if (!attr) return;
const props = JSON.parse(attr);
const root = createRoot(mountNode);
root.render(<ComposeContainer {...props} />);
}
}

View file

@ -1,20 +1,7 @@
.compose-form {
.compose-form__modifiers {
.compose-form__upload {
&-description {
input {
&::placeholder {
opacity: 1;
}
}
}
}
}
}
.status__content a,
.link-footer a,
.reply-indicator__content a,
.edit-indicator__content a,
.link-footer a,
.status__content__read-more-button,
.status__content__translate-button {
text-decoration: underline;
@ -42,7 +29,9 @@
}
}
.status__content a {
.status__content a,
.reply-indicator__content a,
.edit-indicator__content a {
color: $highlight-text-color;
}
@ -50,24 +39,10 @@
color: $darker-text-color;
}
.compose-form__poll-wrapper .button.button-secondary,
.compose-form .autosuggest-textarea__textarea::placeholder,
.compose-form .spoiler-input__input::placeholder,
.report-dialog-modal__textarea::placeholder,
.language-dropdown__dropdown__results__item__common-name,
.compose-form .icon-button {
.report-dialog-modal__textarea::placeholder {
color: $inverted-text-color;
}
.text-icon-button.active {
color: $ui-highlight-color;
}
.language-dropdown__dropdown__results__item.active {
background: $ui-highlight-color;
font-weight: 500;
}
.link-button:disabled {
cursor: not-allowed;

View file

@ -33,9 +33,6 @@ textarea {
}
.compose-form .compose-form__warning,
.reply-indicator__content,
.reply-indicator__display-name,
.reply-indicator__cancel,
.autosuggest-textarea__suggestions__item {
color: $ui-base-color;
}

View file

@ -145,10 +145,6 @@ html {
}
}
.compose-form__autosuggest-wrapper,
.poll__option input[type='text'],
.compose-form .spoiler-input__input,
.compose-form__poll-wrapper select,
.search__input,
.setting-text,
.report-dialog-modal__textarea,
@ -172,28 +168,11 @@ html {
border-bottom: 0;
}
.compose-form__poll-wrapper select {
background: $simple-background-color
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>")
no-repeat right 8px center / auto 16px;
}
.compose-form__poll-wrapper,
.compose-form__poll-wrapper .poll__footer {
border-top-color: lighten($ui-base-color, 8%);
}
.notification__filter-bar {
border: 1px solid lighten($ui-base-color, 8%);
border-top: 0;
}
.compose-form .compose-form__buttons-wrapper {
background: $ui-base-color;
border: 1px solid lighten($ui-base-color, 8%);
border-top: 0;
}
.drawer__header,
.drawer__inner {
background: $white;
@ -206,52 +185,6 @@ html {
no-repeat bottom / 100% auto;
}
// Change the colors used in compose-form
.compose-form {
.compose-form__modifiers {
.compose-form__upload__actions .icon-button,
.compose-form__upload__warning .icon-button {
color: lighten($white, 7%);
&:active,
&:focus,
&:hover {
color: $white;
}
}
}
.compose-form__buttons-wrapper {
background: darken($ui-base-color, 6%);
}
.autosuggest-textarea__suggestions {
background: darken($ui-base-color, 6%);
}
.autosuggest-textarea__suggestions__item {
&:hover,
&:focus,
&:active,
&.selected {
background: lighten($ui-base-color, 4%);
}
}
}
.emoji-mart-bar {
border-color: lighten($ui-base-color, 4%);
&:first-child {
background: darken($ui-base-color, 6%);
}
}
.emoji-mart-search input {
background: rgba($ui-base-color, 0.3);
border-color: $ui-base-color;
}
.upload-progress__backdrop {
background: $ui-base-color;
}
@ -283,55 +216,11 @@ html {
background: $ui-base-color;
}
.privacy-dropdown.active .privacy-dropdown__value.active .icon-button,
.expiration-dropdown.active .expiration-dropdown__value.active .icon-button {
color: $white;
}
.account-gallery__item a {
background-color: $ui-base-color;
}
// Change the colors used in the dropdown menu
.dropdown-menu {
background: $white;
&__arrow::before {
background-color: $white;
}
&__item {
color: $darker-text-color;
&--dangerous {
color: $error-value-color;
}
a,
button {
background: $white;
}
}
}
// Change the text colors on inverted background
.privacy-dropdown__option.active,
.privacy-dropdown__option:hover,
.privacy-dropdown__option.active .privacy-dropdown__option__content,
.privacy-dropdown__option.active .privacy-dropdown__option__content strong,
.privacy-dropdown__option:hover .privacy-dropdown__option__content,
.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,
.expiration-dropdown__option.active,
.expiration-dropdown__option:hover,
.expiration-dropdown__option.active .expiration-dropdown__option__content,
.expiration-dropdown__option.active
.expiration-dropdown__option__content
strong,
.expiration-dropdown__option:hover .expiration-dropdown__option__content,
.expiration-dropdown__option:hover .expiration-dropdown__option__content strong,
.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:active,
.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:focus,
.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:hover,
.actions-modal ul li:not(:empty) a.active,
.actions-modal ul li:not(:empty) a.active button,
.actions-modal ul li:not(:empty) a:active,
@ -340,7 +229,6 @@ html {
.actions-modal ul li:not(:empty) a:focus button,
.actions-modal ul li:not(:empty) a:hover,
.actions-modal ul li:not(:empty) a:hover button,
.language-dropdown__dropdown__results__item.active,
.admin-wrapper .sidebar ul .simple-navigation-active-leaf a,
.simple_form .block-button,
.simple_form .button,
@ -348,19 +236,6 @@ html {
color: $white;
}
.language-dropdown__dropdown__results__item
.language-dropdown__dropdown__results__item__common-name {
color: lighten($ui-base-color, 8%);
}
.language-dropdown__dropdown__results__item.active
.language-dropdown__dropdown__results__item__common-name {
color: darken($ui-base-color, 12%);
}
.dropdown-menu__separator,
.dropdown-menu__item.edited-timestamp__history__item,
.dropdown-menu__container__header,
.compare-history-modal .report-modal__target,
.report-dialog-modal .poll__option.dialog-option {
border-bottom-color: lighten($ui-base-color, 4%);
@ -394,10 +269,7 @@ html {
.reactions-bar__item:hover,
.reactions-bar__item:focus,
.reactions-bar__item:active,
.language-dropdown__dropdown__results__item:hover,
.language-dropdown__dropdown__results__item:focus,
.language-dropdown__dropdown__results__item:active {
.reactions-bar__item:active {
background-color: $ui-base-color;
}
@ -640,11 +512,6 @@ html {
}
}
.reply-indicator {
background: transparent;
border: 1px solid lighten($ui-base-color, 8%);
}
.status__content,
.reply-indicator__content {
a {
@ -684,3 +551,30 @@ html {
background-color: rgba($ui-highlight-color, 0.15);
}
}
.compose-form__actions .icon-button.active,
.dropdown-button.active,
.privacy-dropdown__option.active,
.privacy-dropdown__option:focus,
.language-dropdown__dropdown__results__item:focus,
.language-dropdown__dropdown__results__item.active,
.privacy-dropdown__option:focus .privacy-dropdown__option__content,
.privacy-dropdown__option:focus .privacy-dropdown__option__content strong,
.privacy-dropdown__option.active .privacy-dropdown__option__content,
.privacy-dropdown__option.active .privacy-dropdown__option__content strong,
.language-dropdown__dropdown__results__item:focus
.language-dropdown__dropdown__results__item__common-name,
.language-dropdown__dropdown__results__item.active
.language-dropdown__dropdown__results__item__common-name {
color: $white;
}
.compose-form .spoiler-input__input {
color: lighten($ui-highlight-color, 8%);
}
.compose-form .autosuggest-textarea__textarea,
.compose-form__highlightable,
.poll__option input[type='text'] {
background: darken($ui-base-color, 10%);
}

View file

@ -5,7 +5,7 @@ $white: #ffffff;
$classic-base-color: #282c37;
$classic-primary-color: #9baec8;
$classic-secondary-color: #d9e1e8;
$classic-highlight-color: #858afa;
$classic-highlight-color: #6364ff;
$blurple-600: #563acc; // Iris
$blurple-500: #6364ff; // Brand purple
@ -37,7 +37,7 @@ $ui-button-tertiary-border-color: $blurple-500 !default;
$primary-text-color: $black !default;
$darker-text-color: $classic-base-color !default;
$highlight-text-color: darken($ui-highlight-color, 8%) !default;
$highlight-text-color: $ui-highlight-color !default;
$dark-text-color: #444b5d;
$action-button-color: #606984;
@ -58,3 +58,8 @@ $account-background-color: $white !default;
}
$emojis-requiring-inversion: 'chains';
.theme-mastodon-light {
--dropdown-border-color: #d9e1e8;
--dropdown-background-color: #fff;
}

View file

@ -15,13 +15,14 @@
outline: 0;
box-sizing: border-box;
width: 100%;
border: 0;
box-shadow: none;
font-family: inherit;
background: $ui-base-color;
color: $darker-text-color;
border-radius: 4px;
font-size: 14px;
border: 1px solid lighten($ui-base-color, 8%);
font-size: 17px;
line-height: normal;
margin: 0;
}

View file

@ -1321,6 +1321,9 @@ a.sparkline {
&__label {
padding: 15px;
display: flex;
gap: 8px;
align-items: center;
}
&__rules {
@ -1331,6 +1334,9 @@ a.sparkline {
&__rule {
cursor: pointer;
padding: 15px;
display: flex;
gap: 8px;
align-items: center;
}
}

View file

@ -8,7 +8,7 @@
body {
font-family: $font-sans-serif, sans-serif;
background: darken($ui-base-color, 7%);
background: darken($ui-base-color, 8%);
font-size: 13px;
line-height: 18px;
font-weight: 400;

File diff suppressed because it is too large Load diff

View file

@ -40,13 +40,12 @@
.compose-form {
width: 400px;
margin: 0 auto;
padding: 20px 0;
margin-top: 40px;
padding: 10px 0;
padding-bottom: 20px;
box-sizing: border-box;
@media screen and (width <= 400px) {
width: 100%;
margin-top: 0;
padding: 20px;
}
}
@ -56,13 +55,15 @@
width: 400px;
margin: 0 auto;
display: flex;
font-size: 13px;
line-height: 18px;
align-items: center;
gap: 10px;
font-size: 14px;
line-height: 20px;
box-sizing: border-box;
padding: 20px 0;
margin-top: 40px;
margin-bottom: 10px;
border-bottom: 1px solid $ui-base-color;
border-bottom: 1px solid lighten($ui-base-color, 8%);
@media screen and (width <= 440px) {
width: 100%;
@ -71,9 +72,9 @@
}
.avatar {
width: 40px;
height: 40px;
margin-inline-end: 10px;
width: 48px;
height: 48px;
flex: 0 0 auto;
img {
width: 100%;
@ -87,13 +88,14 @@
.name {
flex: 1 1 auto;
color: $secondary-text-color;
width: calc(100% - 90px);
.username {
display: block;
font-weight: 500;
font-size: 16px;
line-height: 24px;
text-overflow: ellipsis;
overflow: hidden;
color: $primary-text-color;
}
}
@ -101,7 +103,7 @@
display: block;
font-size: 32px;
line-height: 40px;
margin-inline-start: 10px;
flex: 0 0 auto;
}
}

View file

@ -1,7 +1,6 @@
.emoji-mart {
font-size: 13px;
display: inline-block;
color: $inverted-text-color;
&,
* {
@ -15,13 +14,13 @@
}
.emoji-mart-bar {
border: 0 solid darken($ui-secondary-color, 8%);
border: 0 solid var(--dropdown-border-color);
&:first-child {
border-bottom-width: 1px;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
background: $ui-secondary-color;
background: var(--dropdown-border-color);
}
&:last-child {
@ -36,7 +35,6 @@
display: flex;
justify-content: space-between;
padding: 0 6px;
color: $lighter-text-color;
line-height: 0;
}
@ -50,9 +48,10 @@
cursor: pointer;
background: transparent;
border: 0;
color: $darker-text-color;
&:hover {
color: darken($lighter-text-color, 4%);
color: lighten($darker-text-color, 4%);
}
}
@ -60,7 +59,7 @@
color: $highlight-text-color;
&:hover {
color: darken($highlight-text-color, 4%);
color: lighten($highlight-text-color, 4%);
}
.emoji-mart-anchor-bar {
@ -95,7 +94,7 @@
height: 270px;
max-height: 35vh;
padding: 0 6px 6px;
background: $simple-background-color;
background: var(--dropdown-background-color);
will-change: transform;
&::-webkit-scrollbar-track:hover,
@ -107,7 +106,7 @@
.emoji-mart-search {
padding: 10px;
padding-inline-end: 45px;
background: $simple-background-color;
background: var(--dropdown-background-color);
position: relative;
input {
@ -118,9 +117,9 @@
font-family: inherit;
display: block;
width: 100%;
background: rgba($ui-secondary-color, 0.3);
color: $inverted-text-color;
border: 1px solid $ui-secondary-color;
background: $ui-base-color;
color: $darker-text-color;
border: 1px solid lighten($ui-base-color, 8%);
border-radius: 4px;
&::-moz-focus-inner {
@ -155,11 +154,10 @@
&:disabled {
cursor: default;
pointer-events: none;
opacity: 0.3;
}
svg {
fill: $action-button-color;
fill: $darker-text-color;
}
}
@ -185,7 +183,7 @@
inset-inline-start: 0;
width: 100%;
height: 100%;
background-color: rgba($ui-secondary-color, 0.7);
background-color: var(--dropdown-border-color);
border-radius: 100%;
}
}
@ -202,7 +200,7 @@
width: 100%;
font-weight: 500;
padding: 5px 6px;
background: $simple-background-color;
background: var(--dropdown-background-color);
}
}
@ -246,7 +244,7 @@
.emoji-mart-no-results {
font-size: 14px;
color: $light-text-color;
color: $dark-text-color;
text-align: center;
padding: 5px 6px;
padding-top: 70px;

View file

@ -1,5 +1,5 @@
.modal-layout {
background: $ui-base-color
background: darken($ui-base-color, 4%)
url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-lighter-color)}33"/></svg>')
repeat-x bottom fixed;
display: flex;

View file

@ -52,6 +52,8 @@
&__option {
position: relative;
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 0;
line-height: 18px;
cursor: default;
@ -78,16 +80,22 @@
box-sizing: border-box;
width: 100%;
font-size: 14px;
color: $inverted-text-color;
color: $secondary-text-color;
outline: 0;
font-family: inherit;
background: $simple-background-color;
border: 1px solid darken($simple-background-color, 14%);
background: $ui-base-color;
border: 1px solid $darker-text-color;
border-radius: 4px;
padding: 6px 10px;
padding: 8px 12px;
&:focus {
border-color: $highlight-text-color;
border-color: $ui-highlight-color;
}
@media screen and (width <= 600px) {
font-size: 16px;
line-height: 24px;
letter-spacing: 0.5px;
}
}
@ -96,26 +104,20 @@
}
&.editable {
display: flex;
align-items: center;
overflow: visible;
}
}
&__input {
display: inline-block;
display: block;
position: relative;
border: 1px solid $ui-primary-color;
box-sizing: border-box;
width: 18px;
height: 18px;
margin-inline-end: 10px;
top: -1px;
width: 17px;
height: 17px;
border-radius: 50%;
vertical-align: middle;
margin-top: auto;
margin-bottom: auto;
flex: 0 0 18px;
flex: 0 0 auto;
&.checkbox {
border-radius: 4px;
@ -159,6 +161,15 @@
}
}
&__option.editable &__input {
&:active,
&:focus,
&:hover {
border-color: $ui-primary-color;
border-width: 1px;
}
}
&__number {
display: inline-block;
width: 45px;
@ -209,90 +220,6 @@
}
}
.compose-form__poll-wrapper {
border-top: 1px solid darken($simple-background-color, 8%);
ul {
padding: 10px;
}
.poll__input {
&:active,
&:focus,
&:hover {
border-color: $ui-button-focus-background-color;
}
}
.poll__footer {
border-top: 1px solid darken($simple-background-color, 8%);
padding: 10px;
display: flex;
align-items: center;
button,
select {
flex: 1 1 50%;
&:focus {
border-color: $highlight-text-color;
}
}
}
.button.button-secondary {
font-size: 14px;
font-weight: 400;
padding: 6px 10px;
height: auto;
line-height: inherit;
color: $action-button-color;
border-color: $action-button-color;
margin-inline-end: 5px;
&:hover,
&:focus,
&.active {
border-color: $action-button-color;
background-color: $action-button-color;
color: $ui-button-color;
}
}
li {
display: flex;
align-items: center;
.poll__option {
flex: 0 0 auto;
width: calc(100% - (23px + 6px));
margin-inline-end: 6px;
}
}
select {
appearance: none;
box-sizing: border-box;
font-size: 14px;
color: $inverted-text-color;
display: inline-block;
width: auto;
outline: 0;
font-family: inherit;
background: $simple-background-color
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>")
no-repeat right 8px center / auto 16px;
border: 1px solid darken($simple-background-color, 14%);
border-radius: 4px;
padding: 6px 10px;
padding-inline-end: 30px;
}
.icon-button.disabled {
color: darken($simple-background-color, 14%);
}
}
.muted .poll {
color: $dark-text-color;

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