Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Essem 2023-12-17 19:48:56 -06:00
commit bbf2dbaf56
No known key found for this signature in database
GPG key ID: 7D497397CC3A2A8C
151 changed files with 1898 additions and 1298 deletions

View file

@ -70,7 +70,7 @@ services:
hard: -1 hard: -1
libretranslate: libretranslate:
image: libretranslate/libretranslate:v1.4.0 image: libretranslate/libretranslate:v1.4.1
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- lt-data:/home/libretranslate/.local - lt-data:/home/libretranslate/.local

View file

@ -1,5 +1,5 @@
# Node.js # In test, compile the NodeJS code as if we are in production
NODE_ENV=tests NODE_ENV=production
# Federation # Federation
LOCAL_DOMAIN=cb6e6126.ngrok.io LOCAL_DOMAIN=cb6e6126.ngrok.io
LOCAL_HTTPS=true LOCAL_HTTPS=true

View file

@ -48,12 +48,15 @@ jobs:
run: |- run: |-
./bin/rails assets:precompile ./bin/rails assets:precompile
- name: Archive asset artifacts
run: |
tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs*
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
if: matrix.mode == 'test' if: matrix.mode == 'test'
with: with:
path: |- path: |-
./public/assets ./artifacts.tar.gz
./public/packs-test
name: ${{ github.sha }} name: ${{ github.sha }}
retention-days: 0 retention-days: 0
@ -102,7 +105,6 @@ jobs:
SAML_ENABLED: true SAML_ENABLED: true
CAS_ENABLED: true CAS_ENABLED: true
BUNDLE_WITH: 'pam_authentication test' BUNDLE_WITH: 'pam_authentication test'
CI_JOBS: ${{ matrix.ci_job }}/4
GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }} GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }}
strategy: strategy:
@ -112,19 +114,18 @@ jobs:
- '3.0' - '3.0'
- '3.1' - '3.1'
- '.ruby-version' - '.ruby-version'
ci_job:
- 1
- 2
- 3
- 4
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
path: './public' path: './'
name: ${{ github.sha }} name: ${{ github.sha }}
- name: Expand archived asset artifacts
run: |
tar xvzf artifacts.tar.gz
- name: Set up Ruby environment - name: Set up Ruby environment
uses: ./.github/actions/setup-ruby uses: ./.github/actions/setup-ruby
with: with:
@ -134,7 +135,7 @@ jobs:
- name: Load database schema - name: Load database schema
run: './bin/rails db:create db:schema:load db:seed' run: './bin/rails db:create db:schema:load db:seed'
- run: bundle exec rake rspec_chunked - run: bin/rspec
test-e2e: test-e2e:
name: End to End testing name: End to End testing

View file

@ -12,3 +12,5 @@ linters:
enabled: true enabled: true
MiddleDot: MiddleDot:
enabled: true enabled: true
LineLength:
max: 320

View file

@ -1,17 +1,31 @@
# This configuration was generated by # This configuration was generated by
# `haml-lint --auto-gen-config` # `haml-lint --auto-gen-config`
# on 2023-10-25 08:29:48 -0400 using Haml-Lint version 0.51.0. # on 2023-10-26 09:32:34 -0400 using Haml-Lint version 0.51.0.
# The point is for the user to remove these configuration records # The point is for the user to remove these configuration records
# one by one as the lints are removed from the code base. # one by one as the lints are removed from the code base.
# Note that changes in the inspected code, or installation of new # Note that changes in the inspected code, or installation of new
# versions of Haml-Lint, may require this file to be generated again. # versions of Haml-Lint, may require this file to be generated again.
linters: linters:
# Offense count: 945 # Offense count: 16
LineLength: LineLength:
enabled: false exclude:
- 'app/views/admin/account_actions/new.html.haml'
- 'app/views/admin/accounts/index.html.haml'
- 'app/views/admin/ip_blocks/new.html.haml'
- 'app/views/admin/roles/_form.html.haml'
- 'app/views/admin/settings/discovery/show.html.haml'
- 'app/views/auth/registrations/edit.html.haml'
- 'app/views/auth/registrations/new.html.haml'
- 'app/views/filters/_filter_fields.html.haml'
- 'app/views/media/player.html.haml'
- 'app/views/settings/applications/_fields.html.haml'
- 'app/views/settings/imports/index.html.haml'
- 'app/views/settings/preferences/appearance/show.html.haml'
- 'app/views/settings/preferences/notifications/show.html.haml'
- 'app/views/settings/preferences/other/show.html.haml'
# Offense count: 10 # Offense count: 9
RuboCop: RuboCop:
exclude: exclude:
- 'app/views/admin/accounts/_buttons.html.haml' - 'app/views/admin/accounts/_buttons.html.haml'

View file

@ -27,7 +27,7 @@ AllCops:
- 'node_modules/**/*' - 'node_modules/**/*'
- 'Vagrantfile' - 'Vagrantfile'
- 'vendor/**/*' - 'vendor/**/*'
- 'lib/json_ld/*' # Generated files - 'config/initializers/json_ld*' # Generated files
- 'lib/mastodon/migration_helpers.rb' # Vendored from GitLab - 'lib/mastodon/migration_helpers.rb' # Vendored from GitLab
- 'lib/templates/**/*' - 'lib/templates/**/*'

View file

@ -132,11 +132,6 @@ RSpec/InstanceVariable:
RSpec/LetSetup: RSpec/LetSetup:
Exclude: Exclude:
- 'spec/controllers/admin/accounts_controller_spec.rb'
- 'spec/controllers/admin/action_logs_controller_spec.rb'
- 'spec/controllers/admin/instances_controller_spec.rb'
- 'spec/controllers/admin/reports/actions_controller_spec.rb'
- 'spec/controllers/admin/statuses_controller_spec.rb'
- 'spec/controllers/api/v1/accounts/statuses_controller_spec.rb' - 'spec/controllers/api/v1/accounts/statuses_controller_spec.rb'
- 'spec/controllers/api/v1/filters_controller_spec.rb' - 'spec/controllers/api/v1/filters_controller_spec.rb'
- 'spec/controllers/api/v2/admin/accounts_controller_spec.rb' - 'spec/controllers/api/v2/admin/accounts_controller_spec.rb'

View file

@ -23,7 +23,7 @@ gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10' gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.8' gem 'addressable', '~> 2.8'
gem 'bootsnap', '~> 1.16.0', require: false gem 'bootsnap', '~> 1.17.0', require: false
gem 'browser' gem 'browser'
gem 'charlock_holmes', '~> 0.7.7' gem 'charlock_holmes', '~> 0.7.7'
gem 'chewy', '~> 7.3' gem 'chewy', '~> 7.3'
@ -88,7 +88,7 @@ gem 'simple-navigation', '~> 4.4'
gem 'simple_form', '~> 5.2' gem 'simple_form', '~> 5.2'
gem 'sprockets-rails', '~> 3.4', require: 'sprockets/railtie' gem 'sprockets-rails', '~> 3.4', require: 'sprockets/railtie'
gem 'stoplight', '~> 3.0.1' gem 'stoplight', '~> 3.0.1'
gem 'strong_migrations', '~> 0.8' gem 'strong_migrations', '1.3.0'
gem 'tty-prompt', '~> 0.23', require: false gem 'tty-prompt', '~> 0.23', require: false
gem 'twitter-text', '~> 3.1.0' gem 'twitter-text', '~> 3.1.0'
gem 'tzinfo-data', '~> 1.2023' gem 'tzinfo-data', '~> 1.2023'
@ -103,9 +103,6 @@ gem 'rdf-normalize', '~> 0.5'
gem 'private_address_check', '~> 0.5' gem 'private_address_check', '~> 0.5'
group :test do group :test do
# Used to split testing into chunks in CI
gem 'rspec_chunked', '~> 0.6'
# Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab # Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab
gem 'rspec-github', '~> 2.4', require: false gem 'rspec-github', '~> 2.4', require: false

View file

@ -172,7 +172,7 @@ GEM
binding_of_caller (1.0.0) binding_of_caller (1.0.0)
debug_inspector (>= 0.0.1) debug_inspector (>= 0.0.1)
blurhash (0.1.7) blurhash (0.1.7)
bootsnap (1.16.0) bootsnap (1.17.0)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (6.0.1) brakeman (6.0.1)
browser (5.3.1) browser (5.3.1)
@ -236,7 +236,7 @@ GEM
devise (>= 4.0.0) devise (>= 4.0.0)
rpam2 (~> 4.0) rpam2 (~> 4.0)
diff-lcs (1.5.0) diff-lcs (1.5.0)
discard (1.2.1) discard (1.3.0)
activerecord (>= 4.2, < 8) activerecord (>= 4.2, < 8)
docile (1.4.0) docile (1.4.0)
domain_name (0.5.20190701) domain_name (0.5.20190701)
@ -265,7 +265,7 @@ GEM
tzinfo tzinfo
excon (0.100.0) excon (0.100.0)
fabrication (2.30.0) fabrication (2.30.0)
faker (3.2.1) faker (3.2.2)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (1.10.3) faraday (1.10.3)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
@ -456,7 +456,7 @@ GEM
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.4) mini_portile2 (2.8.4)
minitest (5.20.0) minitest (5.20.0)
msgpack (1.7.1) msgpack (1.7.2)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.3.0) multipart-post (2.3.0)
mutex_m (0.1.2) mutex_m (0.1.2)
@ -536,7 +536,7 @@ GEM
pundit (2.3.1) pundit (2.3.1)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.7.1) racc (1.7.3)
rack (2.2.8) rack (2.2.8)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
@ -633,7 +633,7 @@ GEM
rspec-support (~> 3.12.0) rspec-support (~> 3.12.0)
rspec-github (2.4.0) rspec-github (2.4.0)
rspec-core (~> 3.0) rspec-core (~> 3.0)
rspec-mocks (3.12.5) rspec-mocks (3.12.6)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.12.0)
rspec-rails (6.0.3) rspec-rails (6.0.3)
@ -644,15 +644,13 @@ GEM
rspec-expectations (~> 3.12) rspec-expectations (~> 3.12)
rspec-mocks (~> 3.12) rspec-mocks (~> 3.12)
rspec-support (~> 3.12) rspec-support (~> 3.12)
rspec-sidekiq (4.0.1) rspec-sidekiq (4.1.0)
rspec-core (~> 3.0) rspec-core (~> 3.0)
rspec-expectations (~> 3.0) rspec-expectations (~> 3.0)
rspec-mocks (~> 3.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8) sidekiq (>= 5, < 8)
rspec-support (3.12.1) rspec-support (3.12.1)
rspec_chunked (0.6) rubocop (1.57.2)
rubocop (1.57.1)
base64 (~> 0.1.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
@ -742,7 +740,7 @@ GEM
stoplight (3.0.2) stoplight (3.0.2)
redlock (~> 1.0) redlock (~> 1.0)
stringio (3.0.8) stringio (3.0.8)
strong_migrations (0.8.0) strong_migrations (1.3.0)
activerecord (>= 5.2) activerecord (>= 5.2)
swd (1.3.0) swd (1.3.0)
activesupport (>= 3) activesupport (>= 3)
@ -835,7 +833,7 @@ DEPENDENCIES
better_errors (~> 2.9) better_errors (~> 2.9)
binding_of_caller (~> 1.0) binding_of_caller (~> 1.0)
blurhash (~> 0.1) blurhash (~> 0.1)
bootsnap (~> 1.16.0) bootsnap (~> 1.17.0)
brakeman (~> 6.0) brakeman (~> 6.0)
browser browser
bundler-audit (~> 0.9) bundler-audit (~> 0.9)
@ -921,7 +919,6 @@ DEPENDENCIES
rspec-github (~> 2.4) rspec-github (~> 2.4)
rspec-rails (~> 6.0) rspec-rails (~> 6.0)
rspec-sidekiq (~> 4.0) rspec-sidekiq (~> 4.0)
rspec_chunked (~> 0.6)
rubocop rubocop
rubocop-capybara rubocop-capybara
rubocop-performance rubocop-performance
@ -944,7 +941,7 @@ DEPENDENCIES
sprockets-rails (~> 3.4) sprockets-rails (~> 3.4)
stackprof stackprof
stoplight (~> 3.0.1) stoplight (~> 3.0.1)
strong_migrations (~> 0.8) strong_migrations (= 1.3.0)
test-prof test-prof
thor (~> 1.2) thor (~> 1.2)
tty-prompt (~> 0.23) tty-prompt (~> 0.23)

View file

@ -17,6 +17,6 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
| ------- | ---------------- | | ------- | ---------------- |
| 4.2.x | Yes | | 4.2.x | Yes |
| 4.1.x | Yes | | 4.1.x | Yes |
| 4.0.x | Until 2023-10-31 | | 4.0.x | No |
| 3.5.x | Until 2023-12-31 | | 3.5.x | Until 2023-12-31 |
| < 3.5 | No | | < 3.5 | No |

View file

@ -33,7 +33,7 @@ module Admin
# Disallow accidentally downgrading a domain block # Disallow accidentally downgrading a domain block
if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block) if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
@domain_block.save @domain_block.validate
flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe
@domain_block.errors.delete(:domain) @domain_block.errors.delete(:domain)
return render :new return render :new

View file

@ -92,18 +92,10 @@ module CacheConcern
arguments arguments
end end
if Rails.gem_version >= Gem::Version.new('7.0') def attributes_for_database(record)
def attributes_for_database(record) attributes = record.attributes_for_database
attributes = record.attributes_for_database attributes.transform_values! { |attr| attr.is_a?(::ActiveModel::Type::Binary::Data) ? attr.to_s : attr }
attributes.transform_values! { |attr| attr.is_a?(::ActiveModel::Type::Binary::Data) ? attr.to_s : attr } attributes
attributes
end
else
def attributes_for_database(record)
attributes = record.instance_variable_get(:@attributes).send(:attributes).transform_values(&:value_for_database)
attributes.transform_values! { |attr| attr.is_a?(::ActiveModel::Type::Binary::Data) ? attr.to_s : attr }
attributes
end
end end
def deserialize_record(class_name, attributes_from_database, new_record = false) # rubocop:disable Style/OptionalBooleanParameter def deserialize_record(class_name, attributes_from_database, new_record = false) # rubocop:disable Style/OptionalBooleanParameter

View file

@ -1,9 +1,9 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useCallback, useRef, useState, useEffect, forwardRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
@ -37,54 +37,46 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
} }
}; };
export default class AutosuggestTextarea extends ImmutablePureComponent { const AutosuggestTextarea = forwardRef(({
value,
suggestions,
disabled,
placeholder,
onSuggestionSelected,
onSuggestionsClearRequested,
onSuggestionsFetchRequested,
onChange,
onKeyUp,
onKeyDown,
onPaste,
onFocus,
autoFocus = true,
lang,
children,
}, textareaRef) => {
static propTypes = { const [suggestionsHidden, setSuggestionsHidden] = useState(true);
value: PropTypes.string, const [selectedSuggestion, setSelectedSuggestion] = useState(0);
suggestions: ImmutablePropTypes.list, const lastTokenRef = useRef(null);
disabled: PropTypes.bool, const tokenStartRef = useRef(0);
placeholder: PropTypes.string,
onSuggestionSelected: PropTypes.func.isRequired,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired,
autoFocus: PropTypes.bool,
lang: PropTypes.string,
};
static defaultProps = { const handleChange = useCallback((e) => {
autoFocus: true,
};
state = {
suggestionsHidden: true,
focused: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,
};
onChange = (e) => {
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
if (token !== null && this.state.lastToken !== token) { if (token !== null && lastTokenRef.current !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); tokenStartRef.current = tokenStart;
this.props.onSuggestionsFetchRequested(token); lastTokenRef.current = token;
setSelectedSuggestion(0);
onSuggestionsFetchRequested(token);
} else if (token === null) { } else if (token === null) {
this.setState({ lastToken: null }); lastTokenRef.current = null;
this.props.onSuggestionsClearRequested(); onSuggestionsClearRequested();
} }
this.props.onChange(e); onChange(e);
}; }, [onSuggestionsFetchRequested, onSuggestionsClearRequested, onChange, setSelectedSuggestion]);
onKeyDown = (e) => {
const { suggestions, disabled } = this.props;
const { selectedSuggestion, suggestionsHidden } = this.state;
const handleKeyDown = useCallback((e) => {
if (disabled) { if (disabled) {
e.preventDefault(); e.preventDefault();
return; return;
@ -102,80 +94,75 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
document.querySelector('.ui').parentElement.focus(); document.querySelector('.ui').parentElement.focus();
} else { } else {
e.preventDefault(); e.preventDefault();
this.setState({ suggestionsHidden: true }); setSuggestionsHidden(true);
} }
break; break;
case 'ArrowDown': case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) { if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault(); e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); setSelectedSuggestion(Math.min(selectedSuggestion + 1, suggestions.size - 1));
} }
break; break;
case 'ArrowUp': case 'ArrowUp':
if (suggestions.size > 0 && !suggestionsHidden) { if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault(); e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); setSelectedSuggestion(Math.max(selectedSuggestion - 1, 0));
} }
break; break;
case 'Enter': case 'Enter':
case 'Tab': case 'Tab':
// Select suggestion // Select suggestion
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { if (lastTokenRef.current !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestions.get(selectedSuggestion));
} }
break; break;
} }
if (e.defaultPrevented || !this.props.onKeyDown) { if (e.defaultPrevented || !onKeyDown) {
return; return;
} }
this.props.onKeyDown(e); onKeyDown(e);
}; }, [disabled, suggestions, suggestionsHidden, selectedSuggestion, setSelectedSuggestion, setSuggestionsHidden, onSuggestionSelected, onKeyDown]);
onBlur = () => { const handleBlur = useCallback(() => {
this.setState({ suggestionsHidden: true, focused: false }); setSuggestionsHidden(true);
}; }, [setSuggestionsHidden]);
onFocus = (e) => { const handleFocus = useCallback((e) => {
this.setState({ focused: true }); if (onFocus) {
if (this.props.onFocus) { onFocus(e);
this.props.onFocus(e);
} }
}; }, [onFocus]);
onSuggestionClick = (e) => { const handleSuggestionClick = useCallback((e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index'));
e.preventDefault(); e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestion);
this.textarea.focus(); textareaRef.current?.focus();
}; }, [suggestions, onSuggestionSelected, textareaRef]);
UNSAFE_componentWillReceiveProps (nextProps) { const handlePaste = useCallback((e) => {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
this.setState({ suggestionsHidden: false });
}
}
setTextarea = (c) => {
this.textarea = c;
};
onPaste = (e) => {
if (e.clipboardData && e.clipboardData.files.length === 1) { if (e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files); onPaste(e.clipboardData.files);
e.preventDefault(); e.preventDefault();
} }
}; }, [onPaste]);
renderSuggestion = (suggestion, i) => { // Show the suggestions again whenever they change and the textarea is focused
const { selectedSuggestion } = this.state; useEffect(() => {
if (suggestions.size > 0 && textareaRef.current === document.activeElement) {
setSuggestionsHidden(false);
}
}, [suggestions, textareaRef, setSuggestionsHidden]);
const renderSuggestion = (suggestion, i) => {
let inner, key; let inner, key;
if (suggestion.type === 'emoji') { if (suggestion.type === 'emoji') {
@ -190,50 +177,64 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
} }
return ( return (
<div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}> <div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={handleSuggestionClick}>
{inner} {inner}
</div> </div>
); );
}; };
render () { return [
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props; <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
const { suggestionsHidden } = this.state; <div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
return [ <Textarea
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'> ref={textareaRef}
<div className='autosuggest-textarea'> className='autosuggest-textarea__textarea'
<label> disabled={disabled}
<span style={{ display: 'none' }}>{placeholder}</span> 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>,
<Textarea <div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
ref={this.setTextarea} <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
className='autosuggest-textarea__textarea' {suggestions.map(renderSuggestion)}
disabled={disabled} </div>
placeholder={placeholder} </div>,
autoFocus={autoFocus} ];
value={value} });
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
onPaste={this.onPaste}
dir='auto'
aria-autocomplete='list'
lang={lang}
/>
</label>
</div>
{children}
</div>,
<div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'> AutosuggestTextarea.propTypes = {
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> value: PropTypes.string,
{suggestions.map(this.renderSuggestion)} suggestions: ImmutablePropTypes.list,
</div> disabled: PropTypes.bool,
</div>, placeholder: PropTypes.string,
]; onSuggestionSelected: PropTypes.func.isRequired,
} onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired,
onFocus:PropTypes.func,
children: PropTypes.node,
autoFocus: PropTypes.bool,
lang: PropTypes.string,
};
} export default AutosuggestTextarea;

View file

@ -1,4 +1,5 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { createRef } from 'react';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
@ -90,6 +91,11 @@ class ComposeForm extends ImmutablePureComponent {
highlighted: false, highlighted: false,
}; };
constructor(props) {
super(props);
this.textareaRef = createRef(null);
}
handleChange = (e) => { handleChange = (e) => {
this.props.onChange(e.target.value); this.props.onChange(e.target.value);
}; };
@ -118,10 +124,10 @@ class ComposeForm extends ImmutablePureComponent {
onChangeVisibility, onChangeVisibility,
} = this.props; } = this.props;
if (this.props.text !== this.textarea.value) { if (this.props.text !== this.textareaRef.current.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly) // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text // Update the state to match the current text
this.props.onChange(this.textarea.value); this.props.onChange(this.textareaRef.current.value);
} }
if (!this.canSubmit()) { if (!this.canSubmit()) {
@ -154,10 +160,10 @@ class ComposeForm extends ImmutablePureComponent {
// Inserts an emoji at the caret. // Inserts an emoji at the caret.
handleEmojiPick = (data) => { handleEmojiPick = (data) => {
const { textarea: { selectionStart } } = this; const position = this.textareaRef.current.selectionStart;
const { onPickEmoji } = this.props;
if (onPickEmoji) { if (this.props.onPickEmoji) {
onPickEmoji(selectionStart, data); this.props.onPickEmoji(position, data);
} }
}; };
@ -188,13 +194,6 @@ class ComposeForm extends ImmutablePureComponent {
} }
}; };
// Sets a reference to the textarea.
setAutosuggestTextarea = (textareaComponent) => {
if (textareaComponent) {
this.textarea = textareaComponent.textarea;
}
};
// Sets a reference to the CW field. // Sets a reference to the CW field.
handleRefSpoilerText = (spoilerComponent) => { handleRefSpoilerText = (spoilerComponent) => {
if (spoilerComponent) { if (spoilerComponent) {
@ -232,7 +231,6 @@ class ComposeForm extends ImmutablePureComponent {
// everyone else from the conversation. // everyone else from the conversation.
_updateFocusAndSelection = (prevProps) => { _updateFocusAndSelection = (prevProps) => {
const { const {
textarea,
spoilerText, spoilerText,
} = this; } = this;
const { const {
@ -259,30 +257,30 @@ class ComposeForm extends ImmutablePureComponent {
default: default:
selectionStart = selectionEnd = text.length; selectionStart = selectionEnd = text.length;
} }
if (textarea) { if (this.textareaRef.current) {
// Because of the wicg-inert polyfill, the activeElement may not be // Because of the wicg-inert polyfill, the activeElement may not be
// immediately selectable, we have to wait for observers to run, as // immediately selectable, we have to wait for observers to run, as
// described in https://github.com/WICG/inert#performance-and-gotchas // described in https://github.com/WICG/inert#performance-and-gotchas
Promise.resolve().then(() => { Promise.resolve().then(() => {
textarea.setSelectionRange(selectionStart, selectionEnd); this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
textarea.focus(); this.textareaRef.current.focus();
if (!singleColumn) textarea.scrollIntoView(); if (!singleColumn) this.textareaRef.current.scrollIntoView();
this.setState({ highlighted: true }); this.setState({ highlighted: true });
this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700); this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
}).catch(console.error); }).catch(console.error);
} }
// Refocuses the textarea after submitting. // Refocuses the textarea after submitting.
} else if (textarea && prevProps.isSubmitting && !isSubmitting) { } else if (this.textareaRef.current && prevProps.isSubmitting && !isSubmitting) {
textarea.focus(); this.textareaRef.current.focus();
} else if (this.props.spoiler !== prevProps.spoiler) { } else if (this.props.spoiler !== prevProps.spoiler) {
if (this.props.spoiler) { if (this.props.spoiler) {
if (spoilerText) { if (spoilerText) {
spoilerText.focus(); spoilerText.focus();
} }
} else { } else {
if (textarea) { if (this.textareaRef.current) {
textarea.focus(); this.textareaRef.current.focus();
} }
} }
} }
@ -347,7 +345,7 @@ class ComposeForm extends ImmutablePureComponent {
<div className={classNames('compose-form__highlightable', { active: highlighted })}> <div className={classNames('compose-form__highlightable', { active: highlighted })}>
<AutosuggestTextarea <AutosuggestTextarea
ref={this.setAutosuggestTextarea} ref={this.textareaRef}
placeholder={intl.formatMessage(messages.placeholder)} placeholder={intl.formatMessage(messages.placeholder)}
disabled={isSubmitting} disabled={isSubmitting}
value={this.props.text} value={this.props.text}

View file

@ -1,30 +0,0 @@
import 'core-js/features/object/assign';
import 'core-js/features/object/values';
import 'core-js/features/symbol';
import 'core-js/features/promise/finally';
import { decode as decodeBase64 } from '../utils/base64';
if (!Object.hasOwn(HTMLCanvasElement.prototype, 'toBlob')) {
const BASE64_MARKER = ';base64,';
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
value: function (
this: HTMLCanvasElement,
callback: BlobCallback,
type = 'image/png',
quality: unknown,
) {
const dataURL: string = this.toDataURL(type, quality);
let data;
if (dataURL.includes(BASE64_MARKER)) {
const [, base64] = dataURL.split(BASE64_MARKER);
data = decodeBase64(base64);
} else {
[, data] = dataURL.split(',');
}
callback(new Blob([data], { type }));
},
});
}

View file

@ -1,2 +1 @@
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only';
import 'requestidlecallback'; import 'requestidlecallback';

View file

@ -4,39 +4,18 @@
import { loadIntlPolyfills } from './intl'; import { loadIntlPolyfills } from './intl';
function importBasePolyfills() {
return import(/* webpackChunkName: "base_polyfills" */ './base_polyfills');
}
function importExtraPolyfills() { function importExtraPolyfills() {
return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills'); return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills');
} }
export function loadPolyfills() { export function loadPolyfills() {
const needsBasePolyfills = !( // Safari does not have requestIdleCallback.
'toBlob' in HTMLCanvasElement.prototype &&
'assign' in Object &&
'values' in Object &&
'Symbol' in window &&
'finally' in Promise.prototype
);
// Latest version of Firefox and Safari do not have IntersectionObserver.
// Edge does not have requestIdleCallback.
// This avoids shipping them all the polyfills. // This avoids shipping them all the polyfills.
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- those properties might not exist in old browsers, even if they are always here in types */ const needsExtraPolyfills = !window.requestIdleCallback;
const needsExtraPolyfills = !(
window.AbortController &&
window.IntersectionObserver &&
window.IntersectionObserverEntry &&
'isIntersecting' in IntersectionObserverEntry.prototype &&
window.requestIdleCallback
);
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
return Promise.all([ return Promise.all([
loadIntlPolyfills(), loadIntlPolyfills(),
needsBasePolyfills && importBasePolyfills(), // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- those properties might not exist in old browsers, even if they are always here in types
needsExtraPolyfills && importExtraPolyfills(), needsExtraPolyfills && importExtraPolyfills(),
]); ]);
} }

View file

@ -1,3 +1,4 @@
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
import { createAppAsyncThunk } from 'mastodon/store/typed_functions'; import { createAppAsyncThunk } from 'mastodon/store/typed_functions';
import api from '../api'; import api from '../api';
@ -5,8 +6,7 @@ import api from '../api';
export const submitAccountNote = createAppAsyncThunk( export const submitAccountNote = createAppAsyncThunk(
'account_note/submit', 'account_note/submit',
async (args: { id: string; value: string }, { getState }) => { async (args: { id: string; value: string }, { getState }) => {
// TODO: replace `unknown` with `ApiRelationshipJSON` when it is merged const response = await api(getState).post<ApiRelationshipJSON>(
const response = await api(getState).post<unknown>(
`/api/v1/accounts/${args.id}/note`, `/api/v1/accounts/${args.id}/note`,
{ {
comment: args.value, comment: args.value,

View file

@ -1,5 +1,15 @@
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
import {
followAccountSuccess, unfollowAccountSuccess,
authorizeFollowRequestSuccess, rejectFollowRequestSuccess,
followAccountRequest, followAccountFail,
unfollowAccountRequest, unfollowAccountFail,
muteAccountSuccess, unmuteAccountSuccess,
blockAccountSuccess, unblockAccountSuccess,
pinAccountSuccess, unpinAccountSuccess,
fetchRelationshipsSuccess,
} from './accounts_typed';
import { importFetchedAccount, importFetchedAccounts } from './importer'; import { importFetchedAccount, importFetchedAccounts } from './importer';
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
@ -10,36 +20,22 @@ export const ACCOUNT_LOOKUP_REQUEST = 'ACCOUNT_LOOKUP_REQUEST';
export const ACCOUNT_LOOKUP_SUCCESS = 'ACCOUNT_LOOKUP_SUCCESS'; export const ACCOUNT_LOOKUP_SUCCESS = 'ACCOUNT_LOOKUP_SUCCESS';
export const ACCOUNT_LOOKUP_FAIL = 'ACCOUNT_LOOKUP_FAIL'; export const ACCOUNT_LOOKUP_FAIL = 'ACCOUNT_LOOKUP_FAIL';
export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST';
export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS';
export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL';
export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST';
export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS';
export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL';
export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST'; export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST';
export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS';
export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL'; export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL';
export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST'; export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL';
export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST'; export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST';
export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS';
export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL'; export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL';
export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST';
export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL';
export const ACCOUNT_PIN_REQUEST = 'ACCOUNT_PIN_REQUEST'; export const ACCOUNT_PIN_REQUEST = 'ACCOUNT_PIN_REQUEST';
export const ACCOUNT_PIN_SUCCESS = 'ACCOUNT_PIN_SUCCESS';
export const ACCOUNT_PIN_FAIL = 'ACCOUNT_PIN_FAIL'; export const ACCOUNT_PIN_FAIL = 'ACCOUNT_PIN_FAIL';
export const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST'; export const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST';
export const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS';
export const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL'; export const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL';
export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST'; export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
@ -59,7 +55,6 @@ export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS';
export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL'; export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL';
export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST'; export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL'; export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL';
export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST'; export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST';
@ -71,15 +66,15 @@ export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS';
export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL'; export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL';
export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST'; export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST';
export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS';
export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL'; export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL';
export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
export const ACCOUNT_REVEAL = 'ACCOUNT_REVEAL'; export const ACCOUNT_REVEAL = 'ACCOUNT_REVEAL';
export * from './accounts_typed';
export function fetchAccount(id) { export function fetchAccount(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(fetchRelationships([id])); dispatch(fetchRelationships([id]));
@ -149,12 +144,12 @@ export function followAccount(id, options = { reblogs: true }) {
const alreadyFollowing = getState().getIn(['relationships', id, 'following']); const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
const locked = getState().getIn(['accounts', id, 'locked'], false); const locked = getState().getIn(['accounts', id, 'locked'], false);
dispatch(followAccountRequest(id, locked)); dispatch(followAccountRequest({ id, locked }));
api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => { api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => {
dispatch(followAccountSuccess(response.data, alreadyFollowing)); dispatch(followAccountSuccess({relationship: response.data, alreadyFollowing}));
}).catch(error => { }).catch(error => {
dispatch(followAccountFail(error, locked)); dispatch(followAccountFail({ id, error, locked }));
}); });
}; };
} }
@ -164,74 +159,22 @@ export function unfollowAccount(id) {
dispatch(unfollowAccountRequest(id)); dispatch(unfollowAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => { api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => {
dispatch(unfollowAccountSuccess(response.data, getState().get('statuses'))); dispatch(unfollowAccountSuccess({relationship: response.data, statuses: getState().get('statuses')}));
}).catch(error => { }).catch(error => {
dispatch(unfollowAccountFail(error)); dispatch(unfollowAccountFail({ id, error }));
}); });
}; };
} }
export function followAccountRequest(id, locked) {
return {
type: ACCOUNT_FOLLOW_REQUEST,
id,
locked,
skipLoading: true,
};
}
export function followAccountSuccess(relationship, alreadyFollowing) {
return {
type: ACCOUNT_FOLLOW_SUCCESS,
relationship,
alreadyFollowing,
skipLoading: true,
};
}
export function followAccountFail(error, locked) {
return {
type: ACCOUNT_FOLLOW_FAIL,
error,
locked,
skipLoading: true,
};
}
export function unfollowAccountRequest(id) {
return {
type: ACCOUNT_UNFOLLOW_REQUEST,
id,
skipLoading: true,
};
}
export function unfollowAccountSuccess(relationship, statuses) {
return {
type: ACCOUNT_UNFOLLOW_SUCCESS,
relationship,
statuses,
skipLoading: true,
};
}
export function unfollowAccountFail(error) {
return {
type: ACCOUNT_UNFOLLOW_FAIL,
error,
skipLoading: true,
};
}
export function blockAccount(id) { export function blockAccount(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(blockAccountRequest(id)); dispatch(blockAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/block`).then(response => { api(getState).post(`/api/v1/accounts/${id}/block`).then(response => {
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
dispatch(blockAccountSuccess(response.data, getState().get('statuses'))); dispatch(blockAccountSuccess({ relationship: response.data, statuses: getState().get('statuses') }));
}).catch(error => { }).catch(error => {
dispatch(blockAccountFail(id, error)); dispatch(blockAccountFail({ id, error }));
}); });
}; };
} }
@ -241,9 +184,9 @@ export function unblockAccount(id) {
dispatch(unblockAccountRequest(id)); dispatch(unblockAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => { api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => {
dispatch(unblockAccountSuccess(response.data)); dispatch(unblockAccountSuccess({ relationship: response.data }));
}).catch(error => { }).catch(error => {
dispatch(unblockAccountFail(id, error)); dispatch(unblockAccountFail({ id, error }));
}); });
}; };
} }
@ -254,15 +197,6 @@ export function blockAccountRequest(id) {
id, id,
}; };
} }
export function blockAccountSuccess(relationship, statuses) {
return {
type: ACCOUNT_BLOCK_SUCCESS,
relationship,
statuses,
};
}
export function blockAccountFail(error) { export function blockAccountFail(error) {
return { return {
type: ACCOUNT_BLOCK_FAIL, type: ACCOUNT_BLOCK_FAIL,
@ -277,13 +211,6 @@ export function unblockAccountRequest(id) {
}; };
} }
export function unblockAccountSuccess(relationship) {
return {
type: ACCOUNT_UNBLOCK_SUCCESS,
relationship,
};
}
export function unblockAccountFail(error) { export function unblockAccountFail(error) {
return { return {
type: ACCOUNT_UNBLOCK_FAIL, type: ACCOUNT_UNBLOCK_FAIL,
@ -298,9 +225,9 @@ export function muteAccount(id, notifications, duration=0) {
api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => { api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => {
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); dispatch(muteAccountSuccess({ relationship: response.data, statuses: getState().get('statuses') }));
}).catch(error => { }).catch(error => {
dispatch(muteAccountFail(id, error)); dispatch(muteAccountFail({ id, error }));
}); });
}; };
} }
@ -310,9 +237,9 @@ export function unmuteAccount(id) {
dispatch(unmuteAccountRequest(id)); dispatch(unmuteAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => { api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => {
dispatch(unmuteAccountSuccess(response.data)); dispatch(unmuteAccountSuccess({ relationship: response.data }));
}).catch(error => { }).catch(error => {
dispatch(unmuteAccountFail(id, error)); dispatch(unmuteAccountFail({ id, error }));
}); });
}; };
} }
@ -324,14 +251,6 @@ export function muteAccountRequest(id) {
}; };
} }
export function muteAccountSuccess(relationship, statuses) {
return {
type: ACCOUNT_MUTE_SUCCESS,
relationship,
statuses,
};
}
export function muteAccountFail(error) { export function muteAccountFail(error) {
return { return {
type: ACCOUNT_MUTE_FAIL, type: ACCOUNT_MUTE_FAIL,
@ -346,13 +265,6 @@ export function unmuteAccountRequest(id) {
}; };
} }
export function unmuteAccountSuccess(relationship) {
return {
type: ACCOUNT_UNMUTE_SUCCESS,
relationship,
};
}
export function unmuteAccountFail(error) { export function unmuteAccountFail(error) {
return { return {
type: ACCOUNT_UNMUTE_FAIL, type: ACCOUNT_UNMUTE_FAIL,
@ -549,7 +461,7 @@ export function fetchRelationships(accountIds) {
dispatch(fetchRelationshipsRequest(newAccountIds)); dispatch(fetchRelationshipsRequest(newAccountIds));
api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
dispatch(fetchRelationshipsSuccess(response.data)); dispatch(fetchRelationshipsSuccess({ relationships: response.data }));
}).catch(error => { }).catch(error => {
dispatch(fetchRelationshipsFail(error)); dispatch(fetchRelationshipsFail(error));
}); });
@ -564,14 +476,6 @@ export function fetchRelationshipsRequest(ids) {
}; };
} }
export function fetchRelationshipsSuccess(relationships) {
return {
type: RELATIONSHIPS_FETCH_SUCCESS,
relationships,
skipLoading: true,
};
}
export function fetchRelationshipsFail(error) { export function fetchRelationshipsFail(error) {
return { return {
type: RELATIONSHIPS_FETCH_FAIL, type: RELATIONSHIPS_FETCH_FAIL,
@ -659,7 +563,7 @@ export function authorizeFollowRequest(id) {
api(getState) api(getState)
.post(`/api/v1/follow_requests/${id}/authorize`) .post(`/api/v1/follow_requests/${id}/authorize`)
.then(() => dispatch(authorizeFollowRequestSuccess(id))) .then(() => dispatch(authorizeFollowRequestSuccess({ id })))
.catch(error => dispatch(authorizeFollowRequestFail(id, error))); .catch(error => dispatch(authorizeFollowRequestFail(id, error)));
}; };
} }
@ -671,13 +575,6 @@ export function authorizeFollowRequestRequest(id) {
}; };
} }
export function authorizeFollowRequestSuccess(id) {
return {
type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
id,
};
}
export function authorizeFollowRequestFail(id, error) { export function authorizeFollowRequestFail(id, error) {
return { return {
type: FOLLOW_REQUEST_AUTHORIZE_FAIL, type: FOLLOW_REQUEST_AUTHORIZE_FAIL,
@ -693,7 +590,7 @@ export function rejectFollowRequest(id) {
api(getState) api(getState)
.post(`/api/v1/follow_requests/${id}/reject`) .post(`/api/v1/follow_requests/${id}/reject`)
.then(() => dispatch(rejectFollowRequestSuccess(id))) .then(() => dispatch(rejectFollowRequestSuccess({ id })))
.catch(error => dispatch(rejectFollowRequestFail(id, error))); .catch(error => dispatch(rejectFollowRequestFail(id, error)));
}; };
} }
@ -705,13 +602,6 @@ export function rejectFollowRequestRequest(id) {
}; };
} }
export function rejectFollowRequestSuccess(id) {
return {
type: FOLLOW_REQUEST_REJECT_SUCCESS,
id,
};
}
export function rejectFollowRequestFail(id, error) { export function rejectFollowRequestFail(id, error) {
return { return {
type: FOLLOW_REQUEST_REJECT_FAIL, type: FOLLOW_REQUEST_REJECT_FAIL,
@ -725,7 +615,7 @@ export function pinAccount(id) {
dispatch(pinAccountRequest(id)); dispatch(pinAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => { api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => {
dispatch(pinAccountSuccess(response.data)); dispatch(pinAccountSuccess({ relationship: response.data }));
}).catch(error => { }).catch(error => {
dispatch(pinAccountFail(error)); dispatch(pinAccountFail(error));
}); });
@ -737,7 +627,7 @@ export function unpinAccount(id) {
dispatch(unpinAccountRequest(id)); dispatch(unpinAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => { api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => {
dispatch(unpinAccountSuccess(response.data)); dispatch(unpinAccountSuccess({ relationship: response.data }));
}).catch(error => { }).catch(error => {
dispatch(unpinAccountFail(error)); dispatch(unpinAccountFail(error));
}); });
@ -751,13 +641,6 @@ export function pinAccountRequest(id) {
}; };
} }
export function pinAccountSuccess(relationship) {
return {
type: ACCOUNT_PIN_SUCCESS,
relationship,
};
}
export function pinAccountFail(error) { export function pinAccountFail(error) {
return { return {
type: ACCOUNT_PIN_FAIL, type: ACCOUNT_PIN_FAIL,
@ -772,21 +655,9 @@ export function unpinAccountRequest(id) {
}; };
} }
export function unpinAccountSuccess(relationship) {
return {
type: ACCOUNT_UNPIN_SUCCESS,
relationship,
};
}
export function unpinAccountFail(error) { export function unpinAccountFail(error) {
return { return {
type: ACCOUNT_UNPIN_FAIL, type: ACCOUNT_UNPIN_FAIL,
error, error,
}; };
} }
export const revealAccount = id => ({
type: ACCOUNT_REVEAL,
id,
});

View file

@ -0,0 +1,97 @@
import { createAction } from '@reduxjs/toolkit';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
export const revealAccount = createAction<{
id: string;
}>('accounts/revealAccount');
export const importAccounts = createAction<{ accounts: ApiAccountJSON[] }>(
'accounts/importAccounts',
);
function actionWithSkipLoadingTrue<Args extends object>(args: Args) {
return {
payload: {
...args,
skipLoading: true,
},
};
}
export const followAccountSuccess = createAction(
'accounts/followAccountSuccess',
actionWithSkipLoadingTrue<{
relationship: ApiRelationshipJSON;
alreadyFollowing: boolean;
}>,
);
export const unfollowAccountSuccess = createAction(
'accounts/unfollowAccountSuccess',
actionWithSkipLoadingTrue<{
relationship: ApiRelationshipJSON;
statuses: unknown;
alreadyFollowing?: boolean;
}>,
);
export const authorizeFollowRequestSuccess = createAction<{ id: string }>(
'accounts/followRequestAuthorizeSuccess',
);
export const rejectFollowRequestSuccess = createAction<{ id: string }>(
'accounts/followRequestRejectSuccess',
);
export const followAccountRequest = createAction(
'accounts/followRequest',
actionWithSkipLoadingTrue<{ id: string; locked: boolean }>,
);
export const followAccountFail = createAction(
'accounts/followFail',
actionWithSkipLoadingTrue<{ id: string; error: string; locked: boolean }>,
);
export const unfollowAccountRequest = createAction(
'accounts/unfollowRequest',
actionWithSkipLoadingTrue<{ id: string }>,
);
export const unfollowAccountFail = createAction(
'accounts/unfollowFail',
actionWithSkipLoadingTrue<{ id: string; error: string }>,
);
export const blockAccountSuccess = createAction<{
relationship: ApiRelationshipJSON;
statuses: unknown;
}>('accounts/blockSuccess');
export const unblockAccountSuccess = createAction<{
relationship: ApiRelationshipJSON;
}>('accounts/unblockSuccess');
export const muteAccountSuccess = createAction<{
relationship: ApiRelationshipJSON;
statuses: unknown;
}>('accounts/muteSuccess');
export const unmuteAccountSuccess = createAction<{
relationship: ApiRelationshipJSON;
}>('accounts/unmuteSuccess');
export const pinAccountSuccess = createAction<{
relationship: ApiRelationshipJSON;
}>('accounts/pinSuccess');
export const unpinAccountSuccess = createAction<{
relationship: ApiRelationshipJSON;
}>('accounts/unpinSuccess');
export const fetchRelationshipsSuccess = createAction(
'relationships/fetchSuccess',
actionWithSkipLoadingTrue<{ relationships: ApiRelationshipJSON[] }>,
);

View file

@ -1,11 +1,13 @@
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
import { blockDomainSuccess, unblockDomainSuccess } from "./domain_blocks_typed";
export * from "./domain_blocks_typed";
export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST'; export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST';
export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS';
export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL'; export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL';
export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST'; export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST';
export const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS';
export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL'; export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL';
export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST'; export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST';
@ -24,7 +26,7 @@ export function blockDomain(domain) {
const at_domain = '@' + domain; const at_domain = '@' + domain;
const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
dispatch(blockDomainSuccess(domain, accounts)); dispatch(blockDomainSuccess({ domain, accounts }));
}).catch(err => { }).catch(err => {
dispatch(blockDomainFail(domain, err)); dispatch(blockDomainFail(domain, err));
}); });
@ -38,14 +40,6 @@ export function blockDomainRequest(domain) {
}; };
} }
export function blockDomainSuccess(domain, accounts) {
return {
type: DOMAIN_BLOCK_SUCCESS,
domain,
accounts,
};
}
export function blockDomainFail(domain, error) { export function blockDomainFail(domain, error) {
return { return {
type: DOMAIN_BLOCK_FAIL, type: DOMAIN_BLOCK_FAIL,
@ -61,7 +55,7 @@ export function unblockDomain(domain) {
api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => { api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => {
const at_domain = '@' + domain; const at_domain = '@' + domain;
const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
dispatch(unblockDomainSuccess(domain, accounts)); dispatch(unblockDomainSuccess({ domain, accounts }));
}).catch(err => { }).catch(err => {
dispatch(unblockDomainFail(domain, err)); dispatch(unblockDomainFail(domain, err));
}); });
@ -75,14 +69,6 @@ export function unblockDomainRequest(domain) {
}; };
} }
export function unblockDomainSuccess(domain, accounts) {
return {
type: DOMAIN_UNBLOCK_SUCCESS,
domain,
accounts,
};
}
export function unblockDomainFail(domain, error) { export function unblockDomainFail(domain, error) {
return { return {
type: DOMAIN_UNBLOCK_FAIL, type: DOMAIN_UNBLOCK_FAIL,

View file

@ -0,0 +1,13 @@
import { createAction } from '@reduxjs/toolkit';
import type { Account } from 'mastodon/models/account';
export const blockDomainSuccess = createAction<{
domain: string;
accounts: Account[];
}>('domain_blocks/blockSuccess');
export const unblockDomainSuccess = createAction<{
domain: string;
accounts: Account[];
}>('domain_blocks/unblockSuccess');

View file

@ -1,7 +1,7 @@
import { normalizeAccount, normalizeStatus, normalizePoll } from './normalizer'; import { importAccounts } from '../accounts_typed';
import { normalizeStatus, normalizePoll } from './normalizer';
export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
export const STATUS_IMPORT = 'STATUS_IMPORT'; export const STATUS_IMPORT = 'STATUS_IMPORT';
export const STATUSES_IMPORT = 'STATUSES_IMPORT'; export const STATUSES_IMPORT = 'STATUSES_IMPORT';
export const POLLS_IMPORT = 'POLLS_IMPORT'; export const POLLS_IMPORT = 'POLLS_IMPORT';
@ -13,14 +13,6 @@ function pushUnique(array, object) {
} }
} }
export function importAccount(account) {
return { type: ACCOUNT_IMPORT, account };
}
export function importAccounts(accounts) {
return { type: ACCOUNTS_IMPORT, accounts };
}
export function importStatus(status) { export function importStatus(status) {
return { type: STATUS_IMPORT, status }; return { type: STATUS_IMPORT, status };
} }
@ -45,7 +37,7 @@ export function importFetchedAccounts(accounts) {
const normalAccounts = []; const normalAccounts = [];
function processAccount(account) { function processAccount(account) {
pushUnique(normalAccounts, normalizeAccount(account)); pushUnique(normalAccounts, account);
if (account.moved) { if (account.moved) {
processAccount(account.moved); processAccount(account.moved);
@ -54,7 +46,7 @@ export function importFetchedAccounts(accounts) {
accounts.forEach(processAccount); accounts.forEach(processAccount);
return importAccounts(normalAccounts); return importAccounts({ accounts: normalAccounts });
} }
export function importFetchedStatus(status) { export function importFetchedStatus(status) {

View file

@ -2,7 +2,6 @@ import escapeTextContentForBrowser from 'escape-html';
import emojify from '../../features/emoji/emoji'; import emojify from '../../features/emoji/emoji';
import { expandSpoilers } from '../../initial_state'; import { expandSpoilers } from '../../initial_state';
import { unescapeHTML } from '../../utils/html';
const domParser = new DOMParser(); const domParser = new DOMParser();
@ -17,32 +16,6 @@ export function searchTextFromRawStatus (status) {
return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
} }
export function normalizeAccount(account) {
account = { ...account };
const emojiMap = makeEmojiMap(account.emojis);
const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name;
account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
account.note_emojified = emojify(account.note, emojiMap);
account.note_plain = unescapeHTML(account.note);
if (account.fields) {
account.fields = account.fields.map(pair => ({
...pair,
name_emojified: emojify(escapeTextContentForBrowser(pair.name), emojiMap),
value_emojified: emojify(pair.value, emojiMap),
value_plain: unescapeHTML(pair.value),
}));
}
if (account.moved) {
account.moved = account.moved.id;
}
return account;
}
export function normalizeFilterResult(result) { export function normalizeFilterResult(result) {
const normalResult = { ...result }; const normalResult = { ...result };

View file

@ -18,10 +18,12 @@ import {
importFetchedStatuses, importFetchedStatuses,
} from './importer'; } from './importer';
import { submitMarkers } from './markers'; import { submitMarkers } from './markers';
import { notificationsUpdate } from "./notifications_typed";
import { register as registerPushNotifications } from './push_notifications'; import { register as registerPushNotifications } from './push_notifications';
import { saveSettings } from './settings'; import { saveSettings } from './settings';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export * from "./notifications_typed";
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
@ -95,12 +97,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
dispatch(importFetchedAccount(notification.report.target_account)); dispatch(importFetchedAccount(notification.report.target_account));
} }
dispatch({
type: NOTIFICATIONS_UPDATE, dispatch(notificationsUpdate(notification, preferPendingItems, playSound && !filtered));
notification,
usePendingItems: preferPendingItems,
meta: (playSound && !filtered) ? { sound: 'boop' } : undefined,
});
fetchRelatedRelationships(dispatch, [notification]); fetchRelatedRelationships(dispatch, [notification]);
} else if (playSound && !filtered) { } else if (playSound && !filtered) {

View file

@ -0,0 +1,23 @@
import { createAction } from '@reduxjs/toolkit';
import type { ApiAccountJSON } from '../api_types/accounts';
// To be replaced once ApiNotificationJSON type exists
interface FakeApiNotificationJSON {
type: string;
account: ApiAccountJSON;
}
export const notificationsUpdate = createAction(
'notifications/update',
({
playSound,
...args
}: {
notification: FakeApiNotificationJSON;
usePendingItems: boolean;
playSound: boolean;
}) => ({
payload: args,
meta: { playSound: playSound ? { sound: 'boop' } : undefined },
}),
);

View file

@ -11,6 +11,7 @@ const convertState = rawState =>
fromJS(rawState, (k, v) => fromJS(rawState, (k, v) =>
Iterable.isIndexed(v) ? v.toList() : v.toMap()); Iterable.isIndexed(v) ? v.toList() : v.toMap());
export function hydrateStore(rawState) { export function hydrateStore(rawState) {
return dispatch => { return dispatch => {
const state = convertState(rawState); const state = convertState(rawState);

View file

@ -31,9 +31,9 @@ export interface ApiAccountJSON {
id: string; id: string;
last_status_at: string; last_status_at: string;
locked: boolean; locked: boolean;
noindex: boolean; noindex?: boolean;
note: string; note: string;
roles: ApiAccountJSON[]; roles?: ApiAccountJSON[];
statuses_count: number; statuses_count: number;
uri: string; uri: string;
url: string; url: string;

View file

@ -36,7 +36,7 @@ class Account extends ImmutablePureComponent {
static propTypes = { static propTypes = {
size: PropTypes.number, size: PropTypes.number,
account: ImmutablePropTypes.map, account: ImmutablePropTypes.record,
onFollow: PropTypes.func.isRequired, onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired,

View file

@ -1,9 +1,9 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useCallback, useRef, useState, useEffect, forwardRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
@ -37,54 +37,46 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
} }
}; };
export default class AutosuggestTextarea extends ImmutablePureComponent { const AutosuggestTextarea = forwardRef(({
value,
suggestions,
disabled,
placeholder,
onSuggestionSelected,
onSuggestionsClearRequested,
onSuggestionsFetchRequested,
onChange,
onKeyUp,
onKeyDown,
onPaste,
onFocus,
autoFocus = true,
lang,
children,
}, textareaRef) => {
static propTypes = { const [suggestionsHidden, setSuggestionsHidden] = useState(true);
value: PropTypes.string, const [selectedSuggestion, setSelectedSuggestion] = useState(0);
suggestions: ImmutablePropTypes.list, const lastTokenRef = useRef(null);
disabled: PropTypes.bool, const tokenStartRef = useRef(0);
placeholder: PropTypes.string,
onSuggestionSelected: PropTypes.func.isRequired,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired,
autoFocus: PropTypes.bool,
lang: PropTypes.string,
};
static defaultProps = { const handleChange = useCallback((e) => {
autoFocus: true,
};
state = {
suggestionsHidden: true,
focused: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,
};
onChange = (e) => {
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
if (token !== null && this.state.lastToken !== token) { if (token !== null && lastTokenRef.current !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); tokenStartRef.current = tokenStart;
this.props.onSuggestionsFetchRequested(token); lastTokenRef.current = token;
setSelectedSuggestion(0);
onSuggestionsFetchRequested(token);
} else if (token === null) { } else if (token === null) {
this.setState({ lastToken: null }); lastTokenRef.current = null;
this.props.onSuggestionsClearRequested(); onSuggestionsClearRequested();
} }
this.props.onChange(e); onChange(e);
}; }, [onSuggestionsFetchRequested, onSuggestionsClearRequested, onChange, setSelectedSuggestion]);
onKeyDown = (e) => {
const { suggestions, disabled } = this.props;
const { selectedSuggestion, suggestionsHidden } = this.state;
const handleKeyDown = useCallback((e) => {
if (disabled) { if (disabled) {
e.preventDefault(); e.preventDefault();
return; return;
@ -102,80 +94,75 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
document.querySelector('.ui').parentElement.focus(); document.querySelector('.ui').parentElement.focus();
} else { } else {
e.preventDefault(); e.preventDefault();
this.setState({ suggestionsHidden: true }); setSuggestionsHidden(true);
} }
break; break;
case 'ArrowDown': case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) { if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault(); e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); setSelectedSuggestion(Math.min(selectedSuggestion + 1, suggestions.size - 1));
} }
break; break;
case 'ArrowUp': case 'ArrowUp':
if (suggestions.size > 0 && !suggestionsHidden) { if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault(); e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); setSelectedSuggestion(Math.max(selectedSuggestion - 1, 0));
} }
break; break;
case 'Enter': case 'Enter':
case 'Tab': case 'Tab':
// Select suggestion // Select suggestion
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { if (lastTokenRef.current !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestions.get(selectedSuggestion));
} }
break; break;
} }
if (e.defaultPrevented || !this.props.onKeyDown) { if (e.defaultPrevented || !onKeyDown) {
return; return;
} }
this.props.onKeyDown(e); onKeyDown(e);
}; }, [disabled, suggestions, suggestionsHidden, selectedSuggestion, setSelectedSuggestion, setSuggestionsHidden, onSuggestionSelected, onKeyDown]);
onBlur = () => { const handleBlur = useCallback(() => {
this.setState({ suggestionsHidden: true, focused: false }); setSuggestionsHidden(true);
}; }, [setSuggestionsHidden]);
onFocus = (e) => { const handleFocus = useCallback((e) => {
this.setState({ focused: true }); if (onFocus) {
if (this.props.onFocus) { onFocus(e);
this.props.onFocus(e);
} }
}; }, [onFocus]);
onSuggestionClick = (e) => { const handleSuggestionClick = useCallback((e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index'));
e.preventDefault(); e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestion);
this.textarea.focus(); textareaRef.current?.focus();
}; }, [suggestions, onSuggestionSelected, textareaRef]);
UNSAFE_componentWillReceiveProps (nextProps) { const handlePaste = useCallback((e) => {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
this.setState({ suggestionsHidden: false });
}
}
setTextarea = (c) => {
this.textarea = c;
};
onPaste = (e) => {
if (e.clipboardData && e.clipboardData.files.length === 1) { if (e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files); onPaste(e.clipboardData.files);
e.preventDefault(); e.preventDefault();
} }
}; }, [onPaste]);
renderSuggestion = (suggestion, i) => { // Show the suggestions again whenever they change and the textarea is focused
const { selectedSuggestion } = this.state; useEffect(() => {
if (suggestions.size > 0 && textareaRef.current === document.activeElement) {
setSuggestionsHidden(false);
}
}, [suggestions, textareaRef, setSuggestionsHidden]);
const renderSuggestion = (suggestion, i) => {
let inner, key; let inner, key;
if (suggestion.type === 'emoji') { if (suggestion.type === 'emoji') {
@ -190,50 +177,64 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
} }
return ( return (
<div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}> <div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={handleSuggestionClick}>
{inner} {inner}
</div> </div>
); );
}; };
render () { return [
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props; <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
const { suggestionsHidden } = this.state; <div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
return [ <Textarea
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'> ref={textareaRef}
<div className='autosuggest-textarea'> className='autosuggest-textarea__textarea'
<label> disabled={disabled}
<span style={{ display: 'none' }}>{placeholder}</span> 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>,
<Textarea <div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
ref={this.setTextarea} <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
className='autosuggest-textarea__textarea' {suggestions.map(renderSuggestion)}
disabled={disabled} </div>
placeholder={placeholder} </div>,
autoFocus={autoFocus} ];
value={value} });
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
onPaste={this.onPaste}
dir='auto'
aria-autocomplete='list'
lang={lang}
/>
</label>
</div>
{children}
</div>,
<div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'> AutosuggestTextarea.propTypes = {
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> value: PropTypes.string,
{suggestions.map(this.renderSuggestion)} suggestions: ImmutablePropTypes.list,
</div> disabled: PropTypes.bool,
</div>, placeholder: PropTypes.string,
]; onSuggestionSelected: PropTypes.func.isRequired,
} onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired,
onFocus:PropTypes.func,
children: PropTypes.node,
autoFocus: PropTypes.bool,
lang: PropTypes.string,
};
} export default AutosuggestTextarea;

View file

@ -1,7 +1,8 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { Account } from 'mastodon/models/account';
import { useHovering } from '../../hooks/useHovering'; import { useHovering } from '../../hooks/useHovering';
import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state'; import { autoPlayGif } from '../initial_state';
interface Props { interface Props {

View file

@ -1,5 +1,6 @@
import type { Account } from 'mastodon/models/account';
import { useHovering } from '../../hooks/useHovering'; import { useHovering } from '../../hooks/useHovering';
import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state'; import { autoPlayGif } from '../initial_state';
interface Props { interface Props {

View file

@ -2,7 +2,8 @@ import React from 'react';
import type { List } from 'immutable'; import type { List } from 'immutable';
import type { Account } from '../../types/resources'; import type { Account } from 'mastodon/models/account';
import { autoPlayGif } from '../initial_state'; import { autoPlayGif } from '../initial_state';
import { Skeleton } from './skeleton'; import { Skeleton } from './skeleton';

View file

@ -2,6 +2,8 @@ import classNames from 'classnames';
import { ReactComponent as CheckBoxOutlineBlankIcon } from '@material-symbols/svg-600/outlined/check_box_outline_blank.svg'; import { ReactComponent as CheckBoxOutlineBlankIcon } from '@material-symbols/svg-600/outlined/check_box_outline_blank.svg';
import { isProduction } from 'mastodon/utils/environment';
interface SVGPropsWithTitle extends React.SVGProps<SVGSVGElement> { interface SVGPropsWithTitle extends React.SVGProps<SVGSVGElement> {
title?: string; title?: string;
} }
@ -24,7 +26,7 @@ export const Icon: React.FC<Props> = ({
}) => { }) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!IconComponent) { if (!IconComponent) {
if (process.env.NODE_ENV !== 'production') { if (!isProduction()) {
throw new Error( throw new Error(
`<Icon id="${id}" className="${className}"> is missing an "icon" prop.`, `<Icon id="${id}" className="${className}"> is missing an "icon" prop.`,
); );

View file

@ -19,7 +19,7 @@ const makeMapStateToProps = () => {
class InlineAccount extends PureComponent { class InlineAccount extends PureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
}; };
render () { render () {

View file

@ -11,6 +11,7 @@ import type {
import { createBrowserHistory } from 'history'; import { createBrowserHistory } from 'history';
import { layoutFromWindow } from 'mastodon/is_mobile'; import { layoutFromWindow } from 'mastodon/is_mobile';
import { isDevelopment } from 'mastodon/utils/environment';
interface MastodonLocationState { interface MastodonLocationState {
fromMastodon?: boolean; fromMastodon?: boolean;
@ -40,7 +41,7 @@ function normalizePath(
} else if ( } else if (
location.state !== undefined && location.state !== undefined &&
state !== undefined && state !== undefined &&
process.env.NODE_ENV === 'development' isDevelopment()
) { ) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log( console.log(

View file

@ -85,7 +85,7 @@ class Status extends ImmutablePureComponent {
static propTypes = { static propTypes = {
status: ImmutablePropTypes.map, status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map, account: ImmutablePropTypes.record,
previousId: PropTypes.string, previousId: PropTypes.string,
nextInReplyToId: PropTypes.string, nextInReplyToId: PropTypes.string,
rootId: PropTypes.string, rootId: PropTypes.string,

View file

@ -17,8 +17,9 @@ import UI from 'mastodon/features/ui';
import initialState, { title as siteTitle } from 'mastodon/initial_state'; import initialState, { title as siteTitle } from 'mastodon/initial_state';
import { IntlProvider } from 'mastodon/locales'; import { IntlProvider } from 'mastodon/locales';
import { store } from 'mastodon/store'; import { store } from 'mastodon/store';
import { isProduction } from 'mastodon/utils/environment';
const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`; const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
const hydrateAction = hydrateStore(initialState); const hydrateAction = hydrateStore(initialState);

View file

@ -49,7 +49,7 @@ class InlineAlert extends PureComponent {
class AccountNote extends ImmutablePureComponent { class AccountNote extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
value: PropTypes.string, value: PropTypes.string,
onSave: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,

View file

@ -15,7 +15,7 @@ const messages = defineMessages({
class FeaturedTags extends ImmutablePureComponent { class FeaturedTags extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map, account: ImmutablePropTypes.record,
featuredTags: ImmutablePropTypes.list, featuredTags: ImmutablePropTypes.list,
tagged: PropTypes.string, tagged: PropTypes.string,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,

View file

@ -11,7 +11,7 @@ import { Icon } from 'mastodon/components/icon';
export default class FollowRequestNote extends ImmutablePureComponent { export default class FollowRequestNote extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
}; };
render () { render () {

View file

@ -91,7 +91,7 @@ const dateFormatOptions = {
class Header extends ImmutablePureComponent { class Header extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map, account: ImmutablePropTypes.record,
identity_props: ImmutablePropTypes.list, identity_props: ImmutablePropTypes.list,
onFollow: PropTypes.func.isRequired, onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired,

View file

@ -17,7 +17,7 @@ import MovedNote from './moved_note';
class Header extends ImmutablePureComponent { class Header extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map, account: ImmutablePropTypes.record,
onFollow: PropTypes.func.isRequired, onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired,

View file

@ -1,40 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { revealAccount } from 'mastodon/actions/accounts';
import { Button } from 'mastodon/components/button';
import { domain } from 'mastodon/initial_state';
const mapDispatchToProps = (dispatch, { accountId }) => ({
reveal () {
dispatch(revealAccount(accountId));
},
});
class LimitedAccountHint extends PureComponent {
static propTypes = {
accountId: PropTypes.string.isRequired,
reveal: PropTypes.func,
};
render () {
const { reveal } = this.props;
return (
<div className='limited-account-hint'>
<p><FormattedMessage id='limited_account_hint.title' defaultMessage='This profile has been hidden by the moderators of {domain}.' values={{ domain }} /></p>
<Button onClick={reveal}><FormattedMessage id='limited_account_hint.action' defaultMessage='Show profile anyway' /></Button>
</div>
);
}
}
export default connect(() => {}, mapDispatchToProps)(LimitedAccountHint);

View file

@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { revealAccount } from 'mastodon/actions/accounts_typed';
import { Button } from 'mastodon/components/button';
import { domain } from 'mastodon/initial_state';
import { useAppDispatch } from 'mastodon/store';
export const LimitedAccountHint: React.FC<{ accountId: string }> = ({
accountId,
}) => {
const dispatch = useAppDispatch();
const reveal = useCallback(() => {
dispatch(revealAccount({ id: accountId }));
}, [dispatch, accountId]);
return (
<div className='limited-account-hint'>
<p>
<FormattedMessage
id='limited_account_hint.title'
defaultMessage='This profile has been hidden by the moderators of {domain}.'
values={{ domain }}
/>
</p>
<Button onClick={reveal}>
<FormattedMessage
id='limited_account_hint.action'
defaultMessage='Show profile anyway'
/>
</Button>
</div>
);
};

View file

@ -21,7 +21,7 @@ import { LoadingIndicator } from '../../components/loading_indicator';
import StatusList from '../../components/status_list'; import StatusList from '../../components/status_list';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
import LimitedAccountHint from './components/limited_account_hint'; import { LimitedAccountHint } from './components/limited_account_hint';
import HeaderContainer from './containers/header_container'; import HeaderContainer from './containers/header_container';
const emptyList = ImmutableList(); const emptyList = ImmutableList();

View file

@ -28,7 +28,7 @@ const messages = defineMessages({
class ActionBar extends PureComponent { class ActionBar extends PureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
onLogout: PropTypes.func.isRequired, onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };

View file

@ -7,7 +7,7 @@ import { DisplayName } from '../../../components/display_name';
export default class AutosuggestAccount extends ImmutablePureComponent { export default class AutosuggestAccount extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
}; };
render () { render () {

View file

@ -1,4 +1,5 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { createRef } from 'react';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
@ -80,6 +81,11 @@ class ComposeForm extends ImmutablePureComponent {
highlighted: false, highlighted: false,
}; };
constructor(props) {
super(props);
this.textareaRef = createRef(null);
}
handleChange = (e) => { handleChange = (e) => {
this.props.onChange(e.target.value); this.props.onChange(e.target.value);
}; };
@ -103,10 +109,10 @@ class ComposeForm extends ImmutablePureComponent {
}; };
handleSubmit = (e) => { handleSubmit = (e) => {
if (this.props.text !== this.autosuggestTextarea.textarea.value) { if (this.props.text !== this.textareaRef.current.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly) // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text // Update the state to match the current text
this.props.onChange(this.autosuggestTextarea.textarea.value); this.props.onChange(this.textareaRef.current.value);
} }
if (!this.canSubmit()) { if (!this.canSubmit()) {
@ -185,26 +191,22 @@ class ComposeForm extends ImmutablePureComponent {
// immediately selectable, we have to wait for observers to run, as // immediately selectable, we have to wait for observers to run, as
// described in https://github.com/WICG/inert#performance-and-gotchas // described in https://github.com/WICG/inert#performance-and-gotchas
Promise.resolve().then(() => { Promise.resolve().then(() => {
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
this.autosuggestTextarea.textarea.focus(); this.textareaRef.current.focus();
this.setState({ highlighted: true }); this.setState({ highlighted: true });
this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700); this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
}).catch(console.error); }).catch(console.error);
} else if(prevProps.isSubmitting && !this.props.isSubmitting) { } else if(prevProps.isSubmitting && !this.props.isSubmitting) {
this.autosuggestTextarea.textarea.focus(); this.textareaRef.current.focus();
} else if (this.props.spoiler !== prevProps.spoiler) { } else if (this.props.spoiler !== prevProps.spoiler) {
if (this.props.spoiler) { if (this.props.spoiler) {
this.spoilerText.input.focus(); this.spoilerText.input.focus();
} else if (prevProps.spoiler) { } else if (prevProps.spoiler) {
this.autosuggestTextarea.textarea.focus(); this.textareaRef.current.focus();
} }
} }
}; };
setAutosuggestTextarea = (c) => {
this.autosuggestTextarea = c;
};
setSpoilerText = (c) => { setSpoilerText = (c) => {
this.spoilerText = c; this.spoilerText = c;
}; };
@ -215,7 +217,7 @@ class ComposeForm extends ImmutablePureComponent {
handleEmojiPick = (data) => { handleEmojiPick = (data) => {
const { text } = this.props; const { text } = this.props;
const position = this.autosuggestTextarea.textarea.selectionStart; const position = this.textareaRef.current.selectionStart;
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]); const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
this.props.onPickEmoji(position, data, needsSpace); this.props.onPickEmoji(position, data, needsSpace);
@ -264,7 +266,7 @@ class ComposeForm extends ImmutablePureComponent {
<div className={classNames('compose-form__highlightable', { active: highlighted })}> <div className={classNames('compose-form__highlightable', { active: highlighted })}>
<AutosuggestTextarea <AutosuggestTextarea
ref={this.setAutosuggestTextarea} ref={this.textareaRef}
placeholder={intl.formatMessage(messages.placeholder)} placeholder={intl.formatMessage(messages.placeholder)}
disabled={disabled} disabled={disabled}
value={this.props.text} value={this.props.text}

View file

@ -14,7 +14,7 @@ import ActionBar from './action_bar';
export default class NavigationBar extends ImmutablePureComponent { export default class NavigationBar extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
onLogout: PropTypes.func.isRequired, onLogout: PropTypes.func.isRequired,
onClose: PropTypes.func, onClose: PropTypes.func,
}; };

View file

@ -102,7 +102,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
class AccountCard extends ImmutablePureComponent { class AccountCard extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
onFollow: PropTypes.func.isRequired, onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired,

View file

@ -22,7 +22,7 @@ const messages = defineMessages({
class AccountAuthorize extends ImmutablePureComponent { class AccountAuthorize extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
onAuthorize: PropTypes.func.isRequired, onAuthorize: PropTypes.func.isRequired,
onReject: PropTypes.func.isRequired, onReject: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,

View file

@ -23,7 +23,7 @@ import { ColumnBackButton } from '../../components/column_back_button';
import { LoadingIndicator } from '../../components/loading_indicator'; import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list'; import ScrollableList from '../../components/scrollable_list';
import AccountContainer from '../../containers/account_container'; import AccountContainer from '../../containers/account_container';
import LimitedAccountHint from '../account_timeline/components/limited_account_hint'; import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
import HeaderContainer from '../account_timeline/containers/header_container'; import HeaderContainer from '../account_timeline/containers/header_container';
import Column from '../ui/components/column'; import Column from '../ui/components/column';

View file

@ -23,7 +23,7 @@ import { ColumnBackButton } from '../../components/column_back_button';
import { LoadingIndicator } from '../../components/loading_indicator'; import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list'; import ScrollableList from '../../components/scrollable_list';
import AccountContainer from '../../containers/account_container'; import AccountContainer from '../../containers/account_container';
import LimitedAccountHint from '../account_timeline/components/limited_account_hint'; import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
import HeaderContainer from '../account_timeline/containers/header_container'; import HeaderContainer from '../account_timeline/containers/header_container';
import Column from '../ui/components/column'; import Column from '../ui/components/column';

View file

@ -21,7 +21,7 @@ const makeMapStateToProps = () => {
class Account extends ImmutablePureComponent { class Account extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
}; };
render () { render () {

View file

@ -39,7 +39,7 @@ const mapDispatchToProps = (dispatch, { accountId }) => ({
class Account extends ImmutablePureComponent { class Account extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired, onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired, onAdd: PropTypes.func.isRequired,

View file

@ -22,7 +22,7 @@ const messages = defineMessages({
class FollowRequest extends ImmutablePureComponent { class FollowRequest extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
onAuthorize: PropTypes.func.isRequired, onAuthorize: PropTypes.func.isRequired,
onReject: PropTypes.func.isRequired, onReject: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,

View file

@ -20,7 +20,7 @@ const messages = defineMessages({
class Report extends ImmutablePureComponent { class Report extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
report: ImmutablePropTypes.map.isRequired, report: ImmutablePropTypes.map.isRequired,
hidden: PropTypes.bool, hidden: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,

View file

@ -46,7 +46,7 @@ const mapStateToProps = () => {
class Onboarding extends ImmutablePureComponent { class Onboarding extends ImmutablePureComponent {
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
account: ImmutablePropTypes.map, account: ImmutablePropTypes.record,
...WithRouterPropTypes, ...WithRouterPropTypes,
}; };

View file

@ -145,7 +145,7 @@ class Share extends PureComponent {
static propTypes = { static propTypes = {
onBack: PropTypes.func, onBack: PropTypes.func,
account: ImmutablePropTypes.map, account: ImmutablePropTypes.record,
intl: PropTypes.object, intl: PropTypes.object,
}; };

View file

@ -27,7 +27,7 @@ class Header extends ImmutablePureComponent {
static propTypes = { static propTypes = {
accountId: PropTypes.string.isRequired, accountId: PropTypes.string.isRequired,
statusId: PropTypes.string.isRequired, statusId: PropTypes.string.isRequired,
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };

View file

@ -20,7 +20,7 @@ class Thanks extends PureComponent {
static propTypes = { static propTypes = {
submitted: PropTypes.bool, submitted: PropTypes.bool,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
}; };

View file

@ -110,7 +110,7 @@ class FocalPointModal extends ImmutablePureComponent {
static propTypes = { static propTypes = {
media: ImmutablePropTypes.map.isRequired, media: ImmutablePropTypes.map.isRequired,
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
isUploadingThumbnail: PropTypes.bool, isUploadingThumbnail: PropTypes.bool,
onSave: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired,
onChangeDescription: PropTypes.func.isRequired, onChangeDescription: PropTypes.func.isRequired,

View file

@ -41,7 +41,7 @@ class ReportModal extends ImmutablePureComponent {
statusId: PropTypes.string, statusId: PropTypes.string,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.record.isRequired,
}; };
state = { state = {

View file

@ -1,43 +1,5 @@
// @ts-check // @ts-check
/**
* @typedef Emoji
* @property {string} shortcode
* @property {string} static_url
* @property {string} url
*/
/**
* @typedef AccountField
* @property {string} name
* @property {string} value
* @property {string} verified_at
*/
/**
* @typedef Account
* @property {string} acct
* @property {string} avatar
* @property {string} avatar_static
* @property {boolean} bot
* @property {string} created_at
* @property {boolean=} discoverable
* @property {string} display_name
* @property {Emoji[]} emojis
* @property {AccountField[]} fields
* @property {number} followers_count
* @property {number} following_count
* @property {boolean} group
* @property {string} header
* @property {string} header_static
* @property {string} id
* @property {string=} last_status_at
* @property {boolean} locked
* @property {string} note
* @property {number} statuses_count
* @property {string} url
* @property {string} username
*/
/** /**
* @typedef {[code: string, name: string, localName: string]} InitialStateLanguage * @typedef {[code: string, name: string, localName: string]} InitialStateLanguage
@ -87,7 +49,7 @@
/** /**
* @typedef InitialState * @typedef InitialState
* @property {Record<string, Account>} accounts * @property {Record<string, import("./api_types/accounts").ApiAccountJSON>} accounts
* @property {InitialStateLanguage[]} languages * @property {InitialStateLanguage[]} languages
* @property {boolean=} critical_updates_pending * @property {boolean=} critical_updates_pending
* @property {InitialStateMeta} meta * @property {InitialStateMeta} meta

View file

@ -426,7 +426,7 @@
"notification.admin.sign_up": "{name} registrierte sich", "notification.admin.sign_up": "{name} registrierte sich",
"notification.favourite": "{name} favorisierte deinen Beitrag", "notification.favourite": "{name} favorisierte deinen Beitrag",
"notification.reaction": "{name} hat auf deinen Beitrag reagiert", "notification.reaction": "{name} hat auf deinen Beitrag reagiert",
"notification.follow": "{name} folgt dir jetzt", "notification.follow": "{name} folgt dir",
"notification.follow_request": "{name} möchte dir folgen", "notification.follow_request": "{name} möchte dir folgen",
"notification.mention": "{name} erwähnte dich", "notification.mention": "{name} erwähnte dich",
"notification.own_poll": "Deine Umfrage ist beendet", "notification.own_poll": "Deine Umfrage ist beendet",

View file

@ -1,3 +1,5 @@
import { isDevelopment } from 'mastodon/utils/environment';
export interface LocaleData { export interface LocaleData {
locale: string; locale: string;
messages: Record<string, string>; messages: Record<string, string>;
@ -11,7 +13,7 @@ export function setLocale(locale: LocaleData) {
export function getLocale(): LocaleData { export function getLocale(): LocaleData {
if (!loadedLocale) { if (!loadedLocale) {
if (process.env.NODE_ENV === 'development') { if (isDevelopment()) {
throw new Error('getLocale() called before any locale has been set'); throw new Error('getLocale() called before any locale has been set');
} else { } else {
return { locale: 'unknown', messages: {} }; return { locale: 'unknown', messages: {} };

View file

@ -202,7 +202,7 @@
"dismissable_banner.community_timeline": "אלו הם החצרוצים הציבוריים האחרונים מהמשתמשים על שרת {domain}.", "dismissable_banner.community_timeline": "אלו הם החצרוצים הציבוריים האחרונים מהמשתמשים על שרת {domain}.",
"dismissable_banner.dismiss": "בטל", "dismissable_banner.dismiss": "בטל",
"dismissable_banner.explore_links": "אלו הקישורים האחרונים ששותפו על ידי משתמשים ששרת זה רואה ברשת המבוזרת כרגע.", "dismissable_banner.explore_links": "אלו הקישורים האחרונים ששותפו על ידי משתמשים ששרת זה רואה ברשת המבוזרת כרגע.",
"dismissable_banner.explore_statuses": "ההודעות האלו, משרת זה ואחרים ברשת המבוזרת, צוברים חשיפה היום. הודעות חדשות יותר עם יותר הדהודים וחיבובים מדורגים יותר לגובה.", "dismissable_banner.explore_statuses": "אלו הודעות משרת זה ואחרים ברשת המבוזרת שצוברות חשיפה היום. הודעות חדשות יותר עם יותר הדהודים וחיבובים מדורגות גבוה יותר.",
"dismissable_banner.explore_tags": "התגיות האלו, משרת זה ואחרים ברשת המבוזרת, צוברות חשיפה כעת.", "dismissable_banner.explore_tags": "התגיות האלו, משרת זה ואחרים ברשת המבוזרת, צוברות חשיפה כעת.",
"dismissable_banner.public_timeline": "אלו ההודעות האחרונות שהתקבלו מהמשתמשים שנעקבים על ידי משתמשים מ־{domain}.", "dismissable_banner.public_timeline": "אלו ההודעות האחרונות שהתקבלו מהמשתמשים שנעקבים על ידי משתמשים מ־{domain}.",
"embed.instructions": "ניתן להטמיע את ההודעה הזו באתרך ע\"י העתקת הקוד שלהלן.", "embed.instructions": "ניתן להטמיע את ההודעה הזו באתרך ע\"י העתקת הקוד שלהלן.",
@ -315,7 +315,7 @@
"home.pending_critical_update.title": "יצא עדכון אבטחה חשוב!", "home.pending_critical_update.title": "יצא עדכון אבטחה חשוב!",
"home.show_announcements": "הצג הכרזות", "home.show_announcements": "הצג הכרזות",
"interaction_modal.description.favourite": "עם חשבון מסטודון, ניתן לחבב את ההודעה כדי לומר למחבר/ת שהערכת את תוכנו או כדי לשמור אותו לקריאה בעתיד.", "interaction_modal.description.favourite": "עם חשבון מסטודון, ניתן לחבב את ההודעה כדי לומר למחבר/ת שהערכת את תוכנו או כדי לשמור אותו לקריאה בעתיד.",
"interaction_modal.description.follow": "עם חשבון מסטודון, ניתן לעקוב אחרי {name} כדי לקבל את הםוסטים שלו/ה בפיד הבית.", "interaction_modal.description.follow": "עם חשבון מסטודון, ניתן לעקוב אחרי {name} כדי לקבל את הפוסטים שלו/ה בפיד הבית.",
"interaction_modal.description.reblog": "עם חשבון מסטודון, ניתן להדהד את החצרוץ ולשתף עם עוקבים.", "interaction_modal.description.reblog": "עם חשבון מסטודון, ניתן להדהד את החצרוץ ולשתף עם עוקבים.",
"interaction_modal.description.reply": "עם חשבון מסטודון, ניתן לענות לחצרוץ.", "interaction_modal.description.reply": "עם חשבון מסטודון, ניתן לענות לחצרוץ.",
"interaction_modal.login.action": "קח אותי לדף הבית", "interaction_modal.login.action": "קח אותי לדף הבית",
@ -349,7 +349,7 @@
"keyboard_shortcuts.hotkey": "מקש קיצור", "keyboard_shortcuts.hotkey": "מקש קיצור",
"keyboard_shortcuts.legend": "הצגת מקרא", "keyboard_shortcuts.legend": "הצגת מקרא",
"keyboard_shortcuts.local": "פתיחת ציר זמן קהילתי", "keyboard_shortcuts.local": "פתיחת ציר זמן קהילתי",
"keyboard_shortcuts.mention": "לאזכר את המחבר(ת)", "keyboard_shortcuts.mention": "לאזכר את המחבר",
"keyboard_shortcuts.muted": "פתיחת רשימת משתמשים מושתקים", "keyboard_shortcuts.muted": "פתיחת רשימת משתמשים מושתקים",
"keyboard_shortcuts.my_profile": "פתיחת הפרופיל שלך", "keyboard_shortcuts.my_profile": "פתיחת הפרופיל שלך",
"keyboard_shortcuts.notifications": "פתיחת טור התראות", "keyboard_shortcuts.notifications": "פתיחת טור התראות",
@ -493,7 +493,7 @@
"onboarding.steps.setup_profile.title": "התאמה אישית של הפרופיל", "onboarding.steps.setup_profile.title": "התאמה אישית של הפרופיל",
"onboarding.steps.share_profile.body": "ספרו לחברים איך למצוא אתכם במסטודון!", "onboarding.steps.share_profile.body": "ספרו לחברים איך למצוא אתכם במסטודון!",
"onboarding.steps.share_profile.title": "לשתף פרופיל", "onboarding.steps.share_profile.title": "לשתף פרופיל",
"onboarding.tips.2fa": "<strong>הידעת?</strong> ניתן לאבטח את החשבון ע\"י הקמת אימות בשני צעדים במסך מאפייני החשבון. השיטה תעבוד עם כל יישומון תואם TOTP על המגשיר שלך, אין צורך לתת לנו את מספר הטלפון!", "onboarding.tips.2fa": "<strong>הידעת?</strong> ניתן לאבטח את החשבון ע\"י הקמת אימות דו-שלבי במסך מאפייני החשבון. השיטה תעבוד עם כל יישומון תואם TOTP על המכשיר שלך, ללא צורך במספר טלפון!",
"onboarding.tips.accounts_from_other_servers": "<strong>הידעת?</strong> כיוון שמסטודון פועל ברשת מבוזרת, חלק מהפרופילים שתתקלו בהם פועלים משרתים אחרים משרת הבית שלכם. ניתן להיות איתם בקשר בצורה זהה לכל חשבון אחר! שם השרת שלהם הוא החלק השני של שם המשתמש שלהם!", "onboarding.tips.accounts_from_other_servers": "<strong>הידעת?</strong> כיוון שמסטודון פועל ברשת מבוזרת, חלק מהפרופילים שתתקלו בהם פועלים משרתים אחרים משרת הבית שלכם. ניתן להיות איתם בקשר בצורה זהה לכל חשבון אחר! שם השרת שלהם הוא החלק השני של שם המשתמש שלהם!",
"onboarding.tips.migration": "<strong>הידעת?</strong> אם תחליטו כי {domain} איננו שרת שמתאים לכם בעתיד, ניתן לעבור לשרת אחר מבלי לאבד עוקבים. תוכלו אפילו להקים שרת משלכן!", "onboarding.tips.migration": "<strong>הידעת?</strong> אם תחליטו כי {domain} איננו שרת שמתאים לכם בעתיד, ניתן לעבור לשרת אחר מבלי לאבד עוקבים. תוכלו אפילו להקים שרת משלכן!",
"onboarding.tips.verification": "<strong>הידעת?</strong> ניתן לאשרר את החשבון ע\"י קישור הפרופיל אל האתר שלכם ומהאתר חזרה לפרופיל. לא נדרשים תשלומים ומסמכים!", "onboarding.tips.verification": "<strong>הידעת?</strong> ניתן לאשרר את החשבון ע\"י קישור הפרופיל אל האתר שלכם ומהאתר חזרה לפרופיל. לא נדרשים תשלומים ומסמכים!",
@ -575,7 +575,7 @@
"report.thanks.title": "לא מעוניין/ת לראות את זה?", "report.thanks.title": "לא מעוניין/ת לראות את זה?",
"report.thanks.title_actionable": "תודה על הדיווח, נבדוק את העניין.", "report.thanks.title_actionable": "תודה על הדיווח, נבדוק את העניין.",
"report.unfollow": "הפסיקו לעקוב אחרי @{name}", "report.unfollow": "הפסיקו לעקוב אחרי @{name}",
"report.unfollow_explanation": "אתם עוקבים אחרי החשבון הזה. כדי להפסיק לראות את הפרסומים שלו בפיד הבית שלכם, הפסיקו לעקוב אחריהם.", "report.unfollow_explanation": "אתם עוקבים אחרי החשבון הזה. כדי להפסיק לראות את הפרסומים שלו בפיד הבית שלכם, הפסיקו לעקוב אחריו.",
"report_notification.attached_statuses": "{count, plural, one {הודעה מצורפת} two {הודעותיים מצורפות} many {{count} הודעות מצורפות} other {{count} הודעות מצורפות}}", "report_notification.attached_statuses": "{count, plural, one {הודעה מצורפת} two {הודעותיים מצורפות} many {{count} הודעות מצורפות} other {{count} הודעות מצורפות}}",
"report_notification.categories.legal": "חוקי", "report_notification.categories.legal": "חוקי",
"report_notification.categories.other": "שונות", "report_notification.categories.other": "שונות",
@ -630,7 +630,7 @@
"status.edited": "נערך ב{date}", "status.edited": "נערך ב{date}",
"status.edited_x_times": "נערך {count, plural, one {פעם {count}} other {{count} פעמים}}", "status.edited_x_times": "נערך {count, plural, one {פעם {count}} other {{count} פעמים}}",
"status.embed": "הטמעה", "status.embed": "הטמעה",
"status.favourite": "מחובבת", "status.favourite": "חיבוב",
"status.filter": "סנן הודעה זו", "status.filter": "סנן הודעה זו",
"status.filtered": "סונן", "status.filtered": "סונן",
"status.hide": "הסתרת חיצרוץ", "status.hide": "הסתרת חיצרוץ",
@ -707,7 +707,7 @@
"upload_modal.apply": "החל", "upload_modal.apply": "החל",
"upload_modal.applying": "מחיל…", "upload_modal.applying": "מחיל…",
"upload_modal.choose_image": "בחר/י תמונה", "upload_modal.choose_image": "בחר/י תמונה",
"upload_modal.description_placeholder": "דג סקרן שט בים מאוכזב ולפתע מצא חברה", "upload_modal.description_placeholder": "עטלף אבק נס דרך מזגן שהתפוצץ כי חם",
"upload_modal.detect_text": "זהה טקסט מתמונה", "upload_modal.detect_text": "זהה טקסט מתמונה",
"upload_modal.edit_media": "עריכת מדיה", "upload_modal.edit_media": "עריכת מדיה",
"upload_modal.hint": "הקליקי או גררי את המעגל על גבי התצוגה המקדימה על מנת לבחור בנקודת המוקד שתראה תמיד בכל התמונות הממוזערות.", "upload_modal.hint": "הקליקי או גררי את המעגל על גבי התצוגה המקדימה על מנת לבחור בנקודת המוקד שתראה תמיד בכל התמונות הממוזערות.",

View file

@ -1,5 +1,6 @@
{ {
"about.contact": "Kontakt:", "about.contact": "Kontakt:",
"about.domain_blocks.no_reason_available": "Razlog nije dostupan",
"account.account_note_header": "Bilješka", "account.account_note_header": "Bilješka",
"account.add_or_remove_from_list": "Dodaj ili ukloni s liste", "account.add_or_remove_from_list": "Dodaj ili ukloni s liste",
"account.badges.bot": "Bot", "account.badges.bot": "Bot",
@ -14,6 +15,7 @@
"account.edit_profile": "Uredi profil", "account.edit_profile": "Uredi profil",
"account.enable_notifications": "Obavjesti me kada @{name} napravi objavu", "account.enable_notifications": "Obavjesti me kada @{name} napravi objavu",
"account.endorse": "Istakni na profilu", "account.endorse": "Istakni na profilu",
"account.featured_tags.last_status_never": "Nema postova",
"account.follow": "Prati", "account.follow": "Prati",
"account.followers": "Pratitelji", "account.followers": "Pratitelji",
"account.followers.empty": "Nitko još ne prati korisnika/cu.", "account.followers.empty": "Nitko još ne prati korisnika/cu.",
@ -21,13 +23,18 @@
"account.following_counter": "{count, plural, one {{counter} praćeni} few{{counter} praćena} other {{counter} praćenih}}", "account.following_counter": "{count, plural, one {{counter} praćeni} few{{counter} praćena} other {{counter} praćenih}}",
"account.follows.empty": "Korisnik/ca još ne prati nikoga.", "account.follows.empty": "Korisnik/ca još ne prati nikoga.",
"account.follows_you": "Prati te", "account.follows_you": "Prati te",
"account.go_to_profile": "Idi na profil",
"account.hide_reblogs": "Sakrij boostove od @{name}", "account.hide_reblogs": "Sakrij boostove od @{name}",
"account.in_memoriam": "U sjećanje.",
"account.link_verified_on": "Vlasništvo ove poveznice provjereno je {date}", "account.link_verified_on": "Vlasništvo ove poveznice provjereno je {date}",
"account.locked_info": "Status privatnosti ovog računa postavljen je na zaključano. Vlasnik ručno pregledava tko ih može pratiti.", "account.locked_info": "Status privatnosti ovog računa postavljen je na zaključano. Vlasnik ručno pregledava tko ih može pratiti.",
"account.media": "Medijski sadržaj", "account.media": "Medijski sadržaj",
"account.mention": "Spomeni @{name}", "account.mention": "Spomeni @{name}",
"account.mute": "Utišaj @{name}", "account.mute": "Utišaj @{name}",
"account.mute_notifications_short": "Utišaj obavijesti",
"account.mute_short": "Utišaj",
"account.muted": "Utišano", "account.muted": "Utišano",
"account.open_original_page": "Otvori originalnu stranicu",
"account.posts": "Objave", "account.posts": "Objave",
"account.posts_with_replies": "Objave i odgovori", "account.posts_with_replies": "Objave i odgovori",
"account.report": "Prijavi @{name}", "account.report": "Prijavi @{name}",
@ -52,6 +59,7 @@
"alert.unexpected.title": "Ups!", "alert.unexpected.title": "Ups!",
"announcement.announcement": "Najava", "announcement.announcement": "Najava",
"attachments_list.unprocessed": "(neobrađeno)", "attachments_list.unprocessed": "(neobrađeno)",
"audio.hide": "Sakrij audio",
"autosuggest_hashtag.per_week": "{count} tjedno", "autosuggest_hashtag.per_week": "{count} tjedno",
"boost_modal.combo": "Možete pritisnuti {combo} kako biste preskočili ovo sljedeći put", "boost_modal.combo": "Možete pritisnuti {combo} kako biste preskočili ovo sljedeći put",
"bundle_column_error.error.title": "Oh, ne!", "bundle_column_error.error.title": "Oh, ne!",
@ -66,6 +74,7 @@
"column.community": "Lokalna vremenska crta", "column.community": "Lokalna vremenska crta",
"column.directory": "Pregledavanje profila", "column.directory": "Pregledavanje profila",
"column.domain_blocks": "Blokirane domene", "column.domain_blocks": "Blokirane domene",
"column.favourites": "Favoriti",
"column.follow_requests": "Zahtjevi za praćenje", "column.follow_requests": "Zahtjevi za praćenje",
"column.home": "Početna", "column.home": "Početna",
"column.lists": "Liste", "column.lists": "Liste",
@ -86,6 +95,8 @@
"community.column_settings.remote_only": "Samo udaljeno", "community.column_settings.remote_only": "Samo udaljeno",
"compose.language.change": "Promijeni jezik", "compose.language.change": "Promijeni jezik",
"compose.language.search": "Pretraži jezike...", "compose.language.search": "Pretraži jezike...",
"compose.published.open": "Otvori",
"compose.saved.body": "Post spremljen.",
"compose_form.direct_message_warning_learn_more": "Saznajte više", "compose_form.direct_message_warning_learn_more": "Saznajte više",
"compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.", "compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.", "compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",
@ -179,6 +190,8 @@
"errors.unexpected_crash.copy_stacktrace": "Kopiraj stacktrace u međuspremnik", "errors.unexpected_crash.copy_stacktrace": "Kopiraj stacktrace u međuspremnik",
"errors.unexpected_crash.report_issue": "Prijavi problem", "errors.unexpected_crash.report_issue": "Prijavi problem",
"explore.search_results": "Rezultati pretrage", "explore.search_results": "Rezultati pretrage",
"explore.suggested_follows": "Ljudi",
"explore.title": "Pretraži",
"explore.trending_links": "Novosti", "explore.trending_links": "Novosti",
"explore.trending_statuses": "Objave", "explore.trending_statuses": "Objave",
"explore.trending_tags": "Hashtagovi", "explore.trending_tags": "Hashtagovi",
@ -189,12 +202,17 @@
"filter_modal.select_filter.subtitle": "Odaberite postojeću kategoriju ili stvorite novu", "filter_modal.select_filter.subtitle": "Odaberite postojeću kategoriju ili stvorite novu",
"filter_modal.select_filter.title": "Filtriraj ovu objavu", "filter_modal.select_filter.title": "Filtriraj ovu objavu",
"filter_modal.title.status": "Filtriraj objavu", "filter_modal.title.status": "Filtriraj objavu",
"firehose.all": "Sve",
"firehose.local": "Ovaj server",
"follow_request.authorize": "Autoriziraj", "follow_request.authorize": "Autoriziraj",
"follow_request.reject": "Odbij", "follow_request.reject": "Odbij",
"footer.about": "O aplikaciji",
"footer.get_app": "Preuzmi aplikaciju", "footer.get_app": "Preuzmi aplikaciju",
"footer.invite": "Pozovi ljude",
"footer.keyboard_shortcuts": "Tipkovni prečaci", "footer.keyboard_shortcuts": "Tipkovni prečaci",
"footer.privacy_policy": "Pravila o zaštiti privatnosti", "footer.privacy_policy": "Pravila o zaštiti privatnosti",
"footer.source_code": "Prikaz izvornog koda", "footer.source_code": "Prikaz izvornog koda",
"footer.status": "Stanje",
"generic.saved": "Spremljeno", "generic.saved": "Spremljeno",
"getting_started.heading": "Počnimo", "getting_started.heading": "Počnimo",
"hashtag.column_header.tag_mode.all": "i {additional}", "hashtag.column_header.tag_mode.all": "i {additional}",
@ -212,7 +230,11 @@
"home.column_settings.show_reblogs": "Pokaži boostove", "home.column_settings.show_reblogs": "Pokaži boostove",
"home.column_settings.show_replies": "Pokaži odgovore", "home.column_settings.show_replies": "Pokaži odgovore",
"home.hide_announcements": "Sakrij najave", "home.hide_announcements": "Sakrij najave",
"home.pending_critical_update.title": "Dostupno je kritično sigurnosno ažuriranje!",
"home.show_announcements": "Prikaži najave", "home.show_announcements": "Prikaži najave",
"interaction_modal.login.action": "Odvedi me kući",
"interaction_modal.no_account_yet": "Nisi na Mastodonu?",
"interaction_modal.on_this_server": "Na ovom serveru",
"intervals.full.days": "{number, plural, one {# dan} other {# dana}}", "intervals.full.days": "{number, plural, one {# dan} other {# dana}}",
"intervals.full.hours": "{number, plural, one {# sat} few {# sata} other {# sati}}", "intervals.full.hours": "{number, plural, one {# sat} few {# sata} other {# sati}}",
"intervals.full.minutes": "{number, plural, one {# minuta} few {# minute} other {# minuta}}", "intervals.full.minutes": "{number, plural, one {# minuta} few {# minute} other {# minuta}}",

View file

@ -2,12 +2,14 @@ import { useEffect, useState } from 'react';
import { IntlProvider as BaseIntlProvider } from 'react-intl'; import { IntlProvider as BaseIntlProvider } from 'react-intl';
import { isProduction } from 'mastodon/utils/environment';
import { getLocale, isLocaleLoaded } from './global_locale'; import { getLocale, isLocaleLoaded } from './global_locale';
import { loadLocale } from './load_locale'; import { loadLocale } from './load_locale';
function onProviderError(error: unknown) { function onProviderError(error: unknown) {
// Silent the error, like upstream does // Silent the error, like upstream does
if (process.env.NODE_ENV === 'production') return; if (isProduction()) return;
// This browser does not advertise Intl support for this locale, we only print a warning // This browser does not advertise Intl support for this locale, we only print a warning
// As-per the spec, the browser should select the best matching locale // As-per the spec, the browser should select the best matching locale

View file

@ -137,7 +137,7 @@
"compose.language.search": "Cari bahasa...", "compose.language.search": "Cari bahasa...",
"compose.published.body": "Pos telah diterbitkan.", "compose.published.body": "Pos telah diterbitkan.",
"compose.published.open": "Buka", "compose.published.open": "Buka",
"compose.saved.body": "Pos disimpan.", "compose.saved.body": "Kiriman disimpan.",
"compose_form.direct_message_warning_learn_more": "Ketahui lebih lanjut", "compose_form.direct_message_warning_learn_more": "Ketahui lebih lanjut",
"compose_form.encryption_warning": "Hantaran pada Mastodon tidak disulitkan hujung ke hujung. Jangan berkongsi sebarang maklumat sensitif melalui Mastodon.", "compose_form.encryption_warning": "Hantaran pada Mastodon tidak disulitkan hujung ke hujung. Jangan berkongsi sebarang maklumat sensitif melalui Mastodon.",
"compose_form.hashtag_warning": "Hantaran ini tidak akan disenaraikan di bawah mana-mana tanda pagar kerana ia tidak tersenarai. Hanya hantaran awam sahaja boleh dicari menggunakan tanda pagar.", "compose_form.hashtag_warning": "Hantaran ini tidak akan disenaraikan di bawah mana-mana tanda pagar kerana ia tidak tersenarai. Hanya hantaran awam sahaja boleh dicari menggunakan tanda pagar.",
@ -307,6 +307,9 @@
"home.explore_prompt.body": "Suapan rumah anda akan mempunyai gabungan pos daripada hashtag yang telah anda pilih untuk diikuti, orang yang telah anda pilih untuk diikuti dan pos yang mereka tingkatkan. Jika itu terasa terlalu senyap, anda mungkin mahu:", "home.explore_prompt.body": "Suapan rumah anda akan mempunyai gabungan pos daripada hashtag yang telah anda pilih untuk diikuti, orang yang telah anda pilih untuk diikuti dan pos yang mereka tingkatkan. Jika itu terasa terlalu senyap, anda mungkin mahu:",
"home.explore_prompt.title": "Ini adalah pusat operasi anda dalam Mastodon.", "home.explore_prompt.title": "Ini adalah pusat operasi anda dalam Mastodon.",
"home.hide_announcements": "Sembunyikan pengumuman", "home.hide_announcements": "Sembunyikan pengumuman",
"home.pending_critical_update.body": "Sila kemas kini pelayan Mastodon anda secepat yang mungkin!",
"home.pending_critical_update.link": "Lihat pengemaskinian",
"home.pending_critical_update.title": "Kemas kini keselamatan kritikal tersedia!",
"home.show_announcements": "Tunjukkan pengumuman", "home.show_announcements": "Tunjukkan pengumuman",
"interaction_modal.description.favourite": "Dengan akaun di Mastodon, anda boleh menggemari pos ini untuk memberitahu pengarang anda menghargainya dan menyimpannya untuk kemudian.", "interaction_modal.description.favourite": "Dengan akaun di Mastodon, anda boleh menggemari pos ini untuk memberitahu pengarang anda menghargainya dan menyimpannya untuk kemudian.",
"interaction_modal.description.follow": "Dengan akaun pada Mastodon, anda boleh mengikut {name} untuk menerima hantaran mereka di suapan rumah anda.", "interaction_modal.description.follow": "Dengan akaun pada Mastodon, anda boleh mengikut {name} untuk menerima hantaran mereka di suapan rumah anda.",
@ -408,6 +411,7 @@
"navigation_bar.lists": "Senarai", "navigation_bar.lists": "Senarai",
"navigation_bar.logout": "Log keluar", "navigation_bar.logout": "Log keluar",
"navigation_bar.mutes": "Pengguna yang dibisukan", "navigation_bar.mutes": "Pengguna yang dibisukan",
"navigation_bar.opened_in_classic_interface": "Kiriman, akaun dan halaman tertentu yang lain dibuka secara lalai di antara muka web klasik.",
"navigation_bar.personal": "Peribadi", "navigation_bar.personal": "Peribadi",
"navigation_bar.pins": "Hantaran disemat", "navigation_bar.pins": "Hantaran disemat",
"navigation_bar.preferences": "Keutamaan", "navigation_bar.preferences": "Keutamaan",
@ -583,6 +587,7 @@
"search.quick_action.open_url": "Buka URL dalam Mastadon", "search.quick_action.open_url": "Buka URL dalam Mastadon",
"search.quick_action.status_search": "Pos sepadan {x}", "search.quick_action.status_search": "Pos sepadan {x}",
"search.search_or_paste": "Cari atau tampal URL", "search.search_or_paste": "Cari atau tampal URL",
"search_popout.full_text_search_disabled_message": "Tidak tersedia di {domain}.",
"search_popout.language_code": "Kod bahasa ISO", "search_popout.language_code": "Kod bahasa ISO",
"search_popout.options": "Pilihan carian", "search_popout.options": "Pilihan carian",
"search_popout.quick_actions": "Tindakan pantas", "search_popout.quick_actions": "Tindakan pantas",

View file

@ -61,12 +61,12 @@
"account.requested_follow": "{name} vam želi slediti", "account.requested_follow": "{name} vam želi slediti",
"account.share": "Deli profil osebe @{name}", "account.share": "Deli profil osebe @{name}",
"account.show_reblogs": "Pokaži izpostavitve osebe @{name}", "account.show_reblogs": "Pokaži izpostavitve osebe @{name}",
"account.statuses_counter": "{count, plural, one {{count} tut} two {{count} tuta} few {{count} tuti} other {{count} tutov}}", "account.statuses_counter": "{count, plural, one {{count} objava} two {{count} objavi} few {{count} objave} other {{count} objav}}",
"account.unblock": "Odblokiraj @{name}", "account.unblock": "Odblokiraj @{name}",
"account.unblock_domain": "Odblokiraj domeno {domain}", "account.unblock_domain": "Odblokiraj domeno {domain}",
"account.unblock_short": "Odblokiraj", "account.unblock_short": "Odblokiraj",
"account.unendorse": "Ne vključi v profil", "account.unendorse": "Ne vključi v profil",
"account.unfollow": "Prenehaj slediti", "account.unfollow": "Ne sledi več",
"account.unmute": "Odtišaj @{name}", "account.unmute": "Odtišaj @{name}",
"account.unmute_notifications_short": "Izklopi utišanje obvestil", "account.unmute_notifications_short": "Izklopi utišanje obvestil",
"account.unmute_short": "Odtišaj", "account.unmute_short": "Odtišaj",
@ -185,7 +185,7 @@
"confirmations.redraft.message": "Ali ste prepričani, da želite izbrisati ta status in ga preoblikovati? Vzljubi in izpostavitve bodo izgubljeni, odgovori na izvirno objavo pa bodo osiroteli.", "confirmations.redraft.message": "Ali ste prepričani, da želite izbrisati ta status in ga preoblikovati? Vzljubi in izpostavitve bodo izgubljeni, odgovori na izvirno objavo pa bodo osiroteli.",
"confirmations.reply.confirm": "Odgovori", "confirmations.reply.confirm": "Odgovori",
"confirmations.reply.message": "Odgovarjanje bo prepisalo sporočilo, ki ga trenutno sestavljate. Ali ste prepričani, da želite nadaljevati?", "confirmations.reply.message": "Odgovarjanje bo prepisalo sporočilo, ki ga trenutno sestavljate. Ali ste prepričani, da želite nadaljevati?",
"confirmations.unfollow.confirm": "Prenehaj slediti", "confirmations.unfollow.confirm": "Ne sledi več",
"confirmations.unfollow.message": "Ali ste prepričani, da ne želite več slediti {name}?", "confirmations.unfollow.message": "Ali ste prepričani, da ne želite več slediti {name}?",
"conversation.delete": "Izbriši pogovor", "conversation.delete": "Izbriši pogovor",
"conversation.mark_as_read": "Označi kot prebrano", "conversation.mark_as_read": "Označi kot prebrano",
@ -301,7 +301,7 @@
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} objava} two {{counter} objavi} few {{counter} objav} other {{counter} objav}}", "hashtag.counter_by_uses_today": "{count, plural, one {{counter} objava} two {{counter} objavi} few {{counter} objav} other {{counter} objav}}",
"hashtag.follow": "Sledi ključniku", "hashtag.follow": "Sledi ključniku",
"hashtag.unfollow": "Nehaj slediti ključniku", "hashtag.unfollow": "Nehaj slediti ključniku",
"hashtags.and_other": "…and {count, plural, one {} two {# več} few {# več}other {# več}}", "hashtags.and_other": "…in še {count, plural, other {#}}",
"home.actions.go_to_explore": "Poglejte, kaj je v trendu", "home.actions.go_to_explore": "Poglejte, kaj je v trendu",
"home.actions.go_to_suggestions": "Poiščite osebe, ki jim želite slediti", "home.actions.go_to_suggestions": "Poiščite osebe, ki jim želite slediti",
"home.column_settings.basic": "Osnovno", "home.column_settings.basic": "Osnovno",

View file

@ -7,6 +7,8 @@ import * as perf from 'mastodon/performance';
import ready from 'mastodon/ready'; import ready from 'mastodon/ready';
import { store } from 'mastodon/store'; import { store } from 'mastodon/store';
import { isProduction } from './utils/environment';
/** /**
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
@ -21,7 +23,7 @@ function main() {
root.render(<Mastodon {...props} />); root.render(<Mastodon {...props} />);
store.dispatch(setupBrowserNotifications()); store.dispatch(setupBrowserNotifications());
if (process.env.NODE_ENV === 'production' && me && 'serviceWorker' in navigator) { if (isProduction() && me && 'serviceWorker' in navigator) {
const { Workbox } = await import('workbox-window'); const { Workbox } = await import('workbox-window');
const wb = new Workbox('/sw.js'); const wb = new Workbox('/sw.js');
/** @type {ServiceWorkerRegistration} */ /** @type {ServiceWorkerRegistration} */

View file

@ -0,0 +1,149 @@
import type { RecordOf } from 'immutable';
import { List, Record as ImmutableRecord } from 'immutable';
import escapeTextContentForBrowser from 'escape-html';
import type {
ApiAccountFieldJSON,
ApiAccountRoleJSON,
ApiAccountJSON,
} from 'mastodon/api_types/accounts';
import type { ApiCustomEmojiJSON } from 'mastodon/api_types/custom_emoji';
import emojify from 'mastodon/features/emoji/emoji';
import { unescapeHTML } from 'mastodon/utils/html';
import { CustomEmojiFactory } from './custom_emoji';
import type { CustomEmoji } from './custom_emoji';
// AccountField
interface AccountFieldShape extends Required<ApiAccountFieldJSON> {
name_emojified: string;
value_emojified: string;
value_plain: string | null;
}
type AccountField = RecordOf<AccountFieldShape>;
const AccountFieldFactory = ImmutableRecord<AccountFieldShape>({
name: '',
value: '',
verified_at: null,
name_emojified: '',
value_emojified: '',
value_plain: null,
});
// AccountRole
export type AccountRoleShape = ApiAccountRoleJSON;
export type AccountRole = RecordOf<AccountRoleShape>;
const AccountRoleFactory = ImmutableRecord<AccountRoleShape>({
color: '',
id: '',
name: '',
});
// Account
export interface AccountShape
extends Required<
Omit<ApiAccountJSON, 'emojis' | 'fields' | 'roles' | 'moved'>
> {
emojis: List<CustomEmoji>;
fields: List<AccountField>;
roles: List<AccountRole>;
display_name_html: string;
note_emojified: string;
note_plain: string | null;
hidden: boolean;
moved: string | null;
}
export type Account = RecordOf<AccountShape>;
export const accountDefaultValues: AccountShape = {
acct: '',
avatar: '',
avatar_static: '',
bot: false,
created_at: '',
discoverable: false,
display_name: '',
display_name_html: '',
emojis: List<CustomEmoji>(),
fields: List<AccountField>(),
group: false,
header: '',
header_static: '',
id: '',
last_status_at: '',
locked: false,
noindex: false,
note: '',
note_emojified: '',
note_plain: 'string',
roles: List<AccountRole>(),
uri: '',
url: '',
username: '',
followers_count: 0,
following_count: 0,
statuses_count: 0,
hidden: false,
suspended: false,
memorial: false,
limited: false,
moved: null,
};
const AccountFactory = ImmutableRecord<AccountShape>(accountDefaultValues);
type EmojiMap = Record<string, ApiCustomEmojiJSON>;
function makeEmojiMap(emojis: ApiCustomEmojiJSON[]) {
return emojis.reduce<EmojiMap>((obj, emoji) => {
obj[`:${emoji.shortcode}:`] = emoji;
return obj;
}, {});
}
function createAccountField(
jsonField: ApiAccountFieldJSON,
emojiMap: EmojiMap,
) {
return AccountFieldFactory({
...jsonField,
name_emojified: emojify(
escapeTextContentForBrowser(jsonField.name),
emojiMap,
),
value_emojified: emojify(jsonField.value, emojiMap),
value_plain: unescapeHTML(jsonField.value),
});
}
export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) {
const { moved, ...accountJSON } = serverJSON;
const emojiMap = makeEmojiMap(accountJSON.emojis);
const displayName =
accountJSON.display_name.trim().length === 0
? accountJSON.username
: accountJSON.display_name;
return AccountFactory({
...accountJSON,
moved: moved?.id,
fields: List(
serverJSON.fields.map((field) => createAccountField(field, emojiMap)),
),
emojis: List(serverJSON.emojis.map((emoji) => CustomEmojiFactory(emoji))),
roles: List(serverJSON.roles?.map((role) => AccountRoleFactory(role))),
display_name_html: emojify(
escapeTextContentForBrowser(displayName),
emojiMap,
),
note_emojified: emojify(accountJSON.note, emojiMap),
note_plain: unescapeHTML(accountJSON.note),
});
}

View file

@ -0,0 +1,15 @@
import type { RecordOf } from 'immutable';
import { Record } from 'immutable';
import type { ApiCustomEmojiJSON } from 'mastodon/api_types/custom_emoji';
type CustomEmojiShape = Required<ApiCustomEmojiJSON>; // no changes from server shape
export type CustomEmoji = RecordOf<CustomEmojiShape>;
export const CustomEmojiFactory = Record<CustomEmojiShape>({
shortcode: '',
static_url: '',
url: '',
category: '',
visible_in_picker: false,
});

View file

@ -0,0 +1,29 @@
import type { RecordOf } from 'immutable';
import { Record } from 'immutable';
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
type RelationshipShape = Required<ApiRelationshipJSON>; // no changes from server shape
export type Relationship = RecordOf<RelationshipShape>;
const RelationshipFactory = Record<RelationshipShape>({
blocked_by: false,
blocking: false,
domain_blocking: false,
endorsed: false,
followed_by: false,
following: false,
id: '',
languages: null,
muting_notifications: false,
muting: false,
note: '',
notifying: false,
requested_by: false,
requested: false,
showing_reblogs: false,
});
export function createRelationship(attributes: Partial<RelationshipShape>) {
return RelationshipFactory(attributes);
}

View file

@ -5,7 +5,9 @@
import * as marky from 'marky'; import * as marky from 'marky';
if (process.env.NODE_ENV === 'development') { import { isDevelopment } from './utils/environment';
if (isDevelopment()) {
if (typeof performance !== 'undefined' && performance.setResourceTimingBufferSize) { if (typeof performance !== 'undefined' && performance.setResourceTimingBufferSize) {
// Increase Firefox's performance entry limit; otherwise it's capped to 150. // Increase Firefox's performance entry limit; otherwise it's capped to 150.
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1331135 // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1331135
@ -18,13 +20,13 @@ if (process.env.NODE_ENV === 'development') {
} }
export function start(name) { export function start(name) {
if (process.env.NODE_ENV === 'development') { if (isDevelopment()) {
marky.mark(name); marky.mark(name);
} }
} }
export function stop(name) { export function stop(name) {
if (process.env.NODE_ENV === 'development') { if (isDevelopment()) {
marky.stop(name); marky.stop(name);
} }
} }

View file

@ -1,30 +0,0 @@
import 'core-js/features/object/assign';
import 'core-js/features/object/values';
import 'core-js/features/symbol';
import 'core-js/features/promise/finally';
import { decode as decodeBase64 } from '../utils/base64';
if (!Object.hasOwn(HTMLCanvasElement.prototype, 'toBlob')) {
const BASE64_MARKER = ';base64,';
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
value: function (
this: HTMLCanvasElement,
callback: BlobCallback,
type = 'image/png',
quality: unknown,
) {
const dataURL: string = this.toDataURL(type, quality);
let data;
if (dataURL.includes(BASE64_MARKER)) {
const [, base64] = dataURL.split(BASE64_MARKER);
data = decodeBase64(base64);
} else {
[, data] = dataURL.split(',');
}
callback(new Blob([data], { type }));
},
});
}

View file

@ -1,2 +1 @@
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only';
import 'requestidlecallback'; import 'requestidlecallback';

View file

@ -4,39 +4,18 @@
import { loadIntlPolyfills } from './intl'; import { loadIntlPolyfills } from './intl';
function importBasePolyfills() {
return import(/* webpackChunkName: "base_polyfills" */ './base_polyfills');
}
function importExtraPolyfills() { function importExtraPolyfills() {
return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills'); return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills');
} }
export function loadPolyfills() { export function loadPolyfills() {
const needsBasePolyfills = !( // Safari does not have requestIdleCallback.
'toBlob' in HTMLCanvasElement.prototype &&
'assign' in Object &&
'values' in Object &&
'Symbol' in window &&
'finally' in Promise.prototype
);
// Latest version of Firefox and Safari do not have IntersectionObserver.
// Edge does not have requestIdleCallback.
// This avoids shipping them all the polyfills. // This avoids shipping them all the polyfills.
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- those properties might not exist in old browsers, even if they are always here in types */ const needsExtraPolyfills = !window.requestIdleCallback;
const needsExtraPolyfills = !(
window.AbortController &&
window.IntersectionObserver &&
window.IntersectionObserverEntry &&
'isIntersecting' in IntersectionObserverEntry.prototype &&
window.requestIdleCallback
);
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
return Promise.all([ return Promise.all([
loadIntlPolyfills(), loadIntlPolyfills(),
needsBasePolyfills && importBasePolyfills(), // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- those properties might not exist in old browsers, even if they are always here in types
needsExtraPolyfills && importExtraPolyfills(), needsExtraPolyfills && importExtraPolyfills(),
]); ]);
} }

View file

@ -1,39 +0,0 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import { ACCOUNT_REVEAL } from 'mastodon/actions/accounts';
import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from 'mastodon/actions/importer';
const initialState = ImmutableMap();
const normalizeAccount = (state, account) => {
account = { ...account };
delete account.followers_count;
delete account.following_count;
delete account.statuses_count;
account.hidden = state.getIn([account.id, 'hidden']) === false ? false : account.limited;
return state.set(account.id, fromJS(account));
};
const normalizeAccounts = (state, accounts) => {
accounts.forEach(account => {
state = normalizeAccount(state, account);
});
return state;
};
export default function accounts(state = initialState, action) {
switch(action.type) {
case ACCOUNT_IMPORT:
return normalizeAccount(state, action.account);
case ACCOUNTS_IMPORT:
return normalizeAccounts(state, action.accounts);
case ACCOUNT_REVEAL:
return state.setIn([action.id, 'hidden'], false);
default:
return state;
}
}

View file

@ -0,0 +1,84 @@
import { Map as ImmutableMap } from 'immutable';
import type { Reducer } from 'redux';
import {
followAccountSuccess,
unfollowAccountSuccess,
importAccounts,
revealAccount,
} from 'mastodon/actions/accounts_typed';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import { me } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account';
import { createAccountFromServerJSON } from 'mastodon/models/account';
const initialState = ImmutableMap<string, Account>();
const normalizeAccount = (
state: typeof initialState,
account: ApiAccountJSON,
) => {
return state.set(
account.id,
createAccountFromServerJSON(account).set(
'hidden',
state.get(account.id)?.hidden === false
? false
: account.limited || false,
),
);
};
const normalizeAccounts = (
state: typeof initialState,
accounts: ApiAccountJSON[],
) => {
accounts.forEach((account) => {
state = normalizeAccount(state, account);
});
return state;
};
function getCurrentUser() {
if (!me)
throw new Error(
'No current user (me) defined when calling `accountsReducer`',
);
return me;
}
export const accountsReducer: Reducer<typeof initialState> = (
state = initialState,
action,
) => {
if (revealAccount.match(action))
return state.setIn([action.payload.id, 'hidden'], false);
else if (importAccounts.match(action))
return normalizeAccounts(state, action.payload.accounts);
else if (followAccountSuccess.match(action)) {
return state
.update(
action.payload.relationship.id,
(account) => account?.update('followers_count', (n) => n + 1),
)
.update(
getCurrentUser(),
(account) => account?.update('following_count', (n) => n + 1),
);
} else if (unfollowAccountSuccess.match(action))
return state
.update(
action.payload.relationship.id,
(account) =>
account?.update('followers_count', (n) => Math.max(0, n - 1)),
)
.update(
getCurrentUser(),
(account) =>
account?.update('following_count', (n) => Math.max(0, n - 1)),
);
else return state;
};

View file

@ -1,49 +0,0 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import { me } from 'mastodon/initial_state';
import {
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_UNFOLLOW_SUCCESS,
} from '../actions/accounts';
import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer';
const normalizeAccount = (state, account) => state.set(account.id, fromJS({
followers_count: account.followers_count,
following_count: account.following_count,
statuses_count: account.statuses_count,
}));
const normalizeAccounts = (state, accounts) => {
accounts.forEach(account => {
state = normalizeAccount(state, account);
});
return state;
};
const incrementFollowers = (state, accountId) =>
state.updateIn([accountId, 'followers_count'], num => num + 1)
.updateIn([me, 'following_count'], num => num + 1);
const decrementFollowers = (state, accountId) =>
state.updateIn([accountId, 'followers_count'], num => Math.max(0, num - 1))
.updateIn([me, 'following_count'], num => Math.max(0, num - 1));
const initialState = ImmutableMap();
export default function accountsCounters(state = initialState, action) {
switch(action.type) {
case ACCOUNT_IMPORT:
return normalizeAccount(state, action.account);
case ACCOUNTS_IMPORT:
return normalizeAccounts(state, action.accounts);
case ACCOUNT_FOLLOW_SUCCESS:
return action.alreadyFollowing ? state :
incrementFollowers(state, action.relationship.id);
case ACCOUNT_UNFOLLOW_SUCCESS:
return decrementFollowers(state, action.relationship.id);
default:
return state;
}
}

View file

@ -1,7 +1,7 @@
import { Map as ImmutableMap } from 'immutable'; import { Map as ImmutableMap } from 'immutable';
import { ACCOUNT_LOOKUP_FAIL } from '../actions/accounts'; import { ACCOUNT_LOOKUP_FAIL } from '../actions/accounts';
import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer'; import { importAccounts } from '../actions/accounts_typed';
export const normalizeForLookup = str => str.toLowerCase(); export const normalizeForLookup = str => str.toLowerCase();
@ -11,10 +11,8 @@ export default function accountsMap(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ACCOUNT_LOOKUP_FAIL: case ACCOUNT_LOOKUP_FAIL:
return action.error?.response?.status === 404 ? state.set(normalizeForLookup(action.acct), null) : state; return action.error?.response?.status === 404 ? state.set(normalizeForLookup(action.acct), null) : state;
case ACCOUNT_IMPORT: case importAccounts.type:
return state.set(normalizeForLookup(action.account.acct), action.account.id); return state.withMutations(map => action.payload.accounts.forEach(account => map.set(normalizeForLookup(account.acct), account.id)));
case ACCOUNTS_IMPORT:
return state.withMutations(map => action.accounts.forEach(account => map.set(normalizeForLookup(account.acct), account.id)));
default: default:
return state; return state;
} }

View file

@ -1,8 +1,8 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { import {
ACCOUNT_BLOCK_SUCCESS, blockAccountSuccess,
ACCOUNT_MUTE_SUCCESS, muteAccountSuccess,
} from '../actions/accounts'; } from '../actions/accounts';
import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses'; import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
import { TIMELINE_DELETE, TIMELINE_UPDATE } from '../actions/timelines'; import { TIMELINE_DELETE, TIMELINE_UPDATE } from '../actions/timelines';
@ -92,9 +92,9 @@ const updateContext = (state, status) => {
export default function replies(state = initialState, action) { export default function replies(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ACCOUNT_BLOCK_SUCCESS: case blockAccountSuccess.type:
case ACCOUNT_MUTE_SUCCESS: case muteAccountSuccess.type:
return filterContexts(state, action.relationship, action.statuses); return filterContexts(state, action.payload.relationship, action.payload.statuses);
case CONTEXT_FETCH_SUCCESS: case CONTEXT_FETCH_SUCCESS:
return normalizeContext(state, action.id, action.ancestors, action.descendants); return normalizeContext(state, action.id, action.ancestors, action.descendants);
case TIMELINE_DELETE: case TIMELINE_DELETE:

View file

@ -1,7 +1,7 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts'; import { blockAccountSuccess, muteAccountSuccess } from 'mastodon/actions/accounts';
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks'; import { blockDomainSuccess } from 'mastodon/actions/domain_blocks';
import { import {
CONVERSATIONS_MOUNT, CONVERSATIONS_MOUNT,
@ -105,11 +105,11 @@ export default function conversations(state = initialState, action) {
return item; return item;
})); }));
case ACCOUNT_BLOCK_SUCCESS: case blockAccountSuccess.type:
case ACCOUNT_MUTE_SUCCESS: case muteAccountSuccess.type:
return filterConversations(state, [action.relationship.id]); return filterConversations(state, [action.payload.relationship.id]);
case DOMAIN_BLOCK_SUCCESS: case blockDomainSuccess.type:
return filterConversations(state, action.accounts); return filterConversations(state, action.payload.accounts);
case CONVERSATIONS_DELETE_SUCCESS: case CONVERSATIONS_DELETE_SUCCESS:
return state.update('items', list => list.filterNot(item => item.get('id') === action.id)); return state.update('items', list => list.filterNot(item => item.get('id') === action.id));
default: default:

View file

@ -3,7 +3,7 @@ import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutabl
import { import {
DOMAIN_BLOCKS_FETCH_SUCCESS, DOMAIN_BLOCKS_FETCH_SUCCESS,
DOMAIN_BLOCKS_EXPAND_SUCCESS, DOMAIN_BLOCKS_EXPAND_SUCCESS,
DOMAIN_UNBLOCK_SUCCESS, unblockDomainSuccess
} from '../actions/domain_blocks'; } from '../actions/domain_blocks';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
@ -18,8 +18,8 @@ export default function domainLists(state = initialState, action) {
return state.setIn(['blocks', 'items'], ImmutableOrderedSet(action.domains)).setIn(['blocks', 'next'], action.next); return state.setIn(['blocks', 'items'], ImmutableOrderedSet(action.domains)).setIn(['blocks', 'next'], action.next);
case DOMAIN_BLOCKS_EXPAND_SUCCESS: case DOMAIN_BLOCKS_EXPAND_SUCCESS:
return state.updateIn(['blocks', 'items'], set => set.union(action.domains)).setIn(['blocks', 'next'], action.next); return state.updateIn(['blocks', 'items'], set => set.union(action.domains)).setIn(['blocks', 'next'], action.next);
case DOMAIN_UNBLOCK_SUCCESS: case unblockDomainSuccess.type:
return state.updateIn(['blocks', 'items'], set => set.delete(action.domain)); return state.updateIn(['blocks', 'items'], set => set.delete(action.payload.domain));
default: default:
return state; return state;
} }

View file

@ -3,8 +3,7 @@ import { Record as ImmutableRecord } from 'immutable';
import { loadingBarReducer } from 'react-redux-loading-bar'; import { loadingBarReducer } from 'react-redux-loading-bar';
import { combineReducers } from 'redux-immutable'; import { combineReducers } from 'redux-immutable';
import accounts from './accounts'; import { accountsReducer } from './accounts';
import accounts_counters from './accounts_counters';
import accounts_map from './accounts_map'; import accounts_map from './accounts_map';
import alerts from './alerts'; import alerts from './alerts';
import announcements from './announcements'; import announcements from './announcements';
@ -32,7 +31,7 @@ import notifications from './notifications';
import picture_in_picture from './picture_in_picture'; import picture_in_picture from './picture_in_picture';
import polls from './polls'; import polls from './polls';
import push_notifications from './push_notifications'; import push_notifications from './push_notifications';
import relationships from './relationships'; import { relationshipsReducer } from './relationships';
import search from './search'; import search from './search';
import server from './server'; import server from './server';
import settings from './settings'; import settings from './settings';
@ -55,11 +54,10 @@ const reducers = {
user_lists, user_lists,
domain_lists, domain_lists,
status_lists, status_lists,
accounts, accounts: accountsReducer,
accounts_counters,
accounts_map, accounts_map,
statuses, statuses,
relationships, relationships: relationshipsReducer,
settings, settings,
push_notifications, push_notifications,
mutes, mutes,

View file

@ -1,12 +1,12 @@
import { fromJS, Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { fromJS, Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks'; import { blockDomainSuccess } from 'mastodon/actions/domain_blocks';
import { import {
ACCOUNT_BLOCK_SUCCESS, authorizeFollowRequestSuccess,
ACCOUNT_MUTE_SUCCESS, blockAccountSuccess,
FOLLOW_REQUEST_AUTHORIZE_SUCCESS, muteAccountSuccess,
FOLLOW_REQUEST_REJECT_SUCCESS, rejectFollowRequestSuccess,
} from '../actions/accounts'; } from '../actions/accounts';
import { import {
focusApp, focusApp,
@ -16,7 +16,7 @@ import {
MARKERS_FETCH_SUCCESS, MARKERS_FETCH_SUCCESS,
} from '../actions/markers'; } from '../actions/markers';
import { import {
NOTIFICATIONS_UPDATE, notificationsUpdate,
NOTIFICATIONS_EXPAND_SUCCESS, NOTIFICATIONS_EXPAND_SUCCESS,
NOTIFICATIONS_EXPAND_REQUEST, NOTIFICATIONS_EXPAND_REQUEST,
NOTIFICATIONS_EXPAND_FAIL, NOTIFICATIONS_EXPAND_FAIL,
@ -274,19 +274,19 @@ export default function notifications(state = initialState, action) {
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', true); return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', true);
case NOTIFICATIONS_SCROLL_TOP: case NOTIFICATIONS_SCROLL_TOP:
return updateTop(state, action.top); return updateTop(state, action.top);
case NOTIFICATIONS_UPDATE: case notificationsUpdate.type:
return normalizeNotification(state, action.notification, action.usePendingItems); return normalizeNotification(state, action.payload.notification, action.payload.usePendingItems);
case NOTIFICATIONS_EXPAND_SUCCESS: case NOTIFICATIONS_EXPAND_SUCCESS:
return expandNormalizedNotifications(state, action.notifications, action.next, action.isLoadingMore, action.isLoadingRecent, action.usePendingItems); return expandNormalizedNotifications(state, action.notifications, action.next, action.isLoadingMore, action.isLoadingRecent, action.usePendingItems);
case ACCOUNT_BLOCK_SUCCESS: case blockAccountSuccess.type:
return filterNotifications(state, [action.relationship.id]); return filterNotifications(state, [action.payload.relationship.id]);
case ACCOUNT_MUTE_SUCCESS: case muteAccountSuccess.type:
return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state; return action.relationship.muting_notifications ? filterNotifications(state, [action.payload.relationship.id]) : state;
case DOMAIN_BLOCK_SUCCESS: case blockDomainSuccess.type:
return filterNotifications(state, action.accounts); return filterNotifications(state, action.payload.accounts);
case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: case authorizeFollowRequestSuccess.type:
case FOLLOW_REQUEST_REJECT_SUCCESS: case rejectFollowRequestSuccess.type:
return filterNotifications(state, [action.id], 'follow_request'); return filterNotifications(state, [action.payload.id], 'follow_request');
case NOTIFICATIONS_CLEAR: case NOTIFICATIONS_CLEAR:
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false); return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
case TIMELINE_DELETE: case TIMELINE_DELETE:

View file

@ -1,88 +0,0 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import {
submitAccountNote,
} from '../actions/account_notes';
import {
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_FOLLOW_REQUEST,
ACCOUNT_FOLLOW_FAIL,
ACCOUNT_UNFOLLOW_SUCCESS,
ACCOUNT_UNFOLLOW_REQUEST,
ACCOUNT_UNFOLLOW_FAIL,
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_UNBLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS,
ACCOUNT_UNMUTE_SUCCESS,
ACCOUNT_PIN_SUCCESS,
ACCOUNT_UNPIN_SUCCESS,
RELATIONSHIPS_FETCH_SUCCESS,
FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
FOLLOW_REQUEST_REJECT_SUCCESS,
} from '../actions/accounts';
import {
DOMAIN_BLOCK_SUCCESS,
DOMAIN_UNBLOCK_SUCCESS,
} from '../actions/domain_blocks';
import {
NOTIFICATIONS_UPDATE,
} from '../actions/notifications';
const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship));
const normalizeRelationships = (state, relationships) => {
relationships.forEach(relationship => {
state = normalizeRelationship(state, relationship);
});
return state;
};
const setDomainBlocking = (state, accounts, blocking) => {
return state.withMutations(map => {
accounts.forEach(id => {
map.setIn([id, 'domain_blocking'], blocking);
});
});
};
const initialState = ImmutableMap();
export default function relationships(state = initialState, action) {
switch(action.type) {
case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
return state.setIn([action.id, 'followed_by'], true).setIn([action.id, 'requested_by'], false);
case FOLLOW_REQUEST_REJECT_SUCCESS:
return state.setIn([action.id, 'followed_by'], false).setIn([action.id, 'requested_by'], false);
case NOTIFICATIONS_UPDATE:
return action.notification.type === 'follow_request' ? state.setIn([action.notification.account.id, 'requested_by'], true) : state;
case ACCOUNT_FOLLOW_REQUEST:
return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
case ACCOUNT_FOLLOW_FAIL:
return state.setIn([action.id, action.locked ? 'requested' : 'following'], false);
case ACCOUNT_UNFOLLOW_REQUEST:
return state.setIn([action.id, 'following'], false);
case ACCOUNT_UNFOLLOW_FAIL:
return state.setIn([action.id, 'following'], true);
case ACCOUNT_FOLLOW_SUCCESS:
case ACCOUNT_UNFOLLOW_SUCCESS:
case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_UNBLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS:
case ACCOUNT_UNMUTE_SUCCESS:
case ACCOUNT_PIN_SUCCESS:
case ACCOUNT_UNPIN_SUCCESS:
return normalizeRelationship(state, action.relationship);
case RELATIONSHIPS_FETCH_SUCCESS:
return normalizeRelationships(state, action.relationships);
case submitAccountNote.fulfilled:
return normalizeRelationship(state, action.payload.relationship);
case DOMAIN_BLOCK_SUCCESS:
return setDomainBlocking(state, action.accounts, true);
case DOMAIN_UNBLOCK_SUCCESS:
return setDomainBlocking(state, action.accounts, false);
default:
return state;
}
}

View file

@ -0,0 +1,123 @@
import { Map as ImmutableMap } from 'immutable';
import { isFulfilled } from '@reduxjs/toolkit';
import type { Reducer } from 'redux';
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
import type { Account } from 'mastodon/models/account';
import { createRelationship } from 'mastodon/models/relationship';
import type { Relationship } from 'mastodon/models/relationship';
import { submitAccountNote } from '../actions/account_notes';
import {
followAccountSuccess,
unfollowAccountSuccess,
authorizeFollowRequestSuccess,
rejectFollowRequestSuccess,
followAccountRequest,
followAccountFail,
unfollowAccountRequest,
unfollowAccountFail,
blockAccountSuccess,
unblockAccountSuccess,
muteAccountSuccess,
unmuteAccountSuccess,
pinAccountSuccess,
unpinAccountSuccess,
fetchRelationshipsSuccess,
} from '../actions/accounts_typed';
import {
blockDomainSuccess,
unblockDomainSuccess,
} from '../actions/domain_blocks_typed';
import { notificationsUpdate } from '../actions/notifications_typed';
const initialState = ImmutableMap<string, Relationship>();
type State = typeof initialState;
const normalizeRelationship = (
state: State,
relationship: ApiRelationshipJSON,
) => state.set(relationship.id, createRelationship(relationship));
const normalizeRelationships = (
state: State,
relationships: ApiRelationshipJSON[],
) => {
relationships.forEach((relationship) => {
state = normalizeRelationship(state, relationship);
});
return state;
};
const setDomainBlocking = (
state: State,
accounts: Account[],
blocking: boolean,
) => {
return state.withMutations((map) => {
accounts.forEach((id) => {
map.setIn([id, 'domain_blocking'], blocking);
});
});
};
export const relationshipsReducer: Reducer<State> = (
state = initialState,
action,
) => {
if (authorizeFollowRequestSuccess.match(action))
return state
.setIn([action.payload.id, 'followed_by'], true)
.setIn([action.payload.id, 'requested_by'], false);
else if (rejectFollowRequestSuccess.match(action))
return state
.setIn([action.payload.id, 'followed_by'], false)
.setIn([action.payload.id, 'requested_by'], false);
else if (notificationsUpdate.match(action))
return action.payload.notification.type === 'follow_request'
? state.setIn(
[action.payload.notification.account.id, 'requested_by'],
true,
)
: state;
else if (followAccountRequest.match(action))
return state.getIn([action.payload.id, 'following'])
? state
: state.setIn(
[
action.payload.id,
action.payload.locked ? 'requested' : 'following',
],
true,
);
else if (followAccountFail.match(action))
return state.setIn(
[action.payload.id, action.payload.locked ? 'requested' : 'following'],
false,
);
else if (unfollowAccountRequest.match(action))
return state.setIn([action.payload.id, 'following'], false);
else if (unfollowAccountFail.match(action))
return state.setIn([action.payload.id, 'following'], true);
else if (
followAccountSuccess.match(action) ||
unfollowAccountSuccess.match(action) ||
blockAccountSuccess.match(action) ||
unblockAccountSuccess.match(action) ||
muteAccountSuccess.match(action) ||
unmuteAccountSuccess.match(action) ||
pinAccountSuccess.match(action) ||
unpinAccountSuccess.match(action) ||
isFulfilled(submitAccountNote)(action)
)
return normalizeRelationship(state, action.payload.relationship);
else if (fetchRelationshipsSuccess.match(action))
return normalizeRelationships(state, action.payload.relationships);
else if (blockDomainSuccess.match(action))
return setDomainBlocking(state, action.payload.accounts, true);
else if (unblockDomainSuccess.match(action))
return setDomainBlocking(state, action.payload.accounts, false);
else return state;
};

View file

@ -1,8 +1,8 @@
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
import { import {
ACCOUNT_BLOCK_SUCCESS, blockAccountSuccess,
ACCOUNT_MUTE_SUCCESS, muteAccountSuccess,
} from '../actions/accounts'; } from '../actions/accounts';
import { import {
BOOKMARKED_STATUSES_FETCH_REQUEST, BOOKMARKED_STATUSES_FETCH_REQUEST,
@ -142,9 +142,9 @@ export default function statusLists(state = initialState, action) {
return prependOneToList(state, 'pins', action.status); return prependOneToList(state, 'pins', action.status);
case UNPIN_SUCCESS: case UNPIN_SUCCESS:
return removeOneFromList(state, 'pins', action.status); return removeOneFromList(state, 'pins', action.status);
case ACCOUNT_BLOCK_SUCCESS: case blockAccountSuccess.type:
case ACCOUNT_MUTE_SUCCESS: case muteAccountSuccess.type:
return state.updateIn(['trending', 'items'], ImmutableOrderedSet(), list => list.filterNot(statusId => action.statuses.getIn([statusId, 'account']) === action.relationship.id)); return state.updateIn(['trending', 'items'], ImmutableOrderedSet(), list => list.filterNot(statusId => action.payload.statuses.getIn([statusId, 'account']) === action.payload.relationship.id));
default: default:
return state; return state;
} }

View file

@ -1,7 +1,7 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts'; import { blockAccountSuccess, muteAccountSuccess } from 'mastodon/actions/accounts';
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks'; import { blockDomainSuccess } from 'mastodon/actions/domain_blocks';
import { import {
SUGGESTIONS_FETCH_REQUEST, SUGGESTIONS_FETCH_REQUEST,
@ -29,11 +29,11 @@ export default function suggestionsReducer(state = initialState, action) {
return state.set('isLoading', false); return state.set('isLoading', false);
case SUGGESTIONS_DISMISS: case SUGGESTIONS_DISMISS:
return state.update('items', list => list.filterNot(x => x.account === action.id)); return state.update('items', list => list.filterNot(x => x.account === action.id));
case ACCOUNT_BLOCK_SUCCESS: case blockAccountSuccess.type:
case ACCOUNT_MUTE_SUCCESS: case muteAccountSuccess.type:
return state.update('items', list => list.filterNot(x => x.account === action.relationship.id)); return state.update('items', list => list.filterNot(x => x.account === action.payload.relationship.id));
case DOMAIN_BLOCK_SUCCESS: case blockDomainSuccess.type:
return state.update('items', list => list.filterNot(x => action.accounts.includes(x.account))); return state.update('items', list => list.filterNot(x => action.payload.accounts.includes(x.account)));
default: default:
return state; return state;
} }

View file

@ -1,9 +1,9 @@
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import { import {
ACCOUNT_BLOCK_SUCCESS, blockAccountSuccess,
ACCOUNT_MUTE_SUCCESS, muteAccountSuccess,
ACCOUNT_UNFOLLOW_SUCCESS, unfollowAccountSuccess
} from '../actions/accounts'; } from '../actions/accounts';
import { import {
TIMELINE_UPDATE, TIMELINE_UPDATE,
@ -200,11 +200,11 @@ export default function timelines(state = initialState, action) {
return deleteStatus(state, action.id, action.references, action.reblogOf); return deleteStatus(state, action.id, action.references, action.reblogOf);
case TIMELINE_CLEAR: case TIMELINE_CLEAR:
return clearTimeline(state, action.timeline); return clearTimeline(state, action.timeline);
case ACCOUNT_BLOCK_SUCCESS: case blockAccountSuccess.type:
case ACCOUNT_MUTE_SUCCESS: case muteAccountSuccess.type:
return filterTimelines(state, action.relationship, action.statuses); return filterTimelines(state, action.payload.relationship, action.payload.statuses);
case ACCOUNT_UNFOLLOW_SUCCESS: case unfollowAccountSuccess.type:
return filterTimeline('home', state, action.relationship, action.statuses); return filterTimeline('home', state, action.payload.relationship, action.payload.statuses);
case TIMELINE_SCROLL_TOP: case TIMELINE_SCROLL_TOP:
return updateTop(state, action.timeline, action.top); return updateTop(state, action.timeline, action.top);
case TIMELINE_CONNECT: case TIMELINE_CONNECT:

View file

@ -33,8 +33,8 @@ import {
FOLLOW_REQUESTS_EXPAND_REQUEST, FOLLOW_REQUESTS_EXPAND_REQUEST,
FOLLOW_REQUESTS_EXPAND_SUCCESS, FOLLOW_REQUESTS_EXPAND_SUCCESS,
FOLLOW_REQUESTS_EXPAND_FAIL, FOLLOW_REQUESTS_EXPAND_FAIL,
FOLLOW_REQUEST_AUTHORIZE_SUCCESS, authorizeFollowRequestSuccess,
FOLLOW_REQUEST_REJECT_SUCCESS, rejectFollowRequestSuccess,
} from '../actions/accounts'; } from '../actions/accounts';
import { import {
BLOCKS_FETCH_REQUEST, BLOCKS_FETCH_REQUEST,
@ -66,11 +66,7 @@ import {
MUTES_EXPAND_SUCCESS, MUTES_EXPAND_SUCCESS,
MUTES_EXPAND_FAIL, MUTES_EXPAND_FAIL,
} from '../actions/mutes'; } from '../actions/mutes';
import { import { notificationsUpdate } from '../actions/notifications';
NOTIFICATIONS_UPDATE,
} from '../actions/notifications';
const initialListState = ImmutableMap({ const initialListState = ImmutableMap({
next: null, next: null,
@ -163,8 +159,8 @@ export default function userLists(state = initialState, action) {
case FAVOURITES_FETCH_FAIL: case FAVOURITES_FETCH_FAIL:
case FAVOURITES_EXPAND_FAIL: case FAVOURITES_EXPAND_FAIL:
return state.setIn(['favourited_by', action.id, 'isLoading'], false); return state.setIn(['favourited_by', action.id, 'isLoading'], false);
case NOTIFICATIONS_UPDATE: case notificationsUpdate.type:
return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state; return action.payload.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.payload.notification) : state;
case FOLLOW_REQUESTS_FETCH_SUCCESS: case FOLLOW_REQUESTS_FETCH_SUCCESS:
return normalizeList(state, ['follow_requests'], action.accounts, action.next); return normalizeList(state, ['follow_requests'], action.accounts, action.next);
case FOLLOW_REQUESTS_EXPAND_SUCCESS: case FOLLOW_REQUESTS_EXPAND_SUCCESS:
@ -175,9 +171,9 @@ export default function userLists(state = initialState, action) {
case FOLLOW_REQUESTS_FETCH_FAIL: case FOLLOW_REQUESTS_FETCH_FAIL:
case FOLLOW_REQUESTS_EXPAND_FAIL: case FOLLOW_REQUESTS_EXPAND_FAIL:
return state.setIn(['follow_requests', 'isLoading'], false); return state.setIn(['follow_requests', 'isLoading'], false);
case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: case authorizeFollowRequestSuccess.type:
case FOLLOW_REQUEST_REJECT_SUCCESS: case rejectFollowRequestSuccess.type:
return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id)); return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.payload.id));
case BLOCKS_FETCH_SUCCESS: case BLOCKS_FETCH_SUCCESS:
return normalizeList(state, ['blocks'], action.accounts, action.next); return normalizeList(state, ['blocks'], action.accounts, action.next);
case BLOCKS_EXPAND_SUCCESS: case BLOCKS_EXPAND_SUCCESS:

View file

@ -0,0 +1,47 @@
import { Record as ImmutableRecord } from 'immutable';
import { createSelector } from 'reselect';
import { accountDefaultValues } from 'mastodon/models/account';
import type { Account, AccountShape } from 'mastodon/models/account';
import type { Relationship } from 'mastodon/models/relationship';
import type { RootState } from 'mastodon/store';
const getAccountBase = (state: RootState, id: string) =>
state.accounts.get(id, null);
const getAccountRelationship = (state: RootState, id: string) =>
state.relationships.get(id, null);
const getAccountMoved = (state: RootState, id: string) => {
const movedToId = state.accounts.get(id)?.moved;
if (!movedToId) return undefined;
return state.accounts.get(movedToId);
};
interface FullAccountShape extends Omit<AccountShape, 'moved'> {
relationship: Relationship | null;
moved: Account | null;
}
const FullAccountFactory = ImmutableRecord<FullAccountShape>({
...accountDefaultValues,
moved: null,
relationship: null,
});
export function makeGetAccount() {
return createSelector(
[getAccountBase, getAccountRelationship, getAccountMoved],
(base, relationship, moved) => {
if (base === null) {
return null;
}
return FullAccountFactory(base)
.set('relationship', relationship)
.set('moved', moved ?? null);
},
);
}

View file

@ -5,23 +5,7 @@ import { toServerSideType } from 'mastodon/utils/filters';
import { me } from '../initial_state'; import { me } from '../initial_state';
const getAccountBase = (state, id) => state.getIn(['accounts', id], null); export { makeGetAccount } from "./accounts";
const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null);
const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null);
const getAccountMoved = (state, id) => state.getIn(['accounts', state.getIn(['accounts', id, 'moved'])]);
export const makeGetAccount = () => {
return createSelector([getAccountBase, getAccountCounters, getAccountRelationship, getAccountMoved], (base, counters, relationship, moved) => {
if (base === null) {
return null;
}
return base.merge(counters).withMutations(map => {
map.set('relationship', relationship);
map.set('moved', moved);
});
});
};
const getFilters = (state, { contextType }) => { const getFilters = (state, { contextType }) => {
if (!contextType) return null; if (!contextType) return null;

View file

@ -35,6 +35,5 @@ export const store = configureStore({
// Infer the `RootState` and `AppDispatch` types from the store itself // Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof rootReducer>; export type RootState = ReturnType<typeof rootReducer>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch; export type AppDispatch = typeof store.dispatch;
export type GetState = typeof store.getState; export type GetState = typeof store.getState;

View file

@ -0,0 +1,7 @@
export function isDevelopment() {
return process.env.NODE_ENV === 'development';
}
export function isProduction() {
return process.env.NODE_ENV === 'production';
}

View file

@ -1,55 +0,0 @@
import type { Record } from 'immutable';
type CustomEmoji = Record<{
shortcode: string;
static_url: string;
url: string;
}>;
type AccountField = Record<{
name: string;
value: string;
verified_at: string | null;
}>;
interface AccountApiResponseValues {
acct: string;
avatar: string;
avatar_static: string;
bot: boolean;
created_at: string;
discoverable: boolean;
display_name: string;
emojis: CustomEmoji[];
fields: AccountField[];
followers_count: number;
following_count: number;
group: boolean;
header: string;
header_static: string;
id: string;
last_status_at: string;
locked: boolean;
note: string;
statuses_count: number;
url: string;
uri: string;
username: string;
}
type NormalizedAccountField = Record<{
name_emojified: string;
value_emojified: string;
value_plain: string;
}>;
interface NormalizedAccountValues {
display_name_html: string;
fields: NormalizedAccountField[];
note_emojified: string;
note_plain: string;
}
export type Account = Record<
AccountApiResponseValues & NormalizedAccountValues
>;

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