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:
Eugen Rochko 2019-05-02 08:34:32 +02:00 committed by GitHub
parent 21a73c52a7
commit 3f143606fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 170 additions and 115 deletions

View File

@ -157,7 +157,7 @@ class Item extends React.PureComponent {
if (attachment.get('type') === 'unknown') { if (attachment.get('type') === 'unknown') {
return ( 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}%` }}> <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' /> <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
</a> </a>
</div> </div>

View File

@ -1,62 +1,142 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Permalink from '../../../components/permalink'; import { autoPlayGif, displayMedia } from 'mastodon/initial_state';
import { displayMedia } from '../../../initial_state'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import { decode } from 'blurhash';
import { isIOS } from 'mastodon/is_mobile';
export default class MediaItem extends ImmutablePureComponent { export default class MediaItem extends ImmutablePureComponent {
static propTypes = { static propTypes = {
media: ImmutablePropTypes.map.isRequired, attachment: ImmutablePropTypes.map.isRequired,
displayWidth: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired,
}; };
state = { 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 = () => { componentDidMount () {
if (!this.state.visible) { if (this.props.attachment.get('blurhash')) {
this.setState({ visible: true }); this._decode();
return true; }
} }
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 () { render () {
const { media } = this.props; const { attachment, displayWidth } = this.props;
const { visible } = this.state; const { visible, loaded } = this.state;
const status = media.get('status');
const focusX = media.getIn(['meta', 'focus', 'x']); const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
const focusY = media.getIn(['meta', 'focus', 'y']); 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 x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100; const y = ((focusY / -2) + .5) * 100;
const style = {};
let label, icon; 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;
if (media.get('type') === 'gifv') { thumbnail = (
label = <span className='media-gallery__gifv__label'>GIF</span>; <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) { <span className='media-gallery__gifv__label'>GIF</span>
style.backgroundImage = `url(${media.get('preview_url')})`; </div>
style.backgroundPosition = `${x}% ${y}%`;
} else {
icon = (
<span className='account-gallery__item__icons'>
<Icon id='eye-slash' />
</span>
); );
} }
return ( return (
<div className='account-gallery__item'> <div className='account-gallery__item' style={{ width, height }}>
<Permalink to={`/statuses/${status.get('id')}`} href={status.get('url')} style={style} onInterceptClick={this.handleClick}> <a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' style={{ cursor: 'pointer' }} onClick={this.handleClick}>
{icon} <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
{label} {visible && thumbnail}
</Permalink> </a>
</div> </div>
); );
} }

View File

@ -2,22 +2,23 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { fetchAccount } from '../../actions/accounts'; import { fetchAccount } from 'mastodon/actions/accounts';
import { expandAccountMediaTimeline } from '../../actions/timelines'; 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 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 ImmutablePureComponent from 'react-immutable-pure-component';
import { getAccountGallery } from '../../selectors'; import { getAccountGallery } from 'mastodon/selectors';
import MediaItem from './components/media_item'; import MediaItem from './components/media_item';
import HeaderContainer from '../account_timeline/containers/header_container'; import HeaderContainer from '../account_timeline/containers/header_container';
import { ScrollContainer } from 'react-router-scroll-4'; 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 MissingIndicator from 'mastodon/components/missing_indicator';
import { openModal } from 'mastodon/actions/modal';
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
isAccount: !!state.getIn(['accounts', props.params.accountId]), 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']), 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']),
}); });
@ -51,12 +52,16 @@ class AccountGallery extends ImmutablePureComponent {
static propTypes = { static propTypes = {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
medias: ImmutablePropTypes.list.isRequired, attachments: ImmutablePropTypes.list.isRequired,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
isAccount: PropTypes.bool, isAccount: PropTypes.bool,
}; };
state = {
width: 323,
};
componentDidMount () { componentDidMount () {
this.props.dispatch(fetchAccount(this.props.params.accountId)); this.props.dispatch(fetchAccount(this.props.params.accountId));
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
@ -71,11 +76,11 @@ class AccountGallery extends ImmutablePureComponent {
handleScrollToBottom = () => { handleScrollToBottom = () => {
if (this.props.hasMore) { 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 { scrollTop, scrollHeight, clientHeight } = e.target;
const offset = scrollHeight - scrollTop - clientHeight; const offset = scrollHeight - scrollTop - clientHeight;
@ -88,13 +93,31 @@ class AccountGallery extends ImmutablePureComponent {
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId })); this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
}; };
handleLoadOlder = (e) => { handleLoadOlder = e => {
e.preventDefault(); e.preventDefault();
this.handleScrollToBottom(); 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 () { render () {
const { medias, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props; const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
const { width } = this.state;
if (!isAccount) { if (!isAccount) {
return ( return (
@ -104,9 +127,7 @@ class AccountGallery extends ImmutablePureComponent {
); );
} }
let loadOlder = null; if (!attachments && isLoading) {
if (!medias && isLoading) {
return ( return (
<Column> <Column>
<LoadingIndicator /> <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} />; loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
} }
@ -126,23 +149,17 @@ class AccountGallery extends ImmutablePureComponent {
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}> <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
<HeaderContainer accountId={this.props.params.accountId} /> <HeaderContainer accountId={this.props.params.accountId} />
<div role='feed' className='account-gallery__container'> <div role='feed' className='account-gallery__container' ref={this.handleRef}>
{medias.map((media, index) => media === null ? ( {attachments.map((attachment, index) => attachment === null ? (
<LoadMoreMedia <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
key={'more:' + medias.getIn(index + 1, 'id')}
maxId={index > 0 ? medias.getIn(index - 1, 'id') : null}
onLoadMore={this.handleLoadMore}
/>
) : ( ) : (
<MediaItem <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
key={media.get('id')}
media={media}
/>
))} ))}
{loadOlder} {loadOlder}
</div> </div>
{isLoading && medias.size === 0 && ( {isLoading && attachments.size === 0 && (
<div className='scrollable__append'> <div className='scrollable__append'>
<LoadingIndicator /> <LoadingIndicator />
</div> </div>

View File

@ -4233,6 +4233,7 @@ a.status-card.compact:hover {
pointer-events: none; pointer-events: none;
opacity: 0.9; opacity: 0.9;
transition: opacity 0.1s ease; transition: opacity 0.1s ease;
line-height: 18px;
} }
.media-gallery__gifv { .media-gallery__gifv {
@ -4762,62 +4763,19 @@ a.status-card.compact:hover {
.account-gallery__container { .account-gallery__container {
display: flex; display: flex;
justify-content: center;
flex-wrap: wrap; flex-wrap: wrap;
padding: 2px; justify-content: center;
padding: 4px 2px;
} }
.account-gallery__item { .account-gallery__item {
flex-grow: 1; border: none;
width: 50%; box-sizing: border-box;
overflow: hidden; display: block;
position: relative; position: relative;
border-radius: 4px;
&::before { overflow: hidden;
content: "";
display: block;
padding-top: 100%;
}
a {
display: block;
width: calc(100% - 4px);
height: calc(100% - 4px);
margin: 2px; 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;
}
} }
.notification__filter-bar, .notification__filter-bar,