Merge commit 'ce23342d72a7f2ca6f8c35727c86ba54c9c365ca' into glitch-soc/merge-upstream
This commit is contained in:
commit
9d5a9123b9
@ -18,6 +18,7 @@ inherit_from:
|
|||||||
- .rubocop/rspec_rails.yml
|
- .rubocop/rspec_rails.yml
|
||||||
- .rubocop/rspec.yml
|
- .rubocop/rspec.yml
|
||||||
- .rubocop/style.yml
|
- .rubocop/style.yml
|
||||||
|
- .rubocop/i18n.yml
|
||||||
- .rubocop/custom.yml
|
- .rubocop/custom.yml
|
||||||
- .rubocop_todo.yml
|
- .rubocop_todo.yml
|
||||||
- .rubocop/strict.yml
|
- .rubocop/strict.yml
|
||||||
@ -30,6 +31,7 @@ plugins:
|
|||||||
- rubocop-rails
|
- rubocop-rails
|
||||||
- rubocop-rspec
|
- rubocop-rspec
|
||||||
- rubocop-performance
|
- rubocop-performance
|
||||||
|
- rubocop-i18n
|
||||||
|
|
||||||
require:
|
require:
|
||||||
- rubocop-rspec_rails
|
- rubocop-rspec_rails
|
||||||
|
12
.rubocop/i18n.yml
Normal file
12
.rubocop/i18n.yml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
I18n/RailsI18n:
|
||||||
|
Enabled: true
|
||||||
|
Exclude:
|
||||||
|
- 'config/**/*'
|
||||||
|
- 'db/**/*'
|
||||||
|
- 'lib/**/*'
|
||||||
|
- 'spec/**/*'
|
||||||
|
I18n/GetText:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
I18n/RailsI18n/DecorateStringFormattingUsingInterpolation:
|
||||||
|
Enabled: false
|
1
Gemfile
1
Gemfile
@ -165,6 +165,7 @@ group :development do
|
|||||||
# Code linting CLI and plugins
|
# Code linting CLI and plugins
|
||||||
gem 'rubocop', require: false
|
gem 'rubocop', require: false
|
||||||
gem 'rubocop-capybara', require: false
|
gem 'rubocop-capybara', require: false
|
||||||
|
gem 'rubocop-i18n', require: false
|
||||||
gem 'rubocop-performance', require: false
|
gem 'rubocop-performance', require: false
|
||||||
gem 'rubocop-rails', require: false
|
gem 'rubocop-rails', require: false
|
||||||
gem 'rubocop-rspec', require: false
|
gem 'rubocop-rspec', require: false
|
||||||
|
12
Gemfile.lock
12
Gemfile.lock
@ -600,11 +600,11 @@ GEM
|
|||||||
public_suffix (6.0.1)
|
public_suffix (6.0.1)
|
||||||
puma (6.6.0)
|
puma (6.6.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.4.0)
|
pundit (2.5.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (2.2.11)
|
rack (2.2.12)
|
||||||
rack-attack (6.7.0)
|
rack-attack (6.7.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-cors (2.0.2)
|
rack-cors (2.0.2)
|
||||||
@ -669,7 +669,7 @@ GEM
|
|||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
rdoc (6.12.0)
|
rdoc (6.12.0)
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
redcarpet (3.6.0)
|
redcarpet (3.6.1)
|
||||||
redis (4.8.1)
|
redis (4.8.1)
|
||||||
redis-namespace (1.11.0)
|
redis-namespace (1.11.0)
|
||||||
redis (>= 4)
|
redis (>= 4)
|
||||||
@ -719,7 +719,7 @@ GEM
|
|||||||
rspec-mocks (~> 3.0)
|
rspec-mocks (~> 3.0)
|
||||||
sidekiq (>= 5, < 8)
|
sidekiq (>= 5, < 8)
|
||||||
rspec-support (3.13.2)
|
rspec-support (3.13.2)
|
||||||
rubocop (1.73.1)
|
rubocop (1.73.2)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
@ -734,6 +734,9 @@ GEM
|
|||||||
parser (>= 3.3.1.0)
|
parser (>= 3.3.1.0)
|
||||||
rubocop-capybara (2.21.0)
|
rubocop-capybara (2.21.0)
|
||||||
rubocop (~> 1.41)
|
rubocop (~> 1.41)
|
||||||
|
rubocop-i18n (3.2.3)
|
||||||
|
lint_roller (~> 1.1)
|
||||||
|
rubocop (>= 1.72.1)
|
||||||
rubocop-performance (1.24.0)
|
rubocop-performance (1.24.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.72.1, < 2.0)
|
rubocop (>= 1.72.1, < 2.0)
|
||||||
@ -1016,6 +1019,7 @@ DEPENDENCIES
|
|||||||
rspec-sidekiq (~> 5.0)
|
rspec-sidekiq (~> 5.0)
|
||||||
rubocop
|
rubocop
|
||||||
rubocop-capybara
|
rubocop-capybara
|
||||||
|
rubocop-i18n
|
||||||
rubocop-performance
|
rubocop-performance
|
||||||
rubocop-rails
|
rubocop-rails
|
||||||
rubocop-rspec
|
rubocop-rspec
|
||||||
|
@ -81,6 +81,7 @@ class ScrollableList extends PureComponent {
|
|||||||
bindToDocument: PropTypes.bool,
|
bindToDocument: PropTypes.bool,
|
||||||
preventScroll: PropTypes.bool,
|
preventScroll: PropTypes.bool,
|
||||||
footer: PropTypes.node,
|
footer: PropTypes.node,
|
||||||
|
className: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
@ -325,7 +326,7 @@ class ScrollableList extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
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 { fullscreen } = this.state;
|
||||||
const childrenCount = Children.count(children);
|
const childrenCount = Children.count(children);
|
||||||
|
|
||||||
@ -336,9 +337,9 @@ class ScrollableList extends PureComponent {
|
|||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
scrollableArea = (
|
scrollableArea = (
|
||||||
<div className='scrollable scrollable--flex' ref={this.setRef}>
|
<div className='scrollable scrollable--flex' ref={this.setRef}>
|
||||||
<div role='feed' className='item-list'>
|
{prepend}
|
||||||
{prepend}
|
|
||||||
</div>
|
<div role='feed' className='item-list' />
|
||||||
|
|
||||||
<div className='scrollable__append'>
|
<div className='scrollable__append'>
|
||||||
<LoadingIndicator />
|
<LoadingIndicator />
|
||||||
@ -350,9 +351,9 @@ class ScrollableList extends PureComponent {
|
|||||||
} else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) {
|
} else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) {
|
||||||
scrollableArea = (
|
scrollableArea = (
|
||||||
<div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
|
<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}
|
{loadPending}
|
||||||
|
|
||||||
{Children.map(this.props.children, (child, index) => (
|
{Children.map(this.props.children, (child, index) => (
|
||||||
|
@ -11,11 +11,15 @@ import { Icon } from 'mastodon/components/icon';
|
|||||||
import { formatTime } from 'mastodon/features/video';
|
import { formatTime } from 'mastodon/features/video';
|
||||||
import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state';
|
import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state';
|
||||||
import type { Status, MediaAttachment } from 'mastodon/models/status';
|
import type { Status, MediaAttachment } from 'mastodon/models/status';
|
||||||
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
export const MediaItem: React.FC<{
|
export const MediaItem: React.FC<{
|
||||||
attachment: MediaAttachment;
|
attachment: MediaAttachment;
|
||||||
onOpenMedia: (arg0: MediaAttachment) => void;
|
onOpenMedia: (arg0: MediaAttachment) => void;
|
||||||
}> = ({ attachment, onOpenMedia }) => {
|
}> = ({ attachment, onOpenMedia }) => {
|
||||||
|
const account = useAppSelector((state) =>
|
||||||
|
state.accounts.get(attachment.getIn(['status', 'account']) as string),
|
||||||
|
);
|
||||||
const [visible, setVisible] = useState(
|
const [visible, setVisible] = useState(
|
||||||
(displayMedia !== 'hide_all' &&
|
(displayMedia !== 'hide_all' &&
|
||||||
!attachment.getIn(['status', 'sensitive'])) ||
|
!attachment.getIn(['status', 'sensitive'])) ||
|
||||||
@ -70,7 +74,6 @@ export const MediaItem: React.FC<{
|
|||||||
const lang = status.get('language') as string;
|
const lang = status.get('language') as string;
|
||||||
const blurhash = attachment.get('blurhash') as string;
|
const blurhash = attachment.get('blurhash') as string;
|
||||||
const statusId = status.get('id') as string;
|
const statusId = status.get('id') as string;
|
||||||
const acct = status.getIn(['account', 'acct']) as string;
|
|
||||||
const type = attachment.get('type') as string;
|
const type = attachment.get('type') as string;
|
||||||
|
|
||||||
let thumbnail;
|
let thumbnail;
|
||||||
@ -181,7 +184,7 @@ export const MediaItem: React.FC<{
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
className='media-gallery__item-thumbnail'
|
className='media-gallery__item-thumbnail'
|
||||||
href={`/@${acct}/${statusId}`}
|
href={`/@${account?.acct}/${statusId}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
|
@ -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);
|
|
283
app/javascript/mastodon/features/account_gallery/index.tsx
Normal file
283
app/javascript/mastodon/features/account_gallery/index.tsx
Normal 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;
|
@ -91,25 +91,6 @@ export const makeGetReport = () => createSelector([
|
|||||||
(state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]),
|
(state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]),
|
||||||
], (base, targetAccount) => base.set('target_account', targetAccount));
|
], (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([
|
export const getStatusList = createSelector([
|
||||||
(state, type) => state.getIn(['status_lists', type, 'items']),
|
(state, type) => state.getIn(['status_lists', type, 'items']),
|
||||||
], (items) => items.toList());
|
], (items) => items.toList());
|
||||||
|
@ -7398,7 +7398,8 @@ a.status-card {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.load-more {
|
.load-more,
|
||||||
|
.timeline-hint {
|
||||||
grid-column: span 3;
|
grid-column: span 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,7 @@ class HashtagNormalizer
|
|||||||
end
|
end
|
||||||
|
|
||||||
def lowercase(str)
|
def lowercase(str)
|
||||||
str.mb_chars.downcase.to_s
|
str.downcase.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
def cjk_width(str)
|
def cjk_width(str)
|
||||||
|
@ -12,7 +12,7 @@ class VideoMetadataExtractor
|
|||||||
rescue Terrapin::ExitStatusError, Oj::ParseError
|
rescue Terrapin::ExitStatusError, Oj::ParseError
|
||||||
@invalid = true
|
@invalid = true
|
||||||
rescue Terrapin::CommandNotFoundError
|
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
|
end
|
||||||
|
|
||||||
def valid?
|
def valid?
|
||||||
|
@ -160,11 +160,11 @@ class Tag < ApplicationRecord
|
|||||||
private
|
private
|
||||||
|
|
||||||
def validate_name_change
|
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
|
end
|
||||||
|
|
||||||
def validate_display_name_change
|
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,
|
errors.add(:display_name,
|
||||||
I18n.t('tags.does_not_match_previous_name'))
|
I18n.t('tags.does_not_match_previous_name'))
|
||||||
end
|
end
|
||||||
|
@ -95,7 +95,7 @@ class BatchedRemoveStatusService < BaseService
|
|||||||
pipeline.publish(status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', payload)
|
pipeline.publish(status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', payload)
|
||||||
end
|
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}", payload)
|
||||||
pipeline.publish("timeline:hashtag:#{hashtag}:local", payload) if status.local?
|
pipeline.publish("timeline:hashtag:#{hashtag}:local", payload) if status.local?
|
||||||
end
|
end
|
||||||
|
@ -136,8 +136,8 @@ class FanOutOnWriteService < BaseService
|
|||||||
|
|
||||||
def broadcast_to_hashtag_streams!
|
def broadcast_to_hashtag_streams!
|
||||||
@status.tags.map(&:name).each do |hashtag|
|
@status.tags.map(&:name).each do |hashtag|
|
||||||
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", anonymous_payload)
|
redis.publish("timeline:hashtag:#{hashtag.downcase}", anonymous_payload)
|
||||||
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", anonymous_payload) if @status.local?
|
redis.publish("timeline:hashtag:#{hashtag.downcase}:local", anonymous_payload) if @status.local?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -125,8 +125,8 @@ class RemoveStatusService < BaseService
|
|||||||
return if skip_streaming?
|
return if skip_streaming?
|
||||||
|
|
||||||
@status.tags.map(&:name).each do |hashtag|
|
@status.tags.map(&:name).each do |hashtag|
|
||||||
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload)
|
redis.publish("timeline:hashtag:#{hashtag.downcase}", @payload)
|
||||||
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local?
|
redis.publish("timeline:hashtag:#{hashtag.downcase}:local", @payload) if @status.local?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ class NoteLengthValidator < ActiveModel::EachValidator
|
|||||||
private
|
private
|
||||||
|
|
||||||
def too_long?(value)
|
def too_long?(value)
|
||||||
countable_text(value).mb_chars.grapheme_length > options[:maximum]
|
countable_text(value).each_grapheme_cluster.size > options[:maximum]
|
||||||
end
|
end
|
||||||
|
|
||||||
def countable_text(value)
|
def countable_text(value)
|
||||||
|
@ -7,7 +7,7 @@ class PollOptionsValidator < ActiveModel::Validator
|
|||||||
def validate(poll)
|
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_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.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
|
poll.errors.add(:options, I18n.t('polls.errors.duplicate_options')) unless poll.options.uniq.size == poll.options.size
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -18,7 +18,7 @@ class StatusLengthValidator < ActiveModel::Validator
|
|||||||
end
|
end
|
||||||
|
|
||||||
def countable_length(str)
|
def countable_length(str)
|
||||||
str.mb_chars.grapheme_length
|
str.each_grapheme_cluster.size
|
||||||
end
|
end
|
||||||
|
|
||||||
def combined_text(status)
|
def combined_text(status)
|
||||||
|
@ -45,7 +45,7 @@ module Mastodon
|
|||||||
|
|
||||||
def api_versions
|
def api_versions
|
||||||
{
|
{
|
||||||
mastodon: 3,
|
mastodon: 4,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
28
spec/system/admin/terms_of_service/distributions_spec.rb
Normal file
28
spec/system/admin/terms_of_service/distributions_spec.rb
Normal file
@ -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
|
39
spec/system/admin/terms_of_service/drafts_spec.rb
Normal file
39
spec/system/admin/terms_of_service/drafts_spec.rb
Normal file
@ -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
|
40
spec/system/admin/terms_of_service/generates_spec.rb
Normal file
40
spec/system/admin/terms_of_service/generates_spec.rb
Normal file
@ -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
|
18
spec/system/admin/terms_of_service/previews_spec.rb
Normal file
18
spec/system/admin/terms_of_service/previews_spec.rb
Normal file
@ -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
|
25
spec/system/admin/terms_of_service/tests_spec.rb
Normal file
25
spec/system/admin/terms_of_service/tests_spec.rb
Normal file
@ -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
|
@ -30,6 +30,22 @@ RSpec.describe NoteLengthValidator do
|
|||||||
expect(account.errors).to have_received(:add)
|
expect(account.errors).to have_received(:add)
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def starting_string
|
def starting_string
|
||||||
|
@ -41,5 +41,31 @@ RSpec.describe PollOptionsValidator do
|
|||||||
expect(errors).to have_received(:add)
|
expect(errors).to have_received(:add)
|
||||||
end
|
end
|
||||||
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
|
||||||
end
|
end
|
||||||
|
@ -80,6 +80,22 @@ RSpec.describe StatusLengthValidator do
|
|||||||
subject.validate(status)
|
subject.validate(status)
|
||||||
expect(status.errors).to have_received(:add)
|
expect(status.errors).to have_received(:add)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
24
yarn.lock
24
yarn.lock
@ -3264,8 +3264,8 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@reduxjs/toolkit@npm:^2.0.1":
|
"@reduxjs/toolkit@npm:^2.0.1":
|
||||||
version: 2.5.1
|
version: 2.6.0
|
||||||
resolution: "@reduxjs/toolkit@npm:2.5.1"
|
resolution: "@reduxjs/toolkit@npm:2.6.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
immer: "npm:^10.0.3"
|
immer: "npm:^10.0.3"
|
||||||
redux: "npm:^5.0.1"
|
redux: "npm:^5.0.1"
|
||||||
@ -3279,7 +3279,7 @@ __metadata:
|
|||||||
optional: true
|
optional: true
|
||||||
react-redux:
|
react-redux:
|
||||||
optional: true
|
optional: true
|
||||||
checksum: 10c0/e25dd4085e5611d21d4e8d47716072e12318ef8171323d40a80c5b8e79e6d514a973718eb44e41f8491355f7a15e488a0e9f88a97c237327de2615a00b470929
|
checksum: 10c0/3d2c85e56401e72cc7e7f22c5440495c803183afb6e8b67c8d6dd2e6770a9fa56a1b7efdac404608a3ed8f22123e41e8e676fd57657491a81e836df447d9969a
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -5260,13 +5260,13 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"axios@npm:^1.4.0":
|
"axios@npm:^1.4.0":
|
||||||
version: 1.7.9
|
version: 1.8.1
|
||||||
resolution: "axios@npm:1.7.9"
|
resolution: "axios@npm:1.8.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: "npm:^1.15.6"
|
follow-redirects: "npm:^1.15.6"
|
||||||
form-data: "npm:^4.0.0"
|
form-data: "npm:^4.0.0"
|
||||||
proxy-from-env: "npm:^1.1.0"
|
proxy-from-env: "npm:^1.1.0"
|
||||||
checksum: 10c0/b7a41e24b59fee5f0f26c1fc844b45b17442832eb3a0fb42dd4f1430eb4abc571fe168e67913e8a1d91c993232bd1d1ab03e20e4d1fee8c6147649b576fc1b0b
|
checksum: 10c0/b2e1d5a61264502deee4b50f0a6df0aa3b174c546ccf68c0dff714a2b8863232e0bd8cb5b84f853303e97f242a98260f9bb9beabeafe451ad5af538e9eb7ac22
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -6491,9 +6491,9 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"core-js@npm:^3.30.2":
|
"core-js@npm:^3.30.2":
|
||||||
version: 3.40.0
|
version: 3.41.0
|
||||||
resolution: "core-js@npm:3.40.0"
|
resolution: "core-js@npm:3.41.0"
|
||||||
checksum: 10c0/db7946ada881e845d8b157061945b1187618fa45cf162f392a151e8a497962aed2da688c982eaa1d444c864be97a70f8be4d73385294b515d224dd164d19f1d4
|
checksum: 10c0/a29ed0b7fe81acf49d04ce5c17a1947166b1c15197327a5d12f95bbe84b46d60c3c13de701d808f41da06fa316285f3f55ce5903abc8d5642afc1eac4457afc8
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -14777,8 +14777,8 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"react-select@npm:^5.7.3":
|
"react-select@npm:^5.7.3":
|
||||||
version: 5.10.0
|
version: 5.10.1
|
||||||
resolution: "react-select@npm:5.10.0"
|
resolution: "react-select@npm:5.10.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime": "npm:^7.12.0"
|
"@babel/runtime": "npm:^7.12.0"
|
||||||
"@emotion/cache": "npm:^11.4.0"
|
"@emotion/cache": "npm:^11.4.0"
|
||||||
@ -14792,7 +14792,7 @@ __metadata:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
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
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
checksum: 10c0/64cc73ef43556d0a199420d7d19f9f72e3c5e3a7f6828aef5421ec16cc0e4bc337061a8fa3c03afc5b929a087a4ca866f497e0ef865b03fe014c5cacde5e71dd
|
checksum: 10c0/0d10a249b96150bd648f2575d59c848b8fac7f4d368a97ae84e4aaba5bbc1035deba4cdc82e49a43904b79ec50494505809618b0e98022b2d51e7629551912ed
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user