diff --git a/Gemfile.lock b/Gemfile.lock index 19bb8ccc92..e89e762265 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -738,7 +738,7 @@ GEM lint_roller (~> 1.1) rubocop (>= 1.72.1, < 2.0) rubocop-ast (>= 1.38.0, < 2.0) - rubocop-rails (2.30.2) + rubocop-rails (2.30.3) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) diff --git a/app/javascript/mastodon/components/scrollable_list.jsx b/app/javascript/mastodon/components/scrollable_list.jsx index 35cd86ea1a..22ec18afa9 100644 --- a/app/javascript/mastodon/components/scrollable_list.jsx +++ b/app/javascript/mastodon/components/scrollable_list.jsx @@ -81,6 +81,7 @@ class ScrollableList extends PureComponent { bindToDocument: PropTypes.bool, preventScroll: PropTypes.bool, footer: PropTypes.node, + className: PropTypes.string, }; static defaultProps = { @@ -325,7 +326,7 @@ class ScrollableList extends PureComponent { }; render () { - const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props; + const { children, scrollKey, className, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props; const { fullscreen } = this.state; const childrenCount = Children.count(children); @@ -336,9 +337,9 @@ class ScrollableList extends PureComponent { if (showLoading) { scrollableArea = (
-
- {prepend} -
+ {prepend} + +
@@ -350,9 +351,9 @@ class ScrollableList extends PureComponent { } else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) { scrollableArea = (
-
- {prepend} + {prepend} +
{loadPending} {Children.map(this.props.children, (child, index) => ( diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.tsx b/app/javascript/mastodon/features/account_gallery/components/media_item.tsx index fef8a1300d..80704c3388 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.tsx +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.tsx @@ -11,11 +11,15 @@ import { Icon } from 'mastodon/components/icon'; import { formatTime } from 'mastodon/features/video'; import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state'; import type { Status, MediaAttachment } from 'mastodon/models/status'; +import { useAppSelector } from 'mastodon/store'; export const MediaItem: React.FC<{ attachment: MediaAttachment; onOpenMedia: (arg0: MediaAttachment) => void; }> = ({ attachment, onOpenMedia }) => { + const account = useAppSelector((state) => + state.accounts.get(attachment.getIn(['status', 'account']) as string), + ); const [visible, setVisible] = useState( (displayMedia !== 'hide_all' && !attachment.getIn(['status', 'sensitive'])) || @@ -70,7 +74,6 @@ export const MediaItem: React.FC<{ const lang = status.get('language') as string; const blurhash = attachment.get('blurhash') as string; const statusId = status.get('id') as string; - const acct = status.getIn(['account', 'acct']) as string; const type = attachment.get('type') as string; let thumbnail; @@ -181,7 +184,7 @@ export const MediaItem: React.FC<{ { - const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]); - - if (!accountId) { - return { - isLoading: true, - }; - } - - return { - accountId, - isAccount: !!state.getIn(['accounts', accountId]), - attachments: getAccountGallery(state, accountId), - isLoading: state.getIn(['timelines', `account:${accountId}:media`, 'isLoading']), - hasMore: state.getIn(['timelines', `account:${accountId}:media`, 'hasMore']), - suspended: state.getIn(['accounts', accountId, 'suspended'], false), - blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false), - }; -}; - -class LoadMoreMedia extends ImmutablePureComponent { - - static propTypes = { - maxId: PropTypes.string, - onLoadMore: PropTypes.func.isRequired, - }; - - handleLoadMore = () => { - this.props.onLoadMore(this.props.maxId); - }; - - render () { - return ( - - ); - } - -} - -class AccountGallery extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.shape({ - acct: PropTypes.string, - id: PropTypes.string, - }).isRequired, - accountId: PropTypes.string, - dispatch: PropTypes.func.isRequired, - attachments: ImmutablePropTypes.list.isRequired, - isLoading: PropTypes.bool, - hasMore: PropTypes.bool, - isAccount: PropTypes.bool, - blockedBy: PropTypes.bool, - suspended: PropTypes.bool, - multiColumn: PropTypes.bool, - }; - - state = { - width: 323, - }; - - _load () { - const { accountId, isAccount, dispatch } = this.props; - - if (!isAccount) dispatch(fetchAccount(accountId)); - dispatch(expandAccountMediaTimeline(accountId)); - } - - componentDidMount () { - const { params: { acct }, accountId, dispatch } = this.props; - - if (accountId) { - this._load(); - } else { - dispatch(lookupAccount(acct)); - } - } - - componentDidUpdate (prevProps) { - const { params: { acct }, accountId, dispatch } = this.props; - - if (prevProps.accountId !== accountId && accountId) { - this._load(); - } else if (prevProps.params.acct !== acct) { - dispatch(lookupAccount(acct)); - } - } - - handleScrollToBottom = () => { - if (this.props.hasMore) { - this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined); - } - }; - - handleScroll = e => { - const { scrollTop, scrollHeight, clientHeight } = e.target; - const offset = scrollHeight - scrollTop - clientHeight; - - if (150 > offset && !this.props.isLoading) { - this.handleScrollToBottom(); - } - }; - - handleLoadMore = maxId => { - this.props.dispatch(expandAccountMediaTimeline(this.props.accountId, { maxId })); - }; - - handleLoadOlder = e => { - e.preventDefault(); - this.handleScrollToBottom(); - }; - - handleOpenMedia = attachment => { - const { dispatch } = this.props; - const statusId = attachment.getIn(['status', 'id']); - const lang = attachment.getIn(['status', 'language']); - - if (attachment.get('type') === 'video') { - dispatch(openModal({ - modalType: 'VIDEO', - modalProps: { media: attachment, statusId, lang, options: { autoPlay: true } }, - })); - } else if (attachment.get('type') === 'audio') { - dispatch(openModal({ - modalType: 'AUDIO', - modalProps: { media: attachment, statusId, lang, options: { autoPlay: true } }, - })); - } else { - const media = attachment.getIn(['status', 'media_attachments']); - const index = media.findIndex(x => x.get('id') === attachment.get('id')); - - dispatch(openModal({ - modalType: 'MEDIA', - modalProps: { media, index, statusId, lang }, - })); - } - }; - - handleRef = c => { - if (c) { - this.setState({ width: c.offsetWidth }); - } - }; - - render () { - const { attachments, isLoading, hasMore, isAccount, multiColumn, blockedBy, suspended } = this.props; - const { width } = this.state; - - if (!isAccount) { - return ( - - ); - } - - if (!attachments && isLoading) { - return ( - - - - ); - } - - let loadOlder = null; - - if (hasMore && !(isLoading && attachments.size === 0)) { - loadOlder = ; - } - - let emptyMessage; - - if (suspended) { - emptyMessage = ; - } else if (blockedBy) { - emptyMessage = ; - } - - return ( - - - - -
- - - {(suspended || blockedBy) ? ( -
- {emptyMessage} -
- ) : ( -
- {attachments.map((attachment, index) => attachment === null ? ( - 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} /> - ) : ( - - ))} - - {loadOlder} -
- )} - - {isLoading && attachments.size === 0 && ( -
- -
- )} -
-
-
- ); - } - -} - -export default connect(mapStateToProps)(AccountGallery); diff --git a/app/javascript/mastodon/features/account_gallery/index.tsx b/app/javascript/mastodon/features/account_gallery/index.tsx new file mode 100644 index 0000000000..60afdadc81 --- /dev/null +++ b/app/javascript/mastodon/features/account_gallery/index.tsx @@ -0,0 +1,283 @@ +import { useEffect, useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { useParams } from 'react-router-dom'; + +import { createSelector } from '@reduxjs/toolkit'; +import type { Map as ImmutableMap } from 'immutable'; +import { List as ImmutableList } from 'immutable'; + +import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts'; +import { openModal } from 'mastodon/actions/modal'; +import { expandAccountMediaTimeline } from 'mastodon/actions/timelines'; +import { ColumnBackButton } from 'mastodon/components/column_back_button'; +import ScrollableList from 'mastodon/components/scrollable_list'; +import { TimelineHint } from 'mastodon/components/timeline_hint'; +import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header'; +import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint'; +import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; +import Column from 'mastodon/features/ui/components/column'; +import type { MediaAttachment } from 'mastodon/models/media_attachment'; +import { normalizeForLookup } from 'mastodon/reducers/accounts_map'; +import { getAccountHidden } from 'mastodon/selectors/accounts'; +import type { RootState } from 'mastodon/store'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +import { MediaItem } from './components/media_item'; + +const getAccountGallery = createSelector( + [ + (state: RootState, accountId: string) => + (state.timelines as ImmutableMap).getIn( + [`account:${accountId}:media`, 'items'], + ImmutableList(), + ) as ImmutableList, + (state: RootState) => state.statuses, + ], + (statusIds, statuses) => { + let items = ImmutableList(); + + statusIds.forEach((statusId) => { + const status = statuses.get(statusId) as + | ImmutableMap + | undefined; + + if (status) { + items = items.concat( + ( + status.get('media_attachments') as ImmutableList + ).map((media) => media.set('status', status)), + ); + } + }); + + return items; + }, +); + +interface Params { + acct?: string; + id?: string; +} + +const RemoteHint: React.FC<{ + accountId: string; +}> = ({ accountId }) => { + const account = useAppSelector((state) => state.accounts.get(accountId)); + const acct = account?.acct; + const url = account?.url; + const domain = acct ? acct.split('@')[1] : undefined; + + if (!url) { + return null; + } + + return ( + + } + label={ + {domain} }} + /> + } + /> + ); +}; + +export const AccountGallery: React.FC<{ + multiColumn: boolean; +}> = ({ multiColumn }) => { + const { acct, id } = useParams(); + const dispatch = useAppDispatch(); + const accountId = useAppSelector( + (state) => + id ?? + (state.accounts_map.get(normalizeForLookup(acct)) as string | undefined), + ); + const attachments = useAppSelector((state) => + accountId + ? getAccountGallery(state, accountId) + : ImmutableList(), + ); + const isLoading = useAppSelector((state) => + (state.timelines as ImmutableMap).getIn([ + `account:${accountId}:media`, + 'isLoading', + ]), + ); + const hasMore = useAppSelector((state) => + (state.timelines as ImmutableMap).getIn([ + `account:${accountId}:media`, + 'hasMore', + ]), + ); + const account = useAppSelector((state) => + accountId ? state.accounts.get(accountId) : undefined, + ); + const blockedBy = useAppSelector( + (state) => + state.relationships.getIn([accountId, 'blocked_by'], false) as boolean, + ); + const suspended = useAppSelector( + (state) => state.accounts.getIn([accountId, 'suspended'], false) as boolean, + ); + const isAccount = !!account; + const remote = account?.acct !== account?.username; + const hidden = useAppSelector((state) => + accountId ? getAccountHidden(state, accountId) : false, + ); + const maxId = attachments.last()?.getIn(['status', 'id']) as + | string + | undefined; + + useEffect(() => { + if (!accountId) { + dispatch(lookupAccount(acct)); + } + }, [dispatch, accountId, acct]); + + useEffect(() => { + if (accountId && !isAccount) { + dispatch(fetchAccount(accountId)); + } + + if (accountId && isAccount) { + void dispatch(expandAccountMediaTimeline(accountId)); + } + }, [dispatch, accountId, isAccount]); + + const handleLoadMore = useCallback(() => { + if (maxId) { + void dispatch(expandAccountMediaTimeline(accountId, { maxId })); + } + }, [dispatch, accountId, maxId]); + + const handleOpenMedia = useCallback( + (attachment: MediaAttachment) => { + const statusId = attachment.getIn(['status', 'id']); + const lang = attachment.getIn(['status', 'language']); + + if (attachment.get('type') === 'video') { + dispatch( + openModal({ + modalType: 'VIDEO', + modalProps: { + media: attachment, + statusId, + lang, + options: { autoPlay: true }, + }, + }), + ); + } else if (attachment.get('type') === 'audio') { + dispatch( + openModal({ + modalType: 'AUDIO', + modalProps: { + media: attachment, + statusId, + lang, + options: { autoPlay: true }, + }, + }), + ); + } else { + const media = attachment.getIn([ + 'status', + 'media_attachments', + ]) as ImmutableList; + const index = media.findIndex( + (x) => x.get('id') === attachment.get('id'), + ); + + dispatch( + openModal({ + modalType: 'MEDIA', + modalProps: { media, index, statusId, lang }, + }), + ); + } + }, + [dispatch], + ); + + if (accountId && !isAccount) { + return ; + } + + let emptyMessage; + + if (accountId) { + if (suspended) { + emptyMessage = ( + + ); + } else if (hidden) { + emptyMessage = ; + } else if (blockedBy) { + emptyMessage = ( + + ); + } else if (remote && attachments.isEmpty()) { + emptyMessage = ; + } else { + emptyMessage = ( + + ); + } + } + + const forceEmptyState = suspended || blockedBy || hidden; + + return ( + + + + + ) + } + alwaysPrepend + append={remote && accountId && } + scrollKey='account_gallery' + isLoading={isLoading} + hasMore={!forceEmptyState && hasMore} + onLoadMore={handleLoadMore} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {attachments.map((attachment) => ( + + ))} + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default AccountGallery; diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index 9970e27d06..f9c01eba6b 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -58,8 +58,8 @@ import { import { getAccountHidden } from 'mastodon/selectors/accounts'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; -import MemorialNote from './memorial_note'; -import MovedNote from './moved_note'; +import { MemorialNote } from './memorial_note'; +import { MovedNote } from './moved_note'; const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, @@ -833,7 +833,7 @@ export const AccountHeader: React.FC<{
{!hidden && account.memorial && } {!hidden && account.moved && ( - + )}
( +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