(
+export const MemorialNote: React.FC = () => (
);
-
-export default MemorialNote;
diff --git a/app/javascript/mastodon/features/account_timeline/components/moved_note.jsx b/app/javascript/mastodon/features/account_timeline/components/moved_note.jsx
deleted file mode 100644
index 2c996ff769..0000000000
--- a/app/javascript/mastodon/features/account_timeline/components/moved_note.jsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { FormattedMessage } from 'react-intl';
-
-import { Link } from 'react-router-dom';
-
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-import { AvatarOverlay } from '../../../components/avatar_overlay';
-import { DisplayName } from '../../../components/display_name';
-
-export default class MovedNote extends ImmutablePureComponent {
-
- static propTypes = {
- from: ImmutablePropTypes.map.isRequired,
- to: ImmutablePropTypes.map.isRequired,
- };
-
- render () {
- const { from, to } = this.props;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/account_timeline/components/moved_note.tsx b/app/javascript/mastodon/features/account_timeline/components/moved_note.tsx
new file mode 100644
index 0000000000..51dbb93c8b
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/components/moved_note.tsx
@@ -0,0 +1,53 @@
+import { FormattedMessage } from 'react-intl';
+
+import { Link } from 'react-router-dom';
+
+import { AvatarOverlay } from 'mastodon/components/avatar_overlay';
+import { DisplayName } from 'mastodon/components/display_name';
+import { useAppSelector } from 'mastodon/store';
+
+export const MovedNote: React.FC<{
+ accountId: string;
+ targetAccountId: string;
+}> = ({ accountId, targetAccountId }) => {
+ const from = useAppSelector((state) => state.accounts.get(accountId));
+ const to = useAppSelector((state) => state.accounts.get(targetAccountId));
+
+ return (
+
+
+
+
+
+ ),
+ }}
+ />
+
+
+
+
+ );
+};
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index 6d787272ea..d1523abc44 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -91,25 +91,6 @@ export const makeGetReport = () => createSelector([
(state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]),
], (base, targetAccount) => base.set('target_account', targetAccount));
-export const getAccountGallery = createSelector([
- (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()),
- state => state.get('statuses'),
- (state, id) => state.getIn(['accounts', id]),
-], (statusIds, statuses, account) => {
- let medias = ImmutableList();
-
- statusIds.forEach(statusId => {
- let status = statuses.get(statusId);
-
- if (status) {
- status = status.set('account', account);
- medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status)));
- }
- });
-
- return medias;
-});
-
export const getStatusList = createSelector([
(state, type) => state.getIn(['status_lists', type, 'items']),
], (items) => items.toList());
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 75c38d91f2..5e44553da8 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -7398,7 +7398,8 @@ a.status-card {
border-radius: 0;
}
- .load-more {
+ .load-more,
+ .timeline-hint {
grid-column: span 3;
}
}
diff --git a/app/lib/hashtag_normalizer.rb b/app/lib/hashtag_normalizer.rb
index 49fa6101de..5347271194 100644
--- a/app/lib/hashtag_normalizer.rb
+++ b/app/lib/hashtag_normalizer.rb
@@ -16,7 +16,7 @@ class HashtagNormalizer
end
def lowercase(str)
- str.mb_chars.downcase.to_s
+ str.downcase.to_s
end
def cjk_width(str)
diff --git a/app/models/tag.rb b/app/models/tag.rb
index d29cd220f0..a3ccdd8ac6 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -160,11 +160,11 @@ class Tag < ApplicationRecord
private
def validate_name_change
- errors.add(:name, I18n.t('tags.does_not_match_previous_name')) unless name_was.mb_chars.casecmp(name.mb_chars).zero?
+ errors.add(:name, I18n.t('tags.does_not_match_previous_name')) unless name_was.casecmp(name).zero?
end
def validate_display_name_change
- unless HashtagNormalizer.new.normalize(display_name).casecmp(name.mb_chars).zero?
+ unless HashtagNormalizer.new.normalize(display_name).casecmp(name).zero?
errors.add(:display_name,
I18n.t('tags.does_not_match_previous_name'))
end
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index de4ee16e91..5d6ea2550e 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -92,7 +92,7 @@ class BatchedRemoveStatusService < BaseService
pipeline.publish(status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', payload)
end
- status.tags.map { |tag| tag.name.mb_chars.downcase }.each do |hashtag|
+ status.tags.map { |tag| tag.name.downcase }.each do |hashtag|
pipeline.publish("timeline:hashtag:#{hashtag}", payload)
pipeline.publish("timeline:hashtag:#{hashtag}:local", payload) if status.local?
end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 3c084bc857..f3aa479c15 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -128,8 +128,8 @@ class FanOutOnWriteService < BaseService
def broadcast_to_hashtag_streams!
@status.tags.map(&:name).each do |hashtag|
- redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", anonymous_payload)
- redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", anonymous_payload) if @status.local?
+ redis.publish("timeline:hashtag:#{hashtag.downcase}", anonymous_payload)
+ redis.publish("timeline:hashtag:#{hashtag.downcase}:local", anonymous_payload) if @status.local?
end
end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index dc9fb6cab6..522437aeac 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -123,8 +123,8 @@ class RemoveStatusService < BaseService
return if skip_streaming?
@status.tags.map(&:name).each do |hashtag|
- redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload)
- redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local?
+ redis.publish("timeline:hashtag:#{hashtag.downcase}", @payload)
+ redis.publish("timeline:hashtag:#{hashtag.downcase}:local", @payload) if @status.local?
end
end
diff --git a/app/validators/note_length_validator.rb b/app/validators/note_length_validator.rb
index 554ad49ce2..1a16bbf2b3 100644
--- a/app/validators/note_length_validator.rb
+++ b/app/validators/note_length_validator.rb
@@ -8,7 +8,7 @@ class NoteLengthValidator < ActiveModel::EachValidator
private
def too_long?(value)
- countable_text(value).mb_chars.grapheme_length > options[:maximum]
+ countable_text(value).each_grapheme_cluster.size > options[:maximum]
end
def countable_text(value)
diff --git a/app/validators/poll_options_validator.rb b/app/validators/poll_options_validator.rb
index 0ac84f93f4..fd29fc1b44 100644
--- a/app/validators/poll_options_validator.rb
+++ b/app/validators/poll_options_validator.rb
@@ -7,7 +7,7 @@ class PollOptionsValidator < ActiveModel::Validator
def validate(poll)
poll.errors.add(:options, I18n.t('polls.errors.too_few_options')) unless poll.options.size > 1
poll.errors.add(:options, I18n.t('polls.errors.too_many_options', max: MAX_OPTIONS)) if poll.options.size > MAX_OPTIONS
- poll.errors.add(:options, I18n.t('polls.errors.over_character_limit', max: MAX_OPTION_CHARS)) if poll.options.any? { |option| option.mb_chars.grapheme_length > MAX_OPTION_CHARS }
+ poll.errors.add(:options, I18n.t('polls.errors.over_character_limit', max: MAX_OPTION_CHARS)) if poll.options.any? { |option| option.each_grapheme_cluster.size > MAX_OPTION_CHARS }
poll.errors.add(:options, I18n.t('polls.errors.duplicate_options')) unless poll.options.uniq.size == poll.options.size
end
end
diff --git a/app/validators/status_length_validator.rb b/app/validators/status_length_validator.rb
index dc841ded3e..575aaf1869 100644
--- a/app/validators/status_length_validator.rb
+++ b/app/validators/status_length_validator.rb
@@ -18,7 +18,7 @@ class StatusLengthValidator < ActiveModel::Validator
end
def countable_length(str)
- str.mb_chars.grapheme_length
+ str.each_grapheme_cluster.size
end
def combined_text(status)
diff --git a/spec/validators/note_length_validator_spec.rb b/spec/validators/note_length_validator_spec.rb
index 3fdb4ae8b9..c761c95280 100644
--- a/spec/validators/note_length_validator_spec.rb
+++ b/spec/validators/note_length_validator_spec.rb
@@ -30,6 +30,22 @@ RSpec.describe NoteLengthValidator do
expect(account.errors).to have_received(:add)
end
+ it 'counts multi byte emoji as single character' do
+ text = '✨' * 500
+ account = instance_double(Account, note: text, errors: activemodel_errors)
+
+ subject.validate_each(account, 'note', text)
+ expect(account.errors).to_not have_received(:add)
+ end
+
+ it 'counts ZWJ sequence emoji as single character' do
+ text = '🏳️⚧️' * 500
+ account = instance_double(Account, note: text, errors: activemodel_errors)
+
+ subject.validate_each(account, 'note', text)
+ expect(account.errors).to_not have_received(:add)
+ end
+
private
def starting_string
diff --git a/spec/validators/poll_options_validator_spec.rb b/spec/validators/poll_options_validator_spec.rb
index 9e4ec744db..cc03e9d673 100644
--- a/spec/validators/poll_options_validator_spec.rb
+++ b/spec/validators/poll_options_validator_spec.rb
@@ -41,5 +41,31 @@ RSpec.describe PollOptionsValidator do
expect(errors).to have_received(:add)
end
end
+
+ describe 'character length of poll options' do
+ context 'when poll has acceptable length options' do
+ let(:options) { %w(test this) }
+
+ it 'has no errors' do
+ expect(errors).to_not have_received(:add)
+ end
+ end
+
+ context 'when poll has multibyte and ZWJ emoji options' do
+ let(:options) { ['✨' * described_class::MAX_OPTION_CHARS, '🏳️⚧️' * described_class::MAX_OPTION_CHARS] }
+
+ it 'has no errors' do
+ expect(errors).to_not have_received(:add)
+ end
+ end
+
+ context 'when poll has options that are too long' do
+ let(:options) { ['ok', 'a' * (described_class::MAX_OPTION_CHARS**2)] }
+
+ it 'has errors' do
+ expect(errors).to have_received(:add)
+ end
+ end
+ end
end
end
diff --git a/spec/validators/status_length_validator_spec.rb b/spec/validators/status_length_validator_spec.rb
index ecbfd4ba37..050b7500bb 100644
--- a/spec/validators/status_length_validator_spec.rb
+++ b/spec/validators/status_length_validator_spec.rb
@@ -80,6 +80,22 @@ RSpec.describe StatusLengthValidator do
subject.validate(status)
expect(status.errors).to have_received(:add)
end
+
+ it 'counts multi byte emoji as single character' do
+ text = '✨' * 500
+ status = status_double(text: text)
+
+ subject.validate(status)
+ expect(status.errors).to_not have_received(:add)
+ end
+
+ it 'counts ZWJ sequence emoji as single character' do
+ text = '🏳️⚧️' * 500
+ status = status_double(text: text)
+
+ subject.validate(status)
+ expect(status.errors).to_not have_received(:add)
+ end
end
private
diff --git a/yarn.lock b/yarn.lock
index 0d78276bb3..fe861986aa 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3907,9 +3907,9 @@ __metadata:
linkType: hard
"@types/lodash@npm:^4.14.195":
- version: 4.17.15
- resolution: "@types/lodash@npm:4.17.15"
- checksum: 10c0/2eb2dc6d231f5fb4603d176c08c8d7af688f574d09af47466a179cd7812d9f64144ba74bb32ca014570ffdc544eedc51b7a5657212bad083b6eecbd72223f9bb
+ version: 4.17.16
+ resolution: "@types/lodash@npm:4.17.16"
+ checksum: 10c0/cf017901b8ab1d7aabc86d5189d9288f4f99f19a75caf020c0e2c77b8d4cead4db0d0b842d009b029339f92399f49f34377dd7c2721053388f251778b4c23534
languageName: node
linkType: hard