[Glitch] Change account gallery in web UI
Port 3f143606fa to glitch-soc
Signed-off-by: Thibaut Girka <thib@sitedethib.com>
			
			
This commit is contained in:
		
							parent
							
								
									373dd1fdf1
								
							
						
					
					
						commit
						a5da59f140
					
				
					 5 changed files with 166 additions and 119 deletions
				
			
		| 
						 | 
				
			
			@ -177,7 +177,7 @@ class Item extends React.PureComponent {
 | 
			
		|||
    if (attachment.get('type') === 'unknown') {
 | 
			
		||||
      return (
 | 
			
		||||
        <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
 | 
			
		||||
          <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }} >
 | 
			
		||||
          <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
 | 
			
		||||
            <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
 | 
			
		||||
          </a>
 | 
			
		||||
        </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,69 +1,141 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import PropTypes from 'prop-types';
 | 
			
		||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
			
		||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
			
		||||
import Permalink from 'flavours/glitch/components/permalink';
 | 
			
		||||
import { displayMedia } from 'flavours/glitch/util/initial_state';
 | 
			
		||||
import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { decode } from 'blurhash';
 | 
			
		||||
import { isIOS } from 'flavours/glitch/util/is_mobile';
 | 
			
		||||
 | 
			
		||||
export default class MediaItem extends ImmutablePureComponent {
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    media: ImmutablePropTypes.map.isRequired,
 | 
			
		||||
    attachment: ImmutablePropTypes.map.isRequired,
 | 
			
		||||
    displayWidth: PropTypes.number.isRequired,
 | 
			
		||||
    onOpenMedia: PropTypes.func.isRequired,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  state = {
 | 
			
		||||
    visible: displayMedia !== 'hide_all' && !this.props.media.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
 | 
			
		||||
    visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
 | 
			
		||||
    loaded: false,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handleClick = () => {
 | 
			
		||||
    if (!this.state.visible) {
 | 
			
		||||
      this.setState({ visible: true });
 | 
			
		||||
      return true;
 | 
			
		||||
  componentDidMount () {
 | 
			
		||||
    if (this.props.attachment.get('blurhash')) {
 | 
			
		||||
      this._decode();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    return false;
 | 
			
		||||
  componentDidUpdate (prevProps) {
 | 
			
		||||
    if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
 | 
			
		||||
      this._decode();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  _decode () {
 | 
			
		||||
    const hash   = this.props.attachment.get('blurhash');
 | 
			
		||||
    const pixels = decode(hash, 32, 32);
 | 
			
		||||
 | 
			
		||||
    if (pixels) {
 | 
			
		||||
      const ctx       = this.canvas.getContext('2d');
 | 
			
		||||
      const imageData = new ImageData(pixels, 32, 32);
 | 
			
		||||
 | 
			
		||||
      ctx.putImageData(imageData, 0, 0);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setCanvasRef = c => {
 | 
			
		||||
    this.canvas = c;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleImageLoad = () => {
 | 
			
		||||
    this.setState({ loaded: true });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleMouseEnter = e => {
 | 
			
		||||
    if (this.hoverToPlay()) {
 | 
			
		||||
      e.target.play();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleMouseLeave = e => {
 | 
			
		||||
    if (this.hoverToPlay()) {
 | 
			
		||||
      e.target.pause();
 | 
			
		||||
      e.target.currentTime = 0;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  hoverToPlay () {
 | 
			
		||||
    return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleClick = e => {
 | 
			
		||||
    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
 | 
			
		||||
      if (this.state.visible) {
 | 
			
		||||
        this.props.onOpenMedia(this.props.attachment);
 | 
			
		||||
      } else {
 | 
			
		||||
        this.setState({ visible: true });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { media } = this.props;
 | 
			
		||||
    const { visible } = this.state;
 | 
			
		||||
    const status = media.get('status');
 | 
			
		||||
    const focusX = media.getIn(['meta', 'focus', 'x']);
 | 
			
		||||
    const focusY = media.getIn(['meta', 'focus', 'y']);
 | 
			
		||||
    const x = ((focusX /  2) + .5) * 100;
 | 
			
		||||
    const y = ((focusY / -2) + .5) * 100;
 | 
			
		||||
    const style = {};
 | 
			
		||||
    const { attachment, displayWidth } = this.props;
 | 
			
		||||
    const { visible, loaded } = this.state;
 | 
			
		||||
 | 
			
		||||
    let label, icon, title;
 | 
			
		||||
    const width  = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
 | 
			
		||||
    const height = width;
 | 
			
		||||
    const status = attachment.get('status');
 | 
			
		||||
 | 
			
		||||
    if (media.get('type') === 'gifv') {
 | 
			
		||||
      label = <span className='media-gallery__gifv__label'>GIF</span>;
 | 
			
		||||
    }
 | 
			
		||||
    let thumbnail = '';
 | 
			
		||||
 | 
			
		||||
    if (visible) {
 | 
			
		||||
      style.backgroundImage    = `url(${media.get('preview_url')})`;
 | 
			
		||||
      style.backgroundPosition = `${x}% ${y}%`;
 | 
			
		||||
      title                    = media.get('description');
 | 
			
		||||
    } else {
 | 
			
		||||
      icon = (
 | 
			
		||||
        <span className='account-gallery__item__icons'>
 | 
			
		||||
          <i className='fa fa-eye-slash' />
 | 
			
		||||
        </span>
 | 
			
		||||
    if (attachment.get('type') === 'unknown') {
 | 
			
		||||
      // Skip
 | 
			
		||||
    } else if (attachment.get('type') === 'image') {
 | 
			
		||||
      const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
 | 
			
		||||
      const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
 | 
			
		||||
      const x      = ((focusX /  2) + .5) * 100;
 | 
			
		||||
      const y      = ((focusY / -2) + .5) * 100;
 | 
			
		||||
 | 
			
		||||
      thumbnail = (
 | 
			
		||||
        <img
 | 
			
		||||
          src={attachment.get('preview_url')}
 | 
			
		||||
          alt={attachment.get('description')}
 | 
			
		||||
          title={attachment.get('description')}
 | 
			
		||||
          style={{ objectPosition: `${x}% ${y}%` }}
 | 
			
		||||
          onLoad={this.handleImageLoad}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    } else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) {
 | 
			
		||||
      const autoPlay = !isIOS() && autoPlayGif;
 | 
			
		||||
 | 
			
		||||
      thumbnail = (
 | 
			
		||||
        <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
 | 
			
		||||
          <video
 | 
			
		||||
            className='media-gallery__item-gifv-thumbnail'
 | 
			
		||||
            aria-label={attachment.get('description')}
 | 
			
		||||
            title={attachment.get('description')}
 | 
			
		||||
            role='application'
 | 
			
		||||
            src={attachment.get('url')}
 | 
			
		||||
            onMouseEnter={this.handleMouseEnter}
 | 
			
		||||
            onMouseLeave={this.handleMouseLeave}
 | 
			
		||||
            autoPlay={autoPlay}
 | 
			
		||||
            loop
 | 
			
		||||
            muted
 | 
			
		||||
          />
 | 
			
		||||
          <span className='media-gallery__gifv__label'>GIF</span>
 | 
			
		||||
        </div>
 | 
			
		||||
      );
 | 
			
		||||
      title = status.get('spoiler_text') || media.get('description');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className='account-gallery__item'>
 | 
			
		||||
        <Permalink
 | 
			
		||||
          to={`/statuses/${status.get('id')}`}
 | 
			
		||||
          href={status.get('url')}
 | 
			
		||||
          style={style}
 | 
			
		||||
          title={title}
 | 
			
		||||
          onInterceptClick={this.handleClick}
 | 
			
		||||
        >
 | 
			
		||||
          {icon}
 | 
			
		||||
          {label}
 | 
			
		||||
        </Permalink>
 | 
			
		||||
      <div className='account-gallery__item' style={{ width, height }}>
 | 
			
		||||
        <a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' style={{ cursor: 'pointer' }} onClick={this.handleClick}>
 | 
			
		||||
          <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
 | 
			
		||||
          {visible && thumbnail}
 | 
			
		||||
        </a>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,12 +14,13 @@ import HeaderContainer from 'flavours/glitch/features/account_timeline/container
 | 
			
		|||
import { ScrollContainer } from 'react-router-scroll-4';
 | 
			
		||||
import LoadMore from 'flavours/glitch/components/load_more';
 | 
			
		||||
import MissingIndicator from 'flavours/glitch/components/missing_indicator';
 | 
			
		||||
import { openModal } from 'flavours/glitch/actions/modal';
 | 
			
		||||
 | 
			
		||||
const mapStateToProps = (state, props) => ({
 | 
			
		||||
  isAccount: !!state.getIn(['accounts', props.params.accountId]),
 | 
			
		||||
  medias: getAccountGallery(state, props.params.accountId),
 | 
			
		||||
  attachments: getAccountGallery(state, props.params.accountId),
 | 
			
		||||
  isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
 | 
			
		||||
  hasMore:   state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
 | 
			
		||||
  hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
class LoadMoreMedia extends ImmutablePureComponent {
 | 
			
		||||
| 
						 | 
				
			
			@ -50,12 +51,16 @@ export default class AccountGallery extends ImmutablePureComponent {
 | 
			
		|||
  static propTypes = {
 | 
			
		||||
    params: PropTypes.object.isRequired,
 | 
			
		||||
    dispatch: PropTypes.func.isRequired,
 | 
			
		||||
    medias: ImmutablePropTypes.list.isRequired,
 | 
			
		||||
    attachments: ImmutablePropTypes.list.isRequired,
 | 
			
		||||
    isLoading: PropTypes.bool,
 | 
			
		||||
    hasMore: PropTypes.bool,
 | 
			
		||||
    isAccount: PropTypes.bool,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  state = {
 | 
			
		||||
    width: 323,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  componentDidMount () {
 | 
			
		||||
    this.props.dispatch(fetchAccount(this.props.params.accountId));
 | 
			
		||||
    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
 | 
			
		||||
| 
						 | 
				
			
			@ -74,11 +79,11 @@ export default class AccountGallery extends ImmutablePureComponent {
 | 
			
		|||
 | 
			
		||||
  handleScrollToBottom = () => {
 | 
			
		||||
    if (this.props.hasMore) {
 | 
			
		||||
      this.handleLoadMore(this.props.medias.size > 0 ? this.props.medias.last().getIn(['status', 'id']) : undefined);
 | 
			
		||||
      this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleScroll = (e) => {
 | 
			
		||||
  handleScroll = e => {
 | 
			
		||||
    const { scrollTop, scrollHeight, clientHeight } = e.target;
 | 
			
		||||
    const offset = scrollHeight - scrollTop - clientHeight;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -91,7 +96,7 @@ export default class AccountGallery extends ImmutablePureComponent {
 | 
			
		|||
    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handleLoadOlder = (e) => {
 | 
			
		||||
  handleLoadOlder = e => {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    this.handleScrollToBottom();
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -101,12 +106,30 @@ export default class AccountGallery extends ImmutablePureComponent {
 | 
			
		|||
    return !(location.state && location.state.mastodonModalOpen);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setRef = c => {
 | 
			
		||||
  setColumnRef = c => {
 | 
			
		||||
    this.column = c;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleOpenMedia = attachment => {
 | 
			
		||||
    if (attachment.get('type') === 'video') {
 | 
			
		||||
      this.props.dispatch(openModal('VIDEO', { media: attachment }));
 | 
			
		||||
    } else {
 | 
			
		||||
      const media = attachment.getIn(['status', 'media_attachments']);
 | 
			
		||||
      const index = media.findIndex(x => x.get('id') === attachment.get('id'));
 | 
			
		||||
 | 
			
		||||
      this.props.dispatch(openModal('MEDIA', { media, index }));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleRef = c => {
 | 
			
		||||
    if (c) {
 | 
			
		||||
      this.setState({ width: c.offsetWidth });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { medias, isLoading, hasMore, isAccount } = this.props;
 | 
			
		||||
    const { attachments, isLoading, hasMore, isAccount } = this.props;
 | 
			
		||||
    const { width } = this.state;
 | 
			
		||||
 | 
			
		||||
    if (!isAccount) {
 | 
			
		||||
      return (
 | 
			
		||||
| 
						 | 
				
			
			@ -116,9 +139,7 @@ export default class AccountGallery extends ImmutablePureComponent {
 | 
			
		|||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let loadOlder = null;
 | 
			
		||||
 | 
			
		||||
    if (!medias && isLoading) {
 | 
			
		||||
    if (!attachments && isLoading) {
 | 
			
		||||
      return (
 | 
			
		||||
        <Column>
 | 
			
		||||
          <LoadingIndicator />
 | 
			
		||||
| 
						 | 
				
			
			@ -126,35 +147,31 @@ export default class AccountGallery extends ImmutablePureComponent {
 | 
			
		|||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (hasMore && !(isLoading && medias.size === 0)) {
 | 
			
		||||
    let loadOlder = null;
 | 
			
		||||
 | 
			
		||||
    if (hasMore && !(isLoading && attachments.size === 0)) {
 | 
			
		||||
      loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Column ref={this.setRef}>
 | 
			
		||||
      <Column ref={this.setColumnRef}>
 | 
			
		||||
        <ProfileColumnHeader onClick={this.handleHeaderClick} />
 | 
			
		||||
 | 
			
		||||
        <ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}>
 | 
			
		||||
          <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
 | 
			
		||||
            <HeaderContainer accountId={this.props.params.accountId} />
 | 
			
		||||
 | 
			
		||||
            <div role='feed' className='account-gallery__container'>
 | 
			
		||||
              {medias.map((media, index) => media === null ? (
 | 
			
		||||
                <LoadMoreMedia
 | 
			
		||||
                  key={'more:' + medias.getIn(index + 1, 'id')}
 | 
			
		||||
                  maxId={index > 0 ? medias.getIn(index - 1, 'id') : null}
 | 
			
		||||
                  onLoadMore={this.handleLoadMore}
 | 
			
		||||
                />
 | 
			
		||||
            <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={media.get('id')}
 | 
			
		||||
                  media={media}
 | 
			
		||||
                />
 | 
			
		||||
                <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
 | 
			
		||||
              ))}
 | 
			
		||||
 | 
			
		||||
              {loadOlder}
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            {isLoading && medias.size === 0 && (
 | 
			
		||||
            {isLoading && attachments.size === 0 && (
 | 
			
		||||
              <div className='scrollable__append'>
 | 
			
		||||
                <LoadingIndicator />
 | 
			
		||||
              </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -331,62 +331,19 @@
 | 
			
		|||
 | 
			
		||||
.account-gallery__container {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  padding: 2px;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  padding: 4px 2px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.account-gallery__item {
 | 
			
		||||
  flex-grow: 1;
 | 
			
		||||
  width: 50%;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  border: none;
 | 
			
		||||
  box-sizing: border-box;
 | 
			
		||||
  display: block;
 | 
			
		||||
  position: relative;
 | 
			
		||||
 | 
			
		||||
  &::before {
 | 
			
		||||
    content: "";
 | 
			
		||||
    display: block;
 | 
			
		||||
    padding-top: 100%;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  a {
 | 
			
		||||
    display: block;
 | 
			
		||||
    width: calc(100% - 4px);
 | 
			
		||||
    height: calc(100% - 4px);
 | 
			
		||||
    margin: 2px;
 | 
			
		||||
    top: 0;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    background-color: $base-overlay-background;
 | 
			
		||||
    background-size: cover;
 | 
			
		||||
    background-position: center;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    color: $ui-primary-color;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
 | 
			
		||||
    &:hover,
 | 
			
		||||
    &:active,
 | 
			
		||||
    &:focus {
 | 
			
		||||
      outline: 0;
 | 
			
		||||
      color: $ui-secondary-color;
 | 
			
		||||
 | 
			
		||||
      &::before {
 | 
			
		||||
        content: "";
 | 
			
		||||
        display: block;
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        height: 100%;
 | 
			
		||||
        background: rgba($base-overlay-background, 0.3);
 | 
			
		||||
        border-radius: 4px;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &__icons {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    top: 50%;
 | 
			
		||||
    left: 50%;
 | 
			
		||||
    transform: translate(-50%, -50%);
 | 
			
		||||
    font-size: 24px;
 | 
			
		||||
  }
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  margin: 2px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.notification__filter-bar,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -58,6 +58,7 @@
 | 
			
		|||
  pointer-events: none;
 | 
			
		||||
  opacity: 0.9;
 | 
			
		||||
  transition: opacity 0.1s ease;
 | 
			
		||||
  line-height: 18px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.media-gallery__gifv {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in a new issue