Merge branch 'main' into fix/subscribed_languages

This commit is contained in:
Benoît Guchet 2025-03-03 22:54:42 +01:00 committed by GitHub
commit 9457283db1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 431 additions and 330 deletions

View File

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

View File

@ -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 = (
<div className='scrollable scrollable--flex' ref={this.setRef}>
<div role='feed' className='item-list'>
{prepend}
</div>
{prepend}
<div role='feed' className='item-list' />
<div className='scrollable__append'>
<LoadingIndicator />
@ -350,9 +351,9 @@ class ScrollableList extends PureComponent {
} else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) {
scrollableArea = (
<div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
<div role='feed' className='item-list'>
{prepend}
{prepend}
<div role='feed' className={classNames('item-list', className)}>
{loadPending}
{Children.map(this.props.children, (child, index) => (

View File

@ -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<{
<a
className='media-gallery__item-thumbnail'
href={`/@${acct}/${statusId}`}
href={`/@${account?.acct}/${statusId}`}
onClick={handleClick}
target='_blank'
rel='noopener noreferrer'

View File

@ -1,241 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { LoadMore } from 'mastodon/components/load_more';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollContainer from 'mastodon/containers/scroll_container';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { getAccountGallery } from 'mastodon/selectors';
import { expandAccountMediaTimeline } from '../../actions/timelines';
import { AccountHeader } from '../account_timeline/components/account_header';
import Column from '../ui/components/column';
import { MediaItem } from './components/media_item';
const mapStateToProps = (state, { params: { acct, id } }) => {
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 (
<LoadMore
disabled={this.props.disabled}
onClick={this.handleLoadMore}
/>
);
}
}
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 (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
if (!attachments && isLoading) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
let loadOlder = null;
if (hasMore && !(isLoading && attachments.size === 0)) {
loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
}
let emptyMessage;
if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (blockedBy) {
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
}
return (
<Column>
<ColumnBackButton />
<ScrollContainer scrollKey='account_gallery'>
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
<AccountHeader accountId={this.props.accountId} />
{(suspended || blockedBy) ? (
<div className='empty-column-indicator'>
{emptyMessage}
</div>
) : (
<div role='feed' className='account-gallery__container' ref={this.handleRef}>
{attachments.map((attachment, index) => attachment === null ? (
<LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
) : (
<MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
))}
{loadOlder}
</div>
)}
{isLoading && attachments.size === 0 && (
<div className='scrollable__append'>
<LoadingIndicator />
</div>
)}
</div>
</ScrollContainer>
</Column>
);
}
}
export default connect(mapStateToProps)(AccountGallery);

View File

@ -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<string, unknown>).getIn(
[`account:${accountId}:media`, 'items'],
ImmutableList(),
) as ImmutableList<string>,
(state: RootState) => state.statuses,
],
(statusIds, statuses) => {
let items = ImmutableList<MediaAttachment>();
statusIds.forEach((statusId) => {
const status = statuses.get(statusId) as
| ImmutableMap<string, unknown>
| undefined;
if (status) {
items = items.concat(
(
status.get('media_attachments') as ImmutableList<MediaAttachment>
).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 (
<TimelineHint
url={url}
message={
<FormattedMessage
id='hints.profiles.posts_may_be_missing'
defaultMessage='Some posts from this profile may be missing.'
/>
}
label={
<FormattedMessage
id='hints.profiles.see_more_posts'
defaultMessage='See more posts on {domain}'
values={{ domain: <strong>{domain}</strong> }}
/>
}
/>
);
};
export const AccountGallery: React.FC<{
multiColumn: boolean;
}> = ({ multiColumn }) => {
const { acct, id } = useParams<Params>();
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<MediaAttachment>(),
);
const isLoading = useAppSelector((state) =>
(state.timelines as ImmutableMap<string, unknown>).getIn([
`account:${accountId}:media`,
'isLoading',
]),
);
const hasMore = useAppSelector((state) =>
(state.timelines as ImmutableMap<string, unknown>).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<MediaAttachment>;
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 <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
let emptyMessage;
if (accountId) {
if (suspended) {
emptyMessage = (
<FormattedMessage
id='empty_column.account_suspended'
defaultMessage='Account suspended'
/>
);
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) {
emptyMessage = (
<FormattedMessage
id='empty_column.account_unavailable'
defaultMessage='Profile unavailable'
/>
);
} else if (remote && attachments.isEmpty()) {
emptyMessage = <RemoteHint accountId={accountId} />;
} else {
emptyMessage = (
<FormattedMessage
id='empty_column.account_timeline'
defaultMessage='No posts found'
/>
);
}
}
const forceEmptyState = suspended || blockedBy || hidden;
return (
<Column>
<ColumnBackButton />
<ScrollableList
className='account-gallery__container'
prepend={
accountId && (
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
)
}
alwaysPrepend
append={remote && accountId && <RemoteHint accountId={accountId} />}
scrollKey='account_gallery'
isLoading={isLoading}
hasMore={!forceEmptyState && hasMore}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{attachments.map((attachment) => (
<MediaItem
key={attachment.get('id') as string}
attachment={attachment}
onOpenMedia={handleOpenMedia}
/>
))}
</ScrollableList>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default AccountGallery;

View File

@ -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<{
<div className='account-timeline__header'>
{!hidden && account.memorial && <MemorialNote />}
{!hidden && account.moved && (
<MovedNote from={account} to={account.moved} />
<MovedNote accountId={account.id} targetAccountId={account.moved} />
)}
<div

View File

@ -1,11 +1,12 @@
import { FormattedMessage } from 'react-intl';
const MemorialNote = () => (
export const MemorialNote: React.FC = () => (
<div className='account-memorial-banner'>
<div className='account-memorial-banner__message'>
<FormattedMessage id='account.in_memoriam' defaultMessage='In Memoriam.' />
<FormattedMessage
id='account.in_memoriam'
defaultMessage='In Memoriam.'
/>
</div>
</div>
);
export default MemorialNote;

View File

@ -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 (
<div className='moved-account-banner'>
<div className='moved-account-banner__message'>
<FormattedMessage id='account.moved_to' defaultMessage='{name} has indicated that their new account is now:' values={{ name: <bdi><strong dangerouslySetInnerHTML={{ __html: from.get('display_name_html') }} /></bdi> }} />
</div>
<div className='moved-account-banner__action'>
<Link to={`/@${to.get('acct')}`} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
<DisplayName account={to} />
</Link>
<Link to={`/@${to.get('acct')}`} className='button'><FormattedMessage id='account.go_to_profile' defaultMessage='Go to profile' /></Link>
</div>
</div>
);
}
}

View File

@ -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 (
<div className='moved-account-banner'>
<div className='moved-account-banner__message'>
<FormattedMessage
id='account.moved_to'
defaultMessage='{name} has indicated that their new account is now:'
values={{
name: (
<bdi>
<strong
dangerouslySetInnerHTML={{
__html: from?.display_name_html ?? '',
}}
/>
</bdi>
),
}}
/>
</div>
<div className='moved-account-banner__action'>
<Link to={`/@${to?.acct}`} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'>
<AvatarOverlay account={to} friend={from} />
</div>
<DisplayName account={to} />
</Link>
<Link to={`/@${to?.acct}`} className='button'>
<FormattedMessage
id='account.go_to_profile'
defaultMessage='Go to profile'
/>
</Link>
</div>
</div>
);
};

View File

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

View File

@ -7398,7 +7398,8 @@ a.status-card {
border-radius: 0;
}
.load-more {
.load-more,
.timeline-hint {
grid-column: span 3;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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