diff --git a/.rubocop.yml b/.rubocop.yml index bba4282855..39b05d4e01 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -18,6 +18,7 @@ inherit_from: - .rubocop/rspec_rails.yml - .rubocop/rspec.yml - .rubocop/style.yml + - .rubocop/i18n.yml - .rubocop/custom.yml - .rubocop_todo.yml - .rubocop/strict.yml @@ -30,6 +31,7 @@ plugins: - rubocop-rails - rubocop-rspec - rubocop-performance + - rubocop-i18n require: - rubocop-rspec_rails diff --git a/.rubocop/i18n.yml b/.rubocop/i18n.yml new file mode 100644 index 0000000000..de395d3a79 --- /dev/null +++ b/.rubocop/i18n.yml @@ -0,0 +1,12 @@ +I18n/RailsI18n: + Enabled: true + Exclude: + - 'config/**/*' + - 'db/**/*' + - 'lib/**/*' + - 'spec/**/*' +I18n/GetText: + Enabled: false + +I18n/RailsI18n/DecorateStringFormattingUsingInterpolation: + Enabled: false diff --git a/Gemfile b/Gemfile index 9f8c69267a..f4f667ee6b 100644 --- a/Gemfile +++ b/Gemfile @@ -165,6 +165,7 @@ group :development do # Code linting CLI and plugins gem 'rubocop', require: false gem 'rubocop-capybara', require: false + gem 'rubocop-i18n', require: false gem 'rubocop-performance', require: false gem 'rubocop-rails', require: false gem 'rubocop-rspec', require: false diff --git a/Gemfile.lock b/Gemfile.lock index e89e762265..36d2156e4e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -600,11 +600,11 @@ GEM public_suffix (6.0.1) puma (6.6.0) nio4r (~> 2.0) - pundit (2.4.0) + pundit (2.5.0) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.11) + rack (2.2.12) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (2.0.2) @@ -669,7 +669,7 @@ GEM rdf (~> 3.3) rdoc (6.12.0) psych (>= 4.0.0) - redcarpet (3.6.0) + redcarpet (3.6.1) redis (4.8.1) redis-namespace (1.11.0) redis (>= 4) @@ -719,7 +719,7 @@ GEM rspec-mocks (~> 3.0) sidekiq (>= 5, < 8) rspec-support (3.13.2) - rubocop (1.73.1) + rubocop (1.73.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -734,6 +734,9 @@ GEM parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) + rubocop-i18n (3.2.3) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) rubocop-performance (1.24.0) lint_roller (~> 1.1) rubocop (>= 1.72.1, < 2.0) @@ -1016,6 +1019,7 @@ DEPENDENCIES rspec-sidekiq (~> 5.0) rubocop rubocop-capybara + rubocop-i18n rubocop-performance rubocop-rails rubocop-rspec diff --git a/app/javascript/flavours/glitch/components/scrollable_list.jsx b/app/javascript/flavours/glitch/components/scrollable_list.jsx index c7108c28e1..836cc5ee20 100644 --- a/app/javascript/flavours/glitch/components/scrollable_list.jsx +++ b/app/javascript/flavours/glitch/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 || hasMore || !emptyMessage) { scrollableArea = (
-
- {prepend} + {prepend} +
{loadPending} {Children.map(this.props.children, (child, index) => ( diff --git a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.tsx b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.tsx index 1d2c5e652d..f398505a9a 100644 --- a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.tsx +++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.tsx @@ -15,11 +15,15 @@ import { useBlurhash, } from 'flavours/glitch/initial_state'; import type { Status, MediaAttachment } from 'flavours/glitch/models/status'; +import { useAppSelector } from 'flavours/glitch/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,7 @@ export const MediaItem: React.FC<{ attachment.get('description')) as string | undefined; const previewUrl = attachment.get('preview_url') as string; const fullUrl = attachment.get('url') as string; - const avatarUrl = status.getIn(['account', 'avatar_static']) as string; + const avatarUrl = account?.avatar_static; const lang = status.get('language') as string; const blurhash = attachment.get('blurhash') as string; const statusUrl = status.get('url') as string; diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.jsx b/app/javascript/flavours/glitch/features/account_gallery/index.jsx deleted file mode 100644 index b40a0cac41..0000000000 --- a/app/javascript/flavours/glitch/features/account_gallery/index.jsx +++ /dev/null @@ -1,239 +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 'flavours/glitch/actions/accounts'; -import { openModal } from 'flavours/glitch/actions/modal'; -import { LoadMore } from 'flavours/glitch/components/load_more'; -import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; -import ScrollContainer from 'flavours/glitch/containers/scroll_container'; -import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; -import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; -import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map'; -import { getAccountGallery } from 'flavours/glitch/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), - }; -}; - -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, - 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)); - } - } - - handleHeaderClick = () => { - this.column.scrollTop(); - }; - - 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(); - }; - - setColumnRef = c => { - this.column = c; - }; - - 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, suspended } = this.props; - const { width } = this.state; - - if (!isAccount) { - return ( - - ); - } - - if (!attachments && isLoading) { - return ( - - - - ); - } - - let loadOlder = null; - - if (hasMore && !(isLoading && attachments.size === 0)) { - loadOlder = ; - } - - return ( - - - - -
- - - {suspended ? ( -
- -
- ) : ( -
- {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/flavours/glitch/features/account_gallery/index.tsx b/app/javascript/flavours/glitch/features/account_gallery/index.tsx new file mode 100644 index 0000000000..c95cda074c --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_gallery/index.tsx @@ -0,0 +1,291 @@ +import { useEffect, useCallback } from 'react'; + +import { FormattedMessage, useIntl, defineMessages } 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 PersonIcon from '@/material-icons/400-24px/person.svg?react'; +import { lookupAccount, fetchAccount } from 'flavours/glitch/actions/accounts'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; +import { TimelineHint } from 'flavours/glitch/components/timeline_hint'; +import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header'; +import { LimitedAccountHint } from 'flavours/glitch/features/account_timeline/components/limited_account_hint'; +import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; +import Column from 'flavours/glitch/features/ui/components/column'; +import type { MediaAttachment } from 'flavours/glitch/models/media_attachment'; +import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map'; +import { getAccountHidden } from 'flavours/glitch/selectors/accounts'; +import type { RootState } from 'flavours/glitch/store'; +import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; + +import { MediaItem } from './components/media_item'; + +const messages = defineMessages({ + profile: { id: 'column_header.profile', defaultMessage: 'Profile' }, +}); + +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 intl = useIntl(); + 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/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js index b686e32402..303f75470d 100644 --- a/app/javascript/flavours/glitch/selectors/index.js +++ b/app/javascript/flavours/glitch/selectors/index.js @@ -92,25 +92,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/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index 9778d87dd8..1258368dd7 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -7805,7 +7805,8 @@ img.modal-warning { border-radius: 0; } - .load-more { + .load-more, + .timeline-hint { grid-column: span 3; } } 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/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 05d0e74e22..937790ebcf 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/lib/video_metadata_extractor.rb b/app/lib/video_metadata_extractor.rb index 6e544486d6..33a6264b4f 100644 --- a/app/lib/video_metadata_extractor.rb +++ b/app/lib/video_metadata_extractor.rb @@ -12,7 +12,7 @@ class VideoMetadataExtractor rescue Terrapin::ExitStatusError, Oj::ParseError @invalid = true rescue Terrapin::CommandNotFoundError - raise Paperclip::Errors::CommandNotFoundError, 'Could not run the `ffprobe` command. Please install ffmpeg.' + raise Paperclip::Errors::CommandNotFoundError, 'Could not run the `ffprobe` command. Please install ffmpeg.' # rubocop:disable I18n/RailsI18n/DecorateString -- This error is not user-facing end def valid? 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 6ce29d7b8b..9326eed26c 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -95,7 +95,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 3ad57cbea3..94919e9a22 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -136,8 +136,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 3b7a1b8580..25796118a3 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -125,8 +125,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 cfd808c9c3..a4bbb129fc 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 f4725e05fc..4f03a312cb 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/lib/mastodon/version.rb b/lib/mastodon/version.rb index 1e7e5a0f5c..b36b0523b4 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -45,7 +45,7 @@ module Mastodon def api_versions { - mastodon: 3, + mastodon: 4, } end diff --git a/spec/controllers/admin/terms_of_service/distributions_controller_spec.rb b/spec/controllers/admin/terms_of_service/distributions_controller_spec.rb deleted file mode 100644 index 63431c1336..0000000000 --- a/spec/controllers/admin/terms_of_service/distributions_controller_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Admin::TermsOfService::DistributionsController do - render_views - - let(:user) { Fabricate(:admin_user) } - let(:terms_of_service) { Fabricate(:terms_of_service, notification_sent_at: nil) } - - before do - sign_in user, scope: :user - end - - describe 'POST #create' do - it 'returns http success' do - post :create, params: { terms_of_service_id: terms_of_service.id } - - expect(response).to redirect_to(admin_terms_of_service_index_path) - end - end -end diff --git a/spec/controllers/admin/terms_of_service/drafts_controller_spec.rb b/spec/controllers/admin/terms_of_service/drafts_controller_spec.rb deleted file mode 100644 index 0a9c6e6b67..0000000000 --- a/spec/controllers/admin/terms_of_service/drafts_controller_spec.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Admin::TermsOfService::DraftsController do - render_views - - let(:user) { Fabricate(:admin_user) } - - before do - sign_in user, scope: :user - end - - describe 'GET #show' do - it 'returns http success' do - get :show - - expect(response).to have_http_status(:success) - end - end - - describe 'PUT #update' do - subject { put :update, params: params } - - let!(:terms) { Fabricate :terms_of_service, published_at: nil } - - context 'with publishing params' do - let(:params) { { terms_of_service: { text: 'new' }, action_type: 'publish' } } - - it 'publishes the record' do - expect { subject } - .to change(Admin::ActionLog, :count).by(1) - - expect(response) - .to redirect_to(admin_terms_of_service_index_path) - expect(terms.reload.published_at) - .to_not be_nil - end - end - - context 'with non publishing params' do - let(:params) { { terms_of_service: { text: 'new' }, action_type: 'save_draft' } } - - it 'updates but does not publish the record' do - expect { subject } - .to_not change(Admin::ActionLog, :count) - - expect(response) - .to redirect_to(admin_terms_of_service_draft_path) - expect(terms.reload.published_at) - .to be_nil - end - end - - context 'with invalid params' do - let(:params) { { terms_of_service: { text: '' }, action_type: 'save_draft' } } - - it 'does not update the record' do - subject - - expect(response) - .to have_http_status(:success) - end - end - end -end diff --git a/spec/controllers/admin/terms_of_service/generates_controller_spec.rb b/spec/controllers/admin/terms_of_service/generates_controller_spec.rb deleted file mode 100644 index b528bfb57a..0000000000 --- a/spec/controllers/admin/terms_of_service/generates_controller_spec.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Admin::TermsOfService::GeneratesController do - render_views - - let(:user) { Fabricate(:admin_user) } - - before do - sign_in user, scope: :user - end - - describe 'GET #show' do - it 'returns http success' do - get :show - - expect(response).to have_http_status(:success) - end - end - - describe 'POST #create' do - subject { post :create, params: params } - - context 'with valid params' do - let(:params) do - { - terms_of_service_generator: { - admin_email: 'test@host.example', - arbitration_address: '123 Main Street', - arbitration_website: 'https://host.example', - dmca_address: '123 DMCA Ave', - dmca_email: 'dmca@host.example', - domain: 'host.example', - jurisdiction: 'Europe', - choice_of_law: 'New York', - }, - } - end - - it 'saves new record' do - expect { subject } - .to change(TermsOfService, :count).by(1) - expect(response) - .to redirect_to(admin_terms_of_service_draft_path) - end - end - - context 'with invalid params' do - let(:params) do - { - terms_of_service_generator: { - admin_email: 'what the', - }, - } - end - - it 'does not save new record' do - expect { subject } - .to_not change(TermsOfService, :count) - expect(response) - .to have_http_status(200) - end - end - end -end diff --git a/spec/controllers/admin/terms_of_service/previews_controller_spec.rb b/spec/controllers/admin/terms_of_service/previews_controller_spec.rb deleted file mode 100644 index 3878bb4b6f..0000000000 --- a/spec/controllers/admin/terms_of_service/previews_controller_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Admin::TermsOfService::PreviewsController do - render_views - - let(:user) { Fabricate(:admin_user) } - let(:terms_of_service) { Fabricate(:terms_of_service, notification_sent_at: nil) } - - before do - sign_in user, scope: :user - end - - describe 'GET #show' do - it 'returns http success' do - get :show, params: { terms_of_service_id: terms_of_service.id } - - expect(response).to have_http_status(:success) - end - end -end diff --git a/spec/controllers/admin/terms_of_service/tests_controller_spec.rb b/spec/controllers/admin/terms_of_service/tests_controller_spec.rb deleted file mode 100644 index 777f699448..0000000000 --- a/spec/controllers/admin/terms_of_service/tests_controller_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Admin::TermsOfService::TestsController do - render_views - - let(:user) { Fabricate(:admin_user) } - let(:terms_of_service) { Fabricate(:terms_of_service, notification_sent_at: nil) } - - before do - sign_in user, scope: :user - end - - describe 'POST #create' do - it 'returns http success' do - post :create, params: { terms_of_service_id: terms_of_service.id } - - expect(response).to redirect_to(admin_terms_of_service_preview_path(terms_of_service)) - end - end -end diff --git a/spec/controllers/admin/terms_of_service_controller_spec.rb b/spec/controllers/admin/terms_of_service_controller_spec.rb deleted file mode 100644 index feefd312e3..0000000000 --- a/spec/controllers/admin/terms_of_service_controller_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Admin::TermsOfServiceController do - render_views - - let(:user) { Fabricate(:admin_user) } - - before do - sign_in user, scope: :user - end - - describe 'GET #index' do - it 'returns http success' do - get :index - - expect(response).to have_http_status(:success) - end - end -end diff --git a/spec/system/admin/terms_of_service/distributions_spec.rb b/spec/system/admin/terms_of_service/distributions_spec.rb new file mode 100644 index 0000000000..ba525d09c0 --- /dev/null +++ b/spec/system/admin/terms_of_service/distributions_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Admin TermsOfService Distributions' do + let(:user) { Fabricate(:admin_user) } + let(:terms_of_service) { Fabricate(:terms_of_service, notification_sent_at: nil) } + + before { sign_in(user) } + + describe 'Sending a TOS change notification', :inline_jobs do + it 'marks the TOS as notified and sends the email' do + visit admin_terms_of_service_preview_path(terms_of_service) + expect(page) + .to have_title(I18n.t('admin.terms_of_service.preview.title')) + + emails = capture_emails do + expect { click_on I18n.t('admin.terms_of_service.preview.send_to_all', count: 1, display_count: 1) } + .to(change { terms_of_service.reload.notification_sent_at }) + end + expect(emails.first) + .to be_present + .and(deliver_to(user.email)) + expect(page) + .to have_title(I18n.t('admin.terms_of_service.title')) + end + end +end diff --git a/spec/system/admin/terms_of_service/drafts_spec.rb b/spec/system/admin/terms_of_service/drafts_spec.rb new file mode 100644 index 0000000000..3c272694cb --- /dev/null +++ b/spec/system/admin/terms_of_service/drafts_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Admin TermsOfService Drafts' do + before { sign_in(admin_user) } + + describe 'Managing TOS drafts' do + let!(:terms) { Fabricate :terms_of_service, published_at: nil } + + it 'saves and publishes TOS drafts' do + visit admin_terms_of_service_draft_path + expect(page) + .to have_title(I18n.t('admin.terms_of_service.title')) + + # Invalid submission + expect { click_on I18n.t('admin.terms_of_service.save_draft') } + .to_not(change { terms.reload.published_at }) + expect(page) + .to have_title(I18n.t('admin.terms_of_service.title')) + + # Valid submission with draft button + fill_in 'terms_of_service_text', with: 'new' + expect { click_on I18n.t('admin.terms_of_service.save_draft') } + .to not_change { terms.reload.published_at }.from(nil) + .and not_change(Admin::ActionLog, :count) + expect(page) + .to have_title(I18n.t('admin.terms_of_service.title')) + + # Valid with publish button + fill_in 'terms_of_service_text', with: 'newer' + expect { click_on I18n.t('admin.terms_of_service.publish') } + .to change { terms.reload.published_at }.from(nil) + .and change(Admin::ActionLog, :count) + expect(page) + .to have_title(I18n.t('admin.terms_of_service.title')) + end + end +end diff --git a/spec/system/admin/terms_of_service/generates_spec.rb b/spec/system/admin/terms_of_service/generates_spec.rb new file mode 100644 index 0000000000..2307d245b0 --- /dev/null +++ b/spec/system/admin/terms_of_service/generates_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Admin TermsOfService Generates' do + before { sign_in(admin_user) } + + describe 'Generating a TOS policy' do + it 'saves a new TOS from values' do + visit admin_terms_of_service_generate_path + expect(page) + .to have_title(I18n.t('admin.terms_of_service.generates.title')) + + # Invalid form submission + fill_in 'terms_of_service_generator_admin_email', with: 'what the' + expect { submit_form } + .to_not change(TermsOfService, :count) + expect(page) + .to have_title(I18n.t('admin.terms_of_service.generates.title')) + + # Valid submission + fill_in 'terms_of_service_generator_admin_email', with: 'test@host.example' + fill_in 'terms_of_service_generator_arbitration_address', with: '123 Main Street' + fill_in 'terms_of_service_generator_arbitration_website', with: 'https://host.example' + fill_in 'terms_of_service_generator_dmca_address', with: '123 DMCA Ave' + fill_in 'terms_of_service_generator_dmca_email', with: 'dmca@host.example' + fill_in 'terms_of_service_generator_domain', with: 'host.example' + fill_in 'terms_of_service_generator_jurisdiction', with: 'Europe' + fill_in 'terms_of_service_generator_choice_of_law', with: 'New York' + expect { submit_form } + .to change(TermsOfService, :count).by(1) + expect(page) + .to have_title(I18n.t('admin.terms_of_service.title')) + end + + def submit_form + click_on I18n.t('admin.terms_of_service.generates.action') + end + end +end diff --git a/spec/system/admin/terms_of_service/previews_spec.rb b/spec/system/admin/terms_of_service/previews_spec.rb new file mode 100644 index 0000000000..f6030c62a9 --- /dev/null +++ b/spec/system/admin/terms_of_service/previews_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Admin TermsOfService Previews' do + let(:terms_of_service) { Fabricate(:terms_of_service, notification_sent_at: nil) } + + before { sign_in(admin_user) } + + describe 'Viewing TOS previews' do + it 'shows the TOS preview page' do + visit admin_terms_of_service_preview_path(terms_of_service) + + expect(page) + .to have_title(I18n.t('admin.terms_of_service.preview.title')) + end + end +end diff --git a/spec/system/admin/terms_of_service/tests_spec.rb b/spec/system/admin/terms_of_service/tests_spec.rb new file mode 100644 index 0000000000..3fc7d4e75d --- /dev/null +++ b/spec/system/admin/terms_of_service/tests_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Admin TermsOfService Tests' do + let(:user) { Fabricate(:admin_user) } + let(:terms_of_service) { Fabricate(:terms_of_service, notification_sent_at: nil) } + + before { sign_in(user) } + + describe 'Sending test TOS email', :inline_jobs do + it 'generates the test email' do + visit admin_terms_of_service_preview_path(terms_of_service) + expect(page) + .to have_title(I18n.t('admin.terms_of_service.preview.title')) + + emails = capture_emails { click_on I18n.t('admin.terms_of_service.preview.send_preview', email: user.email) } + expect(emails.first) + .to be_present + .and(deliver_to(user.email)) + expect(page) + .to have_title(I18n.t('admin.terms_of_service.preview.title')) + end + end +end 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 ec2f55f18a..c25654d589 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3264,8 +3264,8 @@ __metadata: linkType: hard "@reduxjs/toolkit@npm:^2.0.1": - version: 2.5.1 - resolution: "@reduxjs/toolkit@npm:2.5.1" + version: 2.6.0 + resolution: "@reduxjs/toolkit@npm:2.6.0" dependencies: immer: "npm:^10.0.3" redux: "npm:^5.0.1" @@ -3279,7 +3279,7 @@ __metadata: optional: true react-redux: optional: true - checksum: 10c0/e25dd4085e5611d21d4e8d47716072e12318ef8171323d40a80c5b8e79e6d514a973718eb44e41f8491355f7a15e488a0e9f88a97c237327de2615a00b470929 + checksum: 10c0/3d2c85e56401e72cc7e7f22c5440495c803183afb6e8b67c8d6dd2e6770a9fa56a1b7efdac404608a3ed8f22123e41e8e676fd57657491a81e836df447d9969a languageName: node linkType: hard @@ -5260,13 +5260,13 @@ __metadata: linkType: hard "axios@npm:^1.4.0": - version: 1.7.9 - resolution: "axios@npm:1.7.9" + version: 1.8.1 + resolution: "axios@npm:1.8.1" dependencies: follow-redirects: "npm:^1.15.6" form-data: "npm:^4.0.0" proxy-from-env: "npm:^1.1.0" - checksum: 10c0/b7a41e24b59fee5f0f26c1fc844b45b17442832eb3a0fb42dd4f1430eb4abc571fe168e67913e8a1d91c993232bd1d1ab03e20e4d1fee8c6147649b576fc1b0b + checksum: 10c0/b2e1d5a61264502deee4b50f0a6df0aa3b174c546ccf68c0dff714a2b8863232e0bd8cb5b84f853303e97f242a98260f9bb9beabeafe451ad5af538e9eb7ac22 languageName: node linkType: hard @@ -6491,9 +6491,9 @@ __metadata: linkType: hard "core-js@npm:^3.30.2": - version: 3.40.0 - resolution: "core-js@npm:3.40.0" - checksum: 10c0/db7946ada881e845d8b157061945b1187618fa45cf162f392a151e8a497962aed2da688c982eaa1d444c864be97a70f8be4d73385294b515d224dd164d19f1d4 + version: 3.41.0 + resolution: "core-js@npm:3.41.0" + checksum: 10c0/a29ed0b7fe81acf49d04ce5c17a1947166b1c15197327a5d12f95bbe84b46d60c3c13de701d808f41da06fa316285f3f55ce5903abc8d5642afc1eac4457afc8 languageName: node linkType: hard @@ -14777,8 +14777,8 @@ __metadata: linkType: hard "react-select@npm:^5.7.3": - version: 5.10.0 - resolution: "react-select@npm:5.10.0" + version: 5.10.1 + resolution: "react-select@npm:5.10.1" dependencies: "@babel/runtime": "npm:^7.12.0" "@emotion/cache": "npm:^11.4.0" @@ -14792,7 +14792,7 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/64cc73ef43556d0a199420d7d19f9f72e3c5e3a7f6828aef5421ec16cc0e4bc337061a8fa3c03afc5b929a087a4ca866f497e0ef865b03fe014c5cacde5e71dd + checksum: 10c0/0d10a249b96150bd648f2575d59c848b8fac7f4d368a97ae84e4aaba5bbc1035deba4cdc82e49a43904b79ec50494505809618b0e98022b2d51e7629551912ed languageName: node linkType: hard