- 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 tabmaster^2
@@ -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(); | |||
} | |||
} | |||
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 = {}; | |||
let label, icon; | |||
if (media.get('type') === 'gifv') { | |||
label = <span className='media-gallery__gifv__label'>GIF</span>; | |||
_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 { attachment, displayWidth } = this.props; | |||
const { visible, loaded } = this.state; | |||
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`; | |||
const height = width; | |||
const status = attachment.get('status'); | |||
let thumbnail = ''; | |||
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 | |||
/> | |||
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> | |||
<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, | |||