Change account gallery in web UI (#10667)
- 3 items per row instead of 2 - Use blurhash for previews - Animate/hover-to-play GIFs and videos - Open media modal instead of opening status - Allow opening status instead with ctrl+click and open in new tab
This commit is contained in:
		
							parent
							
								
									21a73c52a7
								
							
						
					
					
						commit
						3f143606fa
					
				
					 4 changed files with 170 additions and 115 deletions
				
			
		|  | @ -157,7 +157,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,62 +1,142 @@ | |||
| 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 '../../../components/permalink'; | ||||
| import { displayMedia } from '../../../initial_state'; | ||||
| import Icon from 'mastodon/components/icon'; | ||||
| import { autoPlayGif, displayMedia } from 'mastodon/initial_state'; | ||||
| import classNames from 'classnames'; | ||||
| import { decode } from 'blurhash'; | ||||
| import { isIOS } from 'mastodon/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; | ||||
|     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}%`; | ||||
|     } else { | ||||
|       icon = ( | ||||
|         <span className='account-gallery__item__icons'> | ||||
|           <Icon id='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> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='account-gallery__item'> | ||||
|         <Permalink to={`/statuses/${status.get('id')}`} href={status.get('url')} style={style} 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> | ||||
|     ); | ||||
|   } | ||||
|  |  | |||
|  | @ -2,24 +2,25 @@ import React from 'react'; | |||
| import { connect } from 'react-redux'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { fetchAccount } from '../../actions/accounts'; | ||||
| import { fetchAccount } from 'mastodon/actions/accounts'; | ||||
| import { expandAccountMediaTimeline } from '../../actions/timelines'; | ||||
| import LoadingIndicator from '../../components/loading_indicator'; | ||||
| import LoadingIndicator from 'mastodon/components/loading_indicator'; | ||||
| import Column from '../ui/components/column'; | ||||
| import ColumnBackButton from '../../components/column_back_button'; | ||||
| import ColumnBackButton from 'mastodon/components/column_back_button'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import { getAccountGallery } from '../../selectors'; | ||||
| import { getAccountGallery } from 'mastodon/selectors'; | ||||
| import MediaItem from './components/media_item'; | ||||
| import HeaderContainer from '../account_timeline/containers/header_container'; | ||||
| import { ScrollContainer } from 'react-router-scroll-4'; | ||||
| import LoadMore from '../../components/load_more'; | ||||
| import LoadMore from 'mastodon/components/load_more'; | ||||
| import MissingIndicator from 'mastodon/components/missing_indicator'; | ||||
| import { openModal } from 'mastodon/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 { | ||||
|  | @ -51,12 +52,16 @@ 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)); | ||||
|  | @ -71,11 +76,11 @@ 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; | ||||
| 
 | ||||
|  | @ -88,13 +93,31 @@ class AccountGallery extends ImmutablePureComponent { | |||
|     this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId })); | ||||
|   }; | ||||
| 
 | ||||
|   handleLoadOlder = (e) => { | ||||
|   handleLoadOlder = e => { | ||||
|     e.preventDefault(); | ||||
|     this.handleScrollToBottom(); | ||||
|   } | ||||
| 
 | ||||
|   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, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props; | ||||
|     const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props; | ||||
|     const { width } = this.state; | ||||
| 
 | ||||
|     if (!isAccount) { | ||||
|       return ( | ||||
|  | @ -104,9 +127,7 @@ class AccountGallery extends ImmutablePureComponent { | |||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     let loadOlder = null; | ||||
| 
 | ||||
|     if (!medias && isLoading) { | ||||
|     if (!attachments && isLoading) { | ||||
|       return ( | ||||
|         <Column> | ||||
|           <LoadingIndicator /> | ||||
|  | @ -114,7 +135,9 @@ 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} />; | ||||
|     } | ||||
| 
 | ||||
|  | @ -126,23 +149,17 @@ class AccountGallery extends ImmutablePureComponent { | |||
|           <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> | ||||
|  |  | |||
|  | @ -4233,6 +4233,7 @@ a.status-card.compact:hover { | |||
|   pointer-events: none; | ||||
|   opacity: 0.9; | ||||
|   transition: opacity 0.1s ease; | ||||
|   line-height: 18px; | ||||
| } | ||||
| 
 | ||||
| .media-gallery__gifv { | ||||
|  | @ -4762,62 +4763,19 @@ a.status-card.compact:hover { | |||
| 
 | ||||
| .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: $darker-text-color; | ||||
|     text-decoration: none; | ||||
|     border-radius: 4px; | ||||
| 
 | ||||
|     &:hover, | ||||
|     &:active, | ||||
|     &:focus { | ||||
|       outline: 0; | ||||
|       color: $secondary-text-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, | ||||
|  |  | |||
		Reference in a new issue