mirror of
https://git.kescher.at/CatCatNya/catstodon.git
synced 2024-11-30 00:39:02 +01:00
Refactor initial state: reduce_motion and auto_play_gif (#5501)
This commit is contained in:
parent
e4080772b5
commit
3de22a82bf
13 changed files with 27 additions and 52 deletions
|
@ -6,6 +6,7 @@ import IconButton from './icon_button';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import { isIOS } from '../is_mobile';
|
import { isIOS } from '../is_mobile';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { autoPlayGif } from '../initial_state';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
|
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
|
||||||
|
@ -23,11 +24,9 @@ class Item extends React.PureComponent {
|
||||||
index: PropTypes.number.isRequired,
|
index: PropTypes.number.isRequired,
|
||||||
size: PropTypes.number.isRequired,
|
size: PropTypes.number.isRequired,
|
||||||
onClick: PropTypes.func.isRequired,
|
onClick: PropTypes.func.isRequired,
|
||||||
autoPlayGif: PropTypes.bool,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
autoPlayGif: false,
|
|
||||||
standalone: false,
|
standalone: false,
|
||||||
index: 0,
|
index: 0,
|
||||||
size: 1,
|
size: 1,
|
||||||
|
@ -47,7 +46,7 @@ class Item extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
hoverToPlay () {
|
hoverToPlay () {
|
||||||
const { attachment, autoPlayGif } = this.props;
|
const { attachment } = this.props;
|
||||||
return !autoPlayGif && attachment.get('type') === 'gifv';
|
return !autoPlayGif && attachment.get('type') === 'gifv';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -139,7 +138,7 @@ class Item extends React.PureComponent {
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
} else if (attachment.get('type') === 'gifv') {
|
} else if (attachment.get('type') === 'gifv') {
|
||||||
const autoPlay = !isIOS() && this.props.autoPlayGif;
|
const autoPlay = !isIOS() && autoPlayGif;
|
||||||
|
|
||||||
thumbnail = (
|
thumbnail = (
|
||||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
||||||
|
@ -181,11 +180,9 @@ export default class MediaGallery extends React.PureComponent {
|
||||||
height: PropTypes.number.isRequired,
|
height: PropTypes.number.isRequired,
|
||||||
onOpenMedia: PropTypes.func.isRequired,
|
onOpenMedia: PropTypes.func.isRequired,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
autoPlayGif: PropTypes.bool,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
autoPlayGif: false,
|
|
||||||
standalone: false,
|
standalone: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -261,9 +258,9 @@ export default class MediaGallery extends React.PureComponent {
|
||||||
const size = media.take(4).size;
|
const size = media.take(4).size;
|
||||||
|
|
||||||
if (this.isStandaloneEligible()) {
|
if (this.isStandaloneEligible()) {
|
||||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} autoPlayGif={this.props.autoPlayGif} />;
|
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />;
|
||||||
} else {
|
} else {
|
||||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
|
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -38,7 +38,6 @@ export default class Status extends ImmutablePureComponent {
|
||||||
onHeightChange: PropTypes.func,
|
onHeightChange: PropTypes.func,
|
||||||
me: PropTypes.string,
|
me: PropTypes.string,
|
||||||
boostModal: PropTypes.bool,
|
boostModal: PropTypes.bool,
|
||||||
autoPlayGif: PropTypes.bool,
|
|
||||||
muted: PropTypes.bool,
|
muted: PropTypes.bool,
|
||||||
hidden: PropTypes.bool,
|
hidden: PropTypes.bool,
|
||||||
onMoveUp: PropTypes.func,
|
onMoveUp: PropTypes.func,
|
||||||
|
@ -56,7 +55,6 @@ export default class Status extends ImmutablePureComponent {
|
||||||
'account',
|
'account',
|
||||||
'me',
|
'me',
|
||||||
'boostModal',
|
'boostModal',
|
||||||
'autoPlayGif',
|
|
||||||
'muted',
|
'muted',
|
||||||
'hidden',
|
'hidden',
|
||||||
]
|
]
|
||||||
|
@ -197,7 +195,7 @@ export default class Status extends ImmutablePureComponent {
|
||||||
} else {
|
} else {
|
||||||
media = (
|
media = (
|
||||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
|
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
|
||||||
{Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />}
|
{Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />}
|
||||||
</Bundle>
|
</Bundle>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,15 +6,14 @@ import { hydrateStore } from '../actions/store';
|
||||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||||
import { getLocale } from '../locales';
|
import { getLocale } from '../locales';
|
||||||
import Compose from '../features/standalone/compose';
|
import Compose from '../features/standalone/compose';
|
||||||
|
import initialState from '../initial_state';
|
||||||
|
|
||||||
const { localeData, messages } = getLocale();
|
const { localeData, messages } = getLocale();
|
||||||
addLocaleData(localeData);
|
addLocaleData(localeData);
|
||||||
|
|
||||||
const store = configureStore();
|
const store = configureStore();
|
||||||
const initialStateContainer = document.getElementById('initial-state');
|
|
||||||
|
|
||||||
if (initialStateContainer !== null) {
|
if (initialState) {
|
||||||
const initialState = JSON.parse(initialStateContainer.textContent);
|
|
||||||
store.dispatch(hydrateStore(initialState));
|
store.dispatch(hydrateStore(initialState));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,12 +10,13 @@ import { hydrateStore } from '../actions/store';
|
||||||
import { connectUserStream } from '../actions/streaming';
|
import { connectUserStream } from '../actions/streaming';
|
||||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||||
import { getLocale } from '../locales';
|
import { getLocale } from '../locales';
|
||||||
|
import initialState from '../initial_state';
|
||||||
|
|
||||||
const { localeData, messages } = getLocale();
|
const { localeData, messages } = getLocale();
|
||||||
addLocaleData(localeData);
|
addLocaleData(localeData);
|
||||||
|
|
||||||
export const store = configureStore();
|
export const store = configureStore();
|
||||||
const hydrateAction = hydrateStore(JSON.parse(document.getElementById('initial-state').textContent));
|
const hydrateAction = hydrateStore(initialState);
|
||||||
store.dispatch(hydrateAction);
|
store.dispatch(hydrateAction);
|
||||||
|
|
||||||
export default class Mastodon extends React.PureComponent {
|
export default class Mastodon extends React.PureComponent {
|
||||||
|
|
|
@ -38,7 +38,6 @@ const makeMapStateToProps = () => {
|
||||||
me: state.getIn(['meta', 'me']),
|
me: state.getIn(['meta', 'me']),
|
||||||
boostModal: state.getIn(['meta', 'boost_modal']),
|
boostModal: state.getIn(['meta', 'boost_modal']),
|
||||||
deleteModal: state.getIn(['meta', 'delete_modal']),
|
deleteModal: state.getIn(['meta', 'delete_modal']),
|
||||||
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapStateToProps;
|
return mapStateToProps;
|
||||||
|
|
|
@ -7,15 +7,14 @@ import { IntlProvider, addLocaleData } from 'react-intl';
|
||||||
import { getLocale } from '../locales';
|
import { getLocale } from '../locales';
|
||||||
import PublicTimeline from '../features/standalone/public_timeline';
|
import PublicTimeline from '../features/standalone/public_timeline';
|
||||||
import HashtagTimeline from '../features/standalone/hashtag_timeline';
|
import HashtagTimeline from '../features/standalone/hashtag_timeline';
|
||||||
|
import initialState from '../initial_state';
|
||||||
|
|
||||||
const { localeData, messages } = getLocale();
|
const { localeData, messages } = getLocale();
|
||||||
addLocaleData(localeData);
|
addLocaleData(localeData);
|
||||||
|
|
||||||
const store = configureStore();
|
const store = configureStore();
|
||||||
const initialStateContainer = document.getElementById('initial-state');
|
|
||||||
|
|
||||||
if (initialStateContainer !== null) {
|
if (initialState) {
|
||||||
const initialState = JSON.parse(initialStateContainer.textContent);
|
|
||||||
store.dispatch(hydrateStore(initialState));
|
store.dispatch(hydrateStore(initialState));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,8 +5,8 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import IconButton from '../../../components/icon_button';
|
import IconButton from '../../../components/icon_button';
|
||||||
import Motion from '../../ui/util/optional_motion';
|
import Motion from '../../ui/util/optional_motion';
|
||||||
import spring from 'react-motion/lib/spring';
|
import spring from 'react-motion/lib/spring';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
import { autoPlayGif } from '../../../initial_state';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
|
@ -14,19 +14,10 @@ const messages = defineMessages({
|
||||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
|
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
|
|
||||||
});
|
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
class Avatar extends ImmutablePureComponent {
|
class Avatar extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
account: ImmutablePropTypes.map.isRequired,
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
autoPlayGif: PropTypes.bool.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -44,7 +35,7 @@ class Avatar extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { account, autoPlayGif } = this.props;
|
const { account } = this.props;
|
||||||
const { isHovered } = this.state;
|
const { isHovered } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -71,7 +62,6 @@ class Avatar extends ImmutablePureComponent {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@connect(makeMapStateToProps)
|
|
||||||
@injectIntl
|
@injectIntl
|
||||||
export default class Header extends ImmutablePureComponent {
|
export default class Header extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
@ -80,7 +70,6 @@ export default class Header extends ImmutablePureComponent {
|
||||||
me: PropTypes.string.isRequired,
|
me: PropTypes.string.isRequired,
|
||||||
onFollow: PropTypes.func.isRequired,
|
onFollow: PropTypes.func.isRequired,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
autoPlayGif: PropTypes.bool.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
@ -124,7 +113,7 @@ export default class Header extends ImmutablePureComponent {
|
||||||
return (
|
return (
|
||||||
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
|
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
|
||||||
<div>
|
<div>
|
||||||
<Avatar account={account} autoPlayGif={this.props.autoPlayGif} />
|
<Avatar account={account} />
|
||||||
|
|
||||||
<span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHtml} />
|
<span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHtml} />
|
||||||
<span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
|
<span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
|
||||||
|
|
|
@ -19,7 +19,6 @@ const mapStateToProps = (state, props) => ({
|
||||||
medias: getAccountGallery(state, props.params.accountId),
|
medias: getAccountGallery(state, props.params.accountId),
|
||||||
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
|
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
|
||||||
hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']),
|
hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']),
|
||||||
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@connect(mapStateToProps)
|
@connect(mapStateToProps)
|
||||||
|
@ -31,7 +30,6 @@ export default class AccountGallery extends ImmutablePureComponent {
|
||||||
medias: ImmutablePropTypes.list.isRequired,
|
medias: ImmutablePropTypes.list.isRequired,
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
hasMore: PropTypes.bool,
|
hasMore: PropTypes.bool,
|
||||||
autoPlayGif: PropTypes.bool,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
|
@ -67,7 +65,7 @@ export default class AccountGallery extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { medias, autoPlayGif, isLoading, hasMore } = this.props;
|
const { medias, isLoading, hasMore } = this.props;
|
||||||
|
|
||||||
let loadMore = null;
|
let loadMore = null;
|
||||||
|
|
||||||
|
@ -100,7 +98,6 @@ export default class AccountGallery extends ImmutablePureComponent {
|
||||||
<MediaItem
|
<MediaItem
|
||||||
key={media.get('id')}
|
key={media.get('id')}
|
||||||
media={media}
|
media={media}
|
||||||
autoPlayGif={autoPlayGif}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{loadMore}
|
{loadMore}
|
||||||
|
|
|
@ -22,7 +22,6 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
||||||
status: ImmutablePropTypes.map.isRequired,
|
status: ImmutablePropTypes.map.isRequired,
|
||||||
onOpenMedia: PropTypes.func.isRequired,
|
onOpenMedia: PropTypes.func.isRequired,
|
||||||
onOpenVideo: PropTypes.func.isRequired,
|
onOpenVideo: PropTypes.func.isRequired,
|
||||||
autoPlayGif: PropTypes.bool,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleAccountClick = (e) => {
|
handleAccountClick = (e) => {
|
||||||
|
@ -70,7 +69,6 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
||||||
media={status.get('media_attachments')}
|
media={status.get('media_attachments')}
|
||||||
height={300}
|
height={300}
|
||||||
onOpenMedia={this.props.onOpenMedia}
|
onOpenMedia={this.props.onOpenMedia}
|
||||||
autoPlayGif={this.props.autoPlayGif}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,7 +45,6 @@ const makeMapStateToProps = () => {
|
||||||
me: state.getIn(['meta', 'me']),
|
me: state.getIn(['meta', 'me']),
|
||||||
boostModal: state.getIn(['meta', 'boost_modal']),
|
boostModal: state.getIn(['meta', 'boost_modal']),
|
||||||
deleteModal: state.getIn(['meta', 'delete_modal']),
|
deleteModal: state.getIn(['meta', 'delete_modal']),
|
||||||
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapStateToProps;
|
return mapStateToProps;
|
||||||
|
@ -68,7 +67,6 @@ export default class Status extends ImmutablePureComponent {
|
||||||
me: PropTypes.string,
|
me: PropTypes.string,
|
||||||
boostModal: PropTypes.bool,
|
boostModal: PropTypes.bool,
|
||||||
deleteModal: PropTypes.bool,
|
deleteModal: PropTypes.bool,
|
||||||
autoPlayGif: PropTypes.bool,
|
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -257,7 +255,7 @@ export default class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
let ancestors, descendants;
|
let ancestors, descendants;
|
||||||
const { status, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props;
|
const { status, ancestorsIds, descendantsIds, me } = this.props;
|
||||||
|
|
||||||
if (status === null) {
|
if (status === null) {
|
||||||
return (
|
return (
|
||||||
|
@ -298,7 +296,6 @@ export default class Status extends ImmutablePureComponent {
|
||||||
<div className='focusable' tabIndex='0'>
|
<div className='focusable' tabIndex='0'>
|
||||||
<DetailedStatus
|
<DetailedStatus
|
||||||
status={status}
|
status={status}
|
||||||
autoPlayGif={autoPlayGif}
|
|
||||||
me={me}
|
me={me}
|
||||||
onOpenVideo={this.handleOpenVideo}
|
onOpenVideo={this.handleOpenVideo}
|
||||||
onOpenMedia={this.handleOpenMedia}
|
onOpenMedia={this.handleOpenMedia}
|
||||||
|
|
|
@ -4,11 +4,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Motion from 'react-motion/lib/Motion';
|
import Motion from 'react-motion/lib/Motion';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { reduceMotion } from '../../../initial_state';
|
||||||
|
|
||||||
const stylesToKeep = ['opacity', 'backgroundOpacity'];
|
const stylesToKeep = ['opacity', 'backgroundOpacity'];
|
||||||
|
|
||||||
let reduceMotion;
|
|
||||||
|
|
||||||
const extractValue = (value) => {
|
const extractValue = (value) => {
|
||||||
// This is either an object with a "val" property or it's a number
|
// This is either an object with a "val" property or it's a number
|
||||||
return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
|
return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
|
||||||
|
@ -26,12 +25,6 @@ class OptionalMotion extends React.Component {
|
||||||
|
|
||||||
const { style, defaultStyle, children } = this.props;
|
const { style, defaultStyle, children } = this.props;
|
||||||
|
|
||||||
if (typeof reduceMotion !== 'boolean') {
|
|
||||||
// This never changes without a page reload, so we can just grab it
|
|
||||||
// once from the body classes as opposed to using Redux's connect(),
|
|
||||||
// which would unnecessarily update every state change
|
|
||||||
reduceMotion = document.body.classList.contains('reduce-motion');
|
|
||||||
}
|
|
||||||
if (reduceMotion) {
|
if (reduceMotion) {
|
||||||
Object.keys(style).forEach(key => {
|
Object.keys(style).forEach(key => {
|
||||||
if (stylesToKeep.includes(key)) {
|
if (stylesToKeep.includes(key)) {
|
||||||
|
|
9
app/javascript/mastodon/initial_state.js
Normal file
9
app/javascript/mastodon/initial_state.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
const element = document.getElementById('initial-state');
|
||||||
|
const initialState = element && JSON.parse(element.textContent);
|
||||||
|
|
||||||
|
const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop];
|
||||||
|
|
||||||
|
export const reduceMotion = getMeta('reduce_motion');
|
||||||
|
export const autoPlayGif = getMeta('auto_play_gif');
|
||||||
|
|
||||||
|
export default initialState;
|
|
@ -27,7 +27,6 @@
|
||||||
= yield :header_tags
|
= yield :header_tags
|
||||||
|
|
||||||
- body_classes ||= @body_classes || ''
|
- body_classes ||= @body_classes || ''
|
||||||
- body_classes += ' reduce-motion' if current_account&.user&.setting_reduce_motion
|
|
||||||
- body_classes += ' system-font' if current_account&.user&.setting_system_font_ui
|
- body_classes += ' system-font' if current_account&.user&.setting_system_font_ui
|
||||||
|
|
||||||
%body{ class: add_rtl_body_class(body_classes) }
|
%body{ class: add_rtl_body_class(body_classes) }
|
||||||
|
|
Loading…
Reference in a new issue