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
libretranslate:
image: libretranslate/libretranslate:v1.4.0
image: libretranslate/libretranslate:v1.4.1
restart: unless-stopped
volumes:
- lt-data:/home/libretranslate/.local

View file

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

View file

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

View file

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

View file

@ -1,17 +1,31 @@
# This configuration was generated by
# `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
# one by one as the lints are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of Haml-Lint, may require this file to be generated again.
linters:
# Offense count: 945
# Offense count: 16
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:
exclude:
- 'app/views/admin/accounts/_buttons.html.haml'

View file

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

View file

@ -132,11 +132,6 @@ RSpec/InstanceVariable:
RSpec/LetSetup:
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/filters_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 'addressable', '~> 2.8'
gem 'bootsnap', '~> 1.16.0', require: false
gem 'bootsnap', '~> 1.17.0', require: false
gem 'browser'
gem 'charlock_holmes', '~> 0.7.7'
gem 'chewy', '~> 7.3'
@ -88,7 +88,7 @@ gem 'simple-navigation', '~> 4.4'
gem 'simple_form', '~> 5.2'
gem 'sprockets-rails', '~> 3.4', require: 'sprockets/railtie'
gem 'stoplight', '~> 3.0.1'
gem 'strong_migrations', '~> 0.8'
gem 'strong_migrations', '1.3.0'
gem 'tty-prompt', '~> 0.23', require: false
gem 'twitter-text', '~> 3.1.0'
gem 'tzinfo-data', '~> 1.2023'
@ -103,9 +103,6 @@ gem 'rdf-normalize', '~> 0.5'
gem 'private_address_check', '~> 0.5'
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
gem 'rspec-github', '~> 2.4', require: false

View file

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

View file

@ -33,7 +33,7 @@ module Admin
# Disallow accidentally downgrading a 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
@domain_block.errors.delete(:domain)
return render :new

View file

@ -92,18 +92,10 @@ module CacheConcern
arguments
end
if Rails.gem_version >= Gem::Version.new('7.0')
def attributes_for_database(record)
attributes = record.attributes_for_database
attributes.transform_values! { |attr| attr.is_a?(::ActiveModel::Type::Binary::Data) ? attr.to_s : attr }
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
def attributes_for_database(record)
attributes = record.attributes_for_database
attributes.transform_values! { |attr| attr.is_a?(::ActiveModel::Type::Binary::Data) ? attr.to_s : attr }
attributes
end
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 { useCallback, useRef, useState, useEffect, forwardRef } from 'react';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
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 = {
value: PropTypes.string,
suggestions: ImmutablePropTypes.list,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
onSuggestionSelected: PropTypes.func.isRequired,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired,
autoFocus: PropTypes.bool,
lang: PropTypes.string,
};
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
const lastTokenRef = useRef(null);
const tokenStartRef = useRef(0);
static defaultProps = {
autoFocus: true,
};
state = {
suggestionsHidden: true,
focused: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,
};
onChange = (e) => {
const handleChange = useCallback((e) => {
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
if (token !== null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
this.props.onSuggestionsFetchRequested(token);
if (token !== null && lastTokenRef.current !== token) {
tokenStartRef.current = tokenStart;
lastTokenRef.current = token;
setSelectedSuggestion(0);
onSuggestionsFetchRequested(token);
} else if (token === null) {
this.setState({ lastToken: null });
this.props.onSuggestionsClearRequested();
lastTokenRef.current = null;
onSuggestionsClearRequested();
}
this.props.onChange(e);
};
onKeyDown = (e) => {
const { suggestions, disabled } = this.props;
const { selectedSuggestion, suggestionsHidden } = this.state;
onChange(e);
}, [onSuggestionsFetchRequested, onSuggestionsClearRequested, onChange, setSelectedSuggestion]);
const handleKeyDown = useCallback((e) => {
if (disabled) {
e.preventDefault();
return;
@ -102,80 +94,75 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
document.querySelector('.ui').parentElement.focus();
} else {
e.preventDefault();
this.setState({ suggestionsHidden: true });
setSuggestionsHidden(true);
}
break;
case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
setSelectedSuggestion(Math.min(selectedSuggestion + 1, suggestions.size - 1));
}
break;
case 'ArrowUp':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
setSelectedSuggestion(Math.max(selectedSuggestion - 1, 0));
}
break;
case 'Enter':
case 'Tab':
// Select suggestion
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
if (lastTokenRef.current !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestions.get(selectedSuggestion));
}
break;
}
if (e.defaultPrevented || !this.props.onKeyDown) {
if (e.defaultPrevented || !onKeyDown) {
return;
}
this.props.onKeyDown(e);
};
onKeyDown(e);
}, [disabled, suggestions, suggestionsHidden, selectedSuggestion, setSelectedSuggestion, setSuggestionsHidden, onSuggestionSelected, onKeyDown]);
onBlur = () => {
this.setState({ suggestionsHidden: true, focused: false });
};
const handleBlur = useCallback(() => {
setSuggestionsHidden(true);
}, [setSuggestionsHidden]);
onFocus = (e) => {
this.setState({ focused: true });
if (this.props.onFocus) {
this.props.onFocus(e);
const handleFocus = useCallback((e) => {
if (onFocus) {
onFocus(e);
}
};
}, [onFocus]);
onSuggestionClick = (e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
const handleSuggestionClick = useCallback((e) => {
const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index'));
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.textarea.focus();
};
onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestion);
textareaRef.current?.focus();
}, [suggestions, onSuggestionSelected, textareaRef]);
UNSAFE_componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
this.setState({ suggestionsHidden: false });
}
}
setTextarea = (c) => {
this.textarea = c;
};
onPaste = (e) => {
const handlePaste = useCallback((e) => {
if (e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files);
onPaste(e.clipboardData.files);
e.preventDefault();
}
};
}, [onPaste]);
renderSuggestion = (suggestion, i) => {
const { selectedSuggestion } = this.state;
// Show the suggestions again whenever they change and the textarea is focused
useEffect(() => {
if (suggestions.size > 0 && textareaRef.current === document.activeElement) {
setSuggestionsHidden(false);
}
}, [suggestions, textareaRef, setSuggestionsHidden]);
const renderSuggestion = (suggestion, i) => {
let inner, key;
if (suggestion.type === 'emoji') {
@ -190,50 +177,64 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
}
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}
</div>
);
};
render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props;
const { suggestionsHidden } = this.state;
return [
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
<div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
return [
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
<div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<Textarea
ref={textareaRef}
className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onKeyUp={onKeyUp}
onFocus={handleFocus}
onBlur={handleBlur}
onPaste={handlePaste}
dir='auto'
aria-autocomplete='list'
lang={lang}
/>
</label>
</div>
{children}
</div>,
<Textarea
ref={this.setTextarea}
className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
onPaste={this.onPaste}
dir='auto'
aria-autocomplete='list'
lang={lang}
/>
</label>
</div>
{children}
</div>,
<div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(renderSuggestion)}
</div>
</div>,
];
});
<div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(this.renderSuggestion)}
</div>
</div>,
];
}
AutosuggestTextarea.propTypes = {
value: PropTypes.string,
suggestions: ImmutablePropTypes.list,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
onSuggestionSelected: PropTypes.func.isRequired,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired,
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 { createRef } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
@ -90,6 +91,11 @@ class ComposeForm extends ImmutablePureComponent {
highlighted: false,
};
constructor(props) {
super(props);
this.textareaRef = createRef(null);
}
handleChange = (e) => {
this.props.onChange(e.target.value);
};
@ -118,10 +124,10 @@ class ComposeForm extends ImmutablePureComponent {
onChangeVisibility,
} = 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)
// Update the state to match the current text
this.props.onChange(this.textarea.value);
this.props.onChange(this.textareaRef.current.value);
}
if (!this.canSubmit()) {
@ -154,10 +160,10 @@ class ComposeForm extends ImmutablePureComponent {
// Inserts an emoji at the caret.
handleEmojiPick = (data) => {
const { textarea: { selectionStart } } = this;
const { onPickEmoji } = this.props;
if (onPickEmoji) {
onPickEmoji(selectionStart, data);
const position = this.textareaRef.current.selectionStart;
if (this.props.onPickEmoji) {
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.
handleRefSpoilerText = (spoilerComponent) => {
if (spoilerComponent) {
@ -232,7 +231,6 @@ class ComposeForm extends ImmutablePureComponent {
// everyone else from the conversation.
_updateFocusAndSelection = (prevProps) => {
const {
textarea,
spoilerText,
} = this;
const {
@ -259,30 +257,30 @@ class ComposeForm extends ImmutablePureComponent {
default:
selectionStart = selectionEnd = text.length;
}
if (textarea) {
if (this.textareaRef.current) {
// Because of the wicg-inert polyfill, the activeElement may not be
// immediately selectable, we have to wait for observers to run, as
// described in https://github.com/WICG/inert#performance-and-gotchas
Promise.resolve().then(() => {
textarea.setSelectionRange(selectionStart, selectionEnd);
textarea.focus();
if (!singleColumn) textarea.scrollIntoView();
this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
this.textareaRef.current.focus();
if (!singleColumn) this.textareaRef.current.scrollIntoView();
this.setState({ highlighted: true });
this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
}).catch(console.error);
}
// Refocuses the textarea after submitting.
} else if (textarea && prevProps.isSubmitting && !isSubmitting) {
textarea.focus();
} else if (this.textareaRef.current && prevProps.isSubmitting && !isSubmitting) {
this.textareaRef.current.focus();
} else if (this.props.spoiler !== prevProps.spoiler) {
if (this.props.spoiler) {
if (spoilerText) {
spoilerText.focus();
}
} else {
if (textarea) {
textarea.focus();
if (this.textareaRef.current) {
this.textareaRef.current.focus();
}
}
}
@ -347,7 +345,7 @@ class ComposeForm extends ImmutablePureComponent {
<div className={classNames('compose-form__highlightable', { active: highlighted })}>
<AutosuggestTextarea
ref={this.setAutosuggestTextarea}
ref={this.textareaRef}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={isSubmitting}
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';

View file

@ -4,39 +4,18 @@
import { loadIntlPolyfills } from './intl';
function importBasePolyfills() {
return import(/* webpackChunkName: "base_polyfills" */ './base_polyfills');
}
function importExtraPolyfills() {
return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills');
}
export function loadPolyfills() {
const needsBasePolyfills = !(
'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.
// Safari does not have requestIdleCallback.
// 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.AbortController &&
window.IntersectionObserver &&
window.IntersectionObserverEntry &&
'isIntersecting' in IntersectionObserverEntry.prototype &&
window.requestIdleCallback
);
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
const needsExtraPolyfills = !window.requestIdleCallback;
return Promise.all([
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(),
]);
}

View file

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

View file

@ -1,5 +1,15 @@
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';
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_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_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS';
export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL';
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_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST';
export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS';
export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL';
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_PIN_REQUEST = 'ACCOUNT_PIN_REQUEST';
export const ACCOUNT_PIN_SUCCESS = 'ACCOUNT_PIN_SUCCESS';
export const ACCOUNT_PIN_FAIL = 'ACCOUNT_PIN_FAIL';
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 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 RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL';
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_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_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 ACCOUNT_REVEAL = 'ACCOUNT_REVEAL';
export * from './accounts_typed';
export function fetchAccount(id) {
return (dispatch, getState) => {
dispatch(fetchRelationships([id]));
@ -149,12 +144,12 @@ export function followAccount(id, options = { reblogs: true }) {
const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
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 => {
dispatch(followAccountSuccess(response.data, alreadyFollowing));
dispatch(followAccountSuccess({relationship: response.data, alreadyFollowing}));
}).catch(error => {
dispatch(followAccountFail(error, locked));
dispatch(followAccountFail({ id, error, locked }));
});
};
}
@ -164,74 +159,22 @@ export function unfollowAccount(id) {
dispatch(unfollowAccountRequest(id));
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 => {
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) {
return (dispatch, getState) => {
dispatch(blockAccountRequest(id));
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
dispatch(blockAccountSuccess(response.data, getState().get('statuses')));
dispatch(blockAccountSuccess({ relationship: response.data, statuses: getState().get('statuses') }));
}).catch(error => {
dispatch(blockAccountFail(id, error));
dispatch(blockAccountFail({ id, error }));
});
};
}
@ -241,9 +184,9 @@ export function unblockAccount(id) {
dispatch(unblockAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => {
dispatch(unblockAccountSuccess(response.data));
dispatch(unblockAccountSuccess({ relationship: response.data }));
}).catch(error => {
dispatch(unblockAccountFail(id, error));
dispatch(unblockAccountFail({ id, error }));
});
};
}
@ -254,15 +197,6 @@ export function blockAccountRequest(id) {
id,
};
}
export function blockAccountSuccess(relationship, statuses) {
return {
type: ACCOUNT_BLOCK_SUCCESS,
relationship,
statuses,
};
}
export function blockAccountFail(error) {
return {
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) {
return {
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 => {
// 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 => {
dispatch(muteAccountFail(id, error));
dispatch(muteAccountFail({ id, error }));
});
};
}
@ -310,9 +237,9 @@ export function unmuteAccount(id) {
dispatch(unmuteAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => {
dispatch(unmuteAccountSuccess(response.data));
dispatch(unmuteAccountSuccess({ relationship: response.data }));
}).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) {
return {
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) {
return {
type: ACCOUNT_UNMUTE_FAIL,
@ -549,7 +461,7 @@ export function fetchRelationships(accountIds) {
dispatch(fetchRelationshipsRequest(newAccountIds));
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 => {
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) {
return {
type: RELATIONSHIPS_FETCH_FAIL,
@ -659,7 +563,7 @@ export function authorizeFollowRequest(id) {
api(getState)
.post(`/api/v1/follow_requests/${id}/authorize`)
.then(() => dispatch(authorizeFollowRequestSuccess(id)))
.then(() => dispatch(authorizeFollowRequestSuccess({ id })))
.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) {
return {
type: FOLLOW_REQUEST_AUTHORIZE_FAIL,
@ -693,7 +590,7 @@ export function rejectFollowRequest(id) {
api(getState)
.post(`/api/v1/follow_requests/${id}/reject`)
.then(() => dispatch(rejectFollowRequestSuccess(id)))
.then(() => dispatch(rejectFollowRequestSuccess({ id })))
.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) {
return {
type: FOLLOW_REQUEST_REJECT_FAIL,
@ -725,7 +615,7 @@ export function pinAccount(id) {
dispatch(pinAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => {
dispatch(pinAccountSuccess(response.data));
dispatch(pinAccountSuccess({ relationship: response.data }));
}).catch(error => {
dispatch(pinAccountFail(error));
});
@ -737,7 +627,7 @@ export function unpinAccount(id) {
dispatch(unpinAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => {
dispatch(unpinAccountSuccess(response.data));
dispatch(unpinAccountSuccess({ relationship: response.data }));
}).catch(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) {
return {
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) {
return {
type: ACCOUNT_UNPIN_FAIL,
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 { blockDomainSuccess, unblockDomainSuccess } from "./domain_blocks_typed";
export * from "./domain_blocks_typed";
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_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST';
export const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS';
export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL';
export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST';
@ -24,7 +26,7 @@ export function blockDomain(domain) {
const at_domain = '@' + domain;
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 => {
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) {
return {
type: DOMAIN_BLOCK_FAIL,
@ -61,7 +55,7 @@ export function unblockDomain(domain) {
api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => {
const at_domain = '@' + domain;
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 => {
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) {
return {
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 STATUSES_IMPORT = 'STATUSES_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) {
return { type: STATUS_IMPORT, status };
}
@ -45,7 +37,7 @@ export function importFetchedAccounts(accounts) {
const normalAccounts = [];
function processAccount(account) {
pushUnique(normalAccounts, normalizeAccount(account));
pushUnique(normalAccounts, account);
if (account.moved) {
processAccount(account.moved);
@ -54,7 +46,7 @@ export function importFetchedAccounts(accounts) {
accounts.forEach(processAccount);
return importAccounts(normalAccounts);
return importAccounts({ accounts: normalAccounts });
}
export function importFetchedStatus(status) {

View file

@ -2,7 +2,6 @@ import escapeTextContentForBrowser from 'escape-html';
import emojify from '../../features/emoji/emoji';
import { expandSpoilers } from '../../initial_state';
import { unescapeHTML } from '../../utils/html';
const domParser = new DOMParser();
@ -17,32 +16,6 @@ export function searchTextFromRawStatus (status) {
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) {
const normalResult = { ...result };

View file

@ -18,10 +18,12 @@ import {
importFetchedStatuses,
} from './importer';
import { submitMarkers } from './markers';
import { notificationsUpdate } from "./notifications_typed";
import { register as registerPushNotifications } from './push_notifications';
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_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
@ -95,12 +97,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
dispatch(importFetchedAccount(notification.report.target_account));
}
dispatch({
type: NOTIFICATIONS_UPDATE,
notification,
usePendingItems: preferPendingItems,
meta: (playSound && !filtered) ? { sound: 'boop' } : undefined,
});
dispatch(notificationsUpdate(notification, preferPendingItems, playSound && !filtered));
fetchRelatedRelationships(dispatch, [notification]);
} 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) =>
Iterable.isIndexed(v) ? v.toList() : v.toMap());
export function hydrateStore(rawState) {
return dispatch => {
const state = convertState(rawState);

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,8 @@ import React from 'react';
import type { List } from 'immutable';
import type { Account } from '../../types/resources';
import type { Account } from 'mastodon/models/account';
import { autoPlayGif } from '../initial_state';
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 { isProduction } from 'mastodon/utils/environment';
interface SVGPropsWithTitle extends React.SVGProps<SVGSVGElement> {
title?: string;
}
@ -24,7 +26,7 @@ export const Icon: React.FC<Props> = ({
}) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!IconComponent) {
if (process.env.NODE_ENV !== 'production') {
if (!isProduction()) {
throw new Error(
`<Icon id="${id}" className="${className}"> is missing an "icon" prop.`,
);

View file

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

View file

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

View file

@ -85,7 +85,7 @@ class Status extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map,
account: ImmutablePropTypes.record,
previousId: PropTypes.string,
nextInReplyToId: 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 { IntlProvider } from 'mastodon/locales';
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);

View file

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

View file

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

View file

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

View file

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

View file

@ -17,7 +17,7 @@ import MovedNote from './moved_note';
class Header extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
account: ImmutablePropTypes.record,
onFollow: PropTypes.func.isRequired,
onBlock: 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 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';
const emptyList = ImmutableList();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -22,7 +22,7 @@ const messages = defineMessages({
class AccountAuthorize extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
account: ImmutablePropTypes.record.isRequired,
onAuthorize: PropTypes.func.isRequired,
onReject: PropTypes.func.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 ScrollableList from '../../components/scrollable_list';
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 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 ScrollableList from '../../components/scrollable_list';
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 Column from '../ui/components/column';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,43 +1,5 @@
// @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
@ -87,7 +49,7 @@
/**
* @typedef InitialState
* @property {Record<string, Account>} accounts
* @property {Record<string, import("./api_types/accounts").ApiAccountJSON>} accounts
* @property {InitialStateLanguage[]} languages
* @property {boolean=} critical_updates_pending
* @property {InitialStateMeta} meta

View file

@ -426,7 +426,7 @@
"notification.admin.sign_up": "{name} registrierte sich",
"notification.favourite": "{name} favorisierte deinen Beitrag",
"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.mention": "{name} erwähnte dich",
"notification.own_poll": "Deine Umfrage ist beendet",

View file

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

View file

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

View file

@ -1,5 +1,6 @@
{
"about.contact": "Kontakt:",
"about.domain_blocks.no_reason_available": "Razlog nije dostupan",
"account.account_note_header": "Bilješka",
"account.add_or_remove_from_list": "Dodaj ili ukloni s liste",
"account.badges.bot": "Bot",
@ -14,6 +15,7 @@
"account.edit_profile": "Uredi profil",
"account.enable_notifications": "Obavjesti me kada @{name} napravi objavu",
"account.endorse": "Istakni na profilu",
"account.featured_tags.last_status_never": "Nema postova",
"account.follow": "Prati",
"account.followers": "Pratitelji",
"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.follows.empty": "Korisnik/ca još ne prati nikoga.",
"account.follows_you": "Prati te",
"account.go_to_profile": "Idi na profil",
"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.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.mention": "Spomeni @{name}",
"account.mute": "Utišaj @{name}",
"account.mute_notifications_short": "Utišaj obavijesti",
"account.mute_short": "Utišaj",
"account.muted": "Utišano",
"account.open_original_page": "Otvori originalnu stranicu",
"account.posts": "Objave",
"account.posts_with_replies": "Objave i odgovori",
"account.report": "Prijavi @{name}",
@ -52,6 +59,7 @@
"alert.unexpected.title": "Ups!",
"announcement.announcement": "Najava",
"attachments_list.unprocessed": "(neobrađeno)",
"audio.hide": "Sakrij audio",
"autosuggest_hashtag.per_week": "{count} tjedno",
"boost_modal.combo": "Možete pritisnuti {combo} kako biste preskočili ovo sljedeći put",
"bundle_column_error.error.title": "Oh, ne!",
@ -66,6 +74,7 @@
"column.community": "Lokalna vremenska crta",
"column.directory": "Pregledavanje profila",
"column.domain_blocks": "Blokirane domene",
"column.favourites": "Favoriti",
"column.follow_requests": "Zahtjevi za praćenje",
"column.home": "Početna",
"column.lists": "Liste",
@ -86,6 +95,8 @@
"community.column_settings.remote_only": "Samo udaljeno",
"compose.language.change": "Promijeni jezik",
"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.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.",
@ -179,6 +190,8 @@
"errors.unexpected_crash.copy_stacktrace": "Kopiraj stacktrace u međuspremnik",
"errors.unexpected_crash.report_issue": "Prijavi problem",
"explore.search_results": "Rezultati pretrage",
"explore.suggested_follows": "Ljudi",
"explore.title": "Pretraži",
"explore.trending_links": "Novosti",
"explore.trending_statuses": "Objave",
"explore.trending_tags": "Hashtagovi",
@ -189,12 +202,17 @@
"filter_modal.select_filter.subtitle": "Odaberite postojeću kategoriju ili stvorite novu",
"filter_modal.select_filter.title": "Filtriraj ovu objavu",
"filter_modal.title.status": "Filtriraj objavu",
"firehose.all": "Sve",
"firehose.local": "Ovaj server",
"follow_request.authorize": "Autoriziraj",
"follow_request.reject": "Odbij",
"footer.about": "O aplikaciji",
"footer.get_app": "Preuzmi aplikaciju",
"footer.invite": "Pozovi ljude",
"footer.keyboard_shortcuts": "Tipkovni prečaci",
"footer.privacy_policy": "Pravila o zaštiti privatnosti",
"footer.source_code": "Prikaz izvornog koda",
"footer.status": "Stanje",
"generic.saved": "Spremljeno",
"getting_started.heading": "Počnimo",
"hashtag.column_header.tag_mode.all": "i {additional}",
@ -212,7 +230,11 @@
"home.column_settings.show_reblogs": "Pokaži boostove",
"home.column_settings.show_replies": "Pokaži odgovore",
"home.hide_announcements": "Sakrij najave",
"home.pending_critical_update.title": "Dostupno je kritično sigurnosno ažuriranje!",
"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.hours": "{number, plural, one {# sat} few {# sata} other {# sati}}",
"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 { isProduction } from 'mastodon/utils/environment';
import { getLocale, isLocaleLoaded } from './global_locale';
import { loadLocale } from './load_locale';
function onProviderError(error: unknown) {
// 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
// As-per the spec, the browser should select the best matching locale

View file

@ -137,7 +137,7 @@
"compose.language.search": "Cari bahasa...",
"compose.published.body": "Pos telah diterbitkan.",
"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.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.",
@ -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.title": "Ini adalah pusat operasi anda dalam Mastodon.",
"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",
"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.",
@ -408,6 +411,7 @@
"navigation_bar.lists": "Senarai",
"navigation_bar.logout": "Log keluar",
"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.pins": "Hantaran disemat",
"navigation_bar.preferences": "Keutamaan",
@ -583,6 +587,7 @@
"search.quick_action.open_url": "Buka URL dalam Mastadon",
"search.quick_action.status_search": "Pos sepadan {x}",
"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.options": "Pilihan carian",
"search_popout.quick_actions": "Tindakan pantas",

View file

@ -61,12 +61,12 @@
"account.requested_follow": "{name} vam želi slediti",
"account.share": "Deli profil 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_domain": "Odblokiraj domeno {domain}",
"account.unblock_short": "Odblokiraj",
"account.unendorse": "Ne vključi v profil",
"account.unfollow": "Prenehaj slediti",
"account.unfollow": "Ne sledi več",
"account.unmute": "Odtišaj @{name}",
"account.unmute_notifications_short": "Izklopi utišanje obvestil",
"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.reply.confirm": "Odgovori",
"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}?",
"conversation.delete": "Izbriši pogovor",
"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.follow": "Sledi 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_suggestions": "Poiščite osebe, ki jim želite slediti",
"home.column_settings.basic": "Osnovno",

View file

@ -7,6 +7,8 @@ import * as perf from 'mastodon/performance';
import ready from 'mastodon/ready';
import { store } from 'mastodon/store';
import { isProduction } from './utils/environment';
/**
* @returns {Promise<void>}
*/
@ -21,7 +23,7 @@ function main() {
root.render(<Mastodon {...props} />);
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 wb = new Workbox('/sw.js');
/** @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';
if (process.env.NODE_ENV === 'development') {
import { isDevelopment } from './utils/environment';
if (isDevelopment()) {
if (typeof performance !== 'undefined' && performance.setResourceTimingBufferSize) {
// Increase Firefox's performance entry limit; otherwise it's capped to 150.
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1331135
@ -18,13 +20,13 @@ if (process.env.NODE_ENV === 'development') {
}
export function start(name) {
if (process.env.NODE_ENV === 'development') {
if (isDevelopment()) {
marky.mark(name);
}
}
export function stop(name) {
if (process.env.NODE_ENV === 'development') {
if (isDevelopment()) {
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';

View file

@ -4,39 +4,18 @@
import { loadIntlPolyfills } from './intl';
function importBasePolyfills() {
return import(/* webpackChunkName: "base_polyfills" */ './base_polyfills');
}
function importExtraPolyfills() {
return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills');
}
export function loadPolyfills() {
const needsBasePolyfills = !(
'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.
// Safari does not have requestIdleCallback.
// 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.AbortController &&
window.IntersectionObserver &&
window.IntersectionObserverEntry &&
'isIntersecting' in IntersectionObserverEntry.prototype &&
window.requestIdleCallback
);
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
const needsExtraPolyfills = !window.requestIdleCallback;
return Promise.all([
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(),
]);
}

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 { 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();
@ -11,10 +11,8 @@ export default function accountsMap(state = initialState, action) {
switch(action.type) {
case ACCOUNT_LOOKUP_FAIL:
return action.error?.response?.status === 404 ? state.set(normalizeForLookup(action.acct), null) : state;
case ACCOUNT_IMPORT:
return state.set(normalizeForLookup(action.account.acct), action.account.id);
case ACCOUNTS_IMPORT:
return state.withMutations(map => action.accounts.forEach(account => map.set(normalizeForLookup(account.acct), account.id)));
case importAccounts.type:
return state.withMutations(map => action.payload.accounts.forEach(account => map.set(normalizeForLookup(account.acct), account.id)));
default:
return state;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,12 @@
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 {
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS,
FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
FOLLOW_REQUEST_REJECT_SUCCESS,
authorizeFollowRequestSuccess,
blockAccountSuccess,
muteAccountSuccess,
rejectFollowRequestSuccess,
} from '../actions/accounts';
import {
focusApp,
@ -16,7 +16,7 @@ import {
MARKERS_FETCH_SUCCESS,
} from '../actions/markers';
import {
NOTIFICATIONS_UPDATE,
notificationsUpdate,
NOTIFICATIONS_EXPAND_SUCCESS,
NOTIFICATIONS_EXPAND_REQUEST,
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);
case NOTIFICATIONS_SCROLL_TOP:
return updateTop(state, action.top);
case NOTIFICATIONS_UPDATE:
return normalizeNotification(state, action.notification, action.usePendingItems);
case notificationsUpdate.type:
return normalizeNotification(state, action.payload.notification, action.payload.usePendingItems);
case NOTIFICATIONS_EXPAND_SUCCESS:
return expandNormalizedNotifications(state, action.notifications, action.next, action.isLoadingMore, action.isLoadingRecent, action.usePendingItems);
case ACCOUNT_BLOCK_SUCCESS:
return filterNotifications(state, [action.relationship.id]);
case ACCOUNT_MUTE_SUCCESS:
return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state;
case DOMAIN_BLOCK_SUCCESS:
return filterNotifications(state, action.accounts);
case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
case FOLLOW_REQUEST_REJECT_SUCCESS:
return filterNotifications(state, [action.id], 'follow_request');
case blockAccountSuccess.type:
return filterNotifications(state, [action.payload.relationship.id]);
case muteAccountSuccess.type:
return action.relationship.muting_notifications ? filterNotifications(state, [action.payload.relationship.id]) : state;
case blockDomainSuccess.type:
return filterNotifications(state, action.payload.accounts);
case authorizeFollowRequestSuccess.type:
case rejectFollowRequestSuccess.type:
return filterNotifications(state, [action.payload.id], 'follow_request');
case NOTIFICATIONS_CLEAR:
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
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 {
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS,
blockAccountSuccess,
muteAccountSuccess,
} from '../actions/accounts';
import {
BOOKMARKED_STATUSES_FETCH_REQUEST,
@ -142,9 +142,9 @@ export default function statusLists(state = initialState, action) {
return prependOneToList(state, 'pins', action.status);
case UNPIN_SUCCESS:
return removeOneFromList(state, 'pins', action.status);
case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS:
return state.updateIn(['trending', 'items'], ImmutableOrderedSet(), list => list.filterNot(statusId => action.statuses.getIn([statusId, 'account']) === action.relationship.id));
case blockAccountSuccess.type:
case muteAccountSuccess.type:
return state.updateIn(['trending', 'items'], ImmutableOrderedSet(), list => list.filterNot(statusId => action.payload.statuses.getIn([statusId, 'account']) === action.payload.relationship.id));
default:
return state;
}

View file

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

View file

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

View file

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

View file

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