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>
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>
);
}
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 {
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));
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;
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 (
);
}
- let loadOlder = null;
-
- if (!medias && isLoading) {
+ if (!attachments && isLoading) {
return (
<Column>
<LoadingIndicator />
);
}
- if (hasMore && !(isLoading && medias.size === 0)) {
+ let loadOlder = null;
+
+ if (hasMore && !(isLoading && attachments.size === 0)) {
loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
}
<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>
pointer-events: none;
opacity: 0.9;
transition: opacity 0.1s ease;
+ line-height: 18px;
}
.media-gallery__gifv {
.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,