Skip to content

Commit 4b59bd8

Browse files
Gargronhiyuki2578
authored andcommitted
Change account gallery in web UI (mastodon#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
1 parent 69f431c commit 4b59bd8

4 files changed

Lines changed: 172 additions & 117 deletions

File tree

app/javascript/mastodon/components/media_gallery.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ class Item extends React.PureComponent {
157157
if (attachment.get('type') === 'unknown') {
158158
return (
159159
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
160-
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }} >
160+
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
161161
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
162162
</a>
163163
</div>

app/javascript/mastodon/features/account_gallery/components/media_item.js

Lines changed: 117 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,142 @@
11
import React from 'react';
2+
import PropTypes from 'prop-types';
23
import ImmutablePropTypes from 'react-immutable-proptypes';
34
import ImmutablePureComponent from 'react-immutable-pure-component';
4-
import Permalink from '../../../components/permalink';
5-
import { displayMedia } from '../../../initial_state';
6-
import Icon from 'mastodon/components/icon';
5+
import { autoPlayGif, displayMedia } from 'mastodon/initial_state';
6+
import classNames from 'classnames';
7+
import { decode } from 'blurhash';
8+
import { isIOS } from 'mastodon/is_mobile';
79

810
export default class MediaItem extends ImmutablePureComponent {
911

1012
static propTypes = {
11-
media: ImmutablePropTypes.map.isRequired,
13+
attachment: ImmutablePropTypes.map.isRequired,
14+
displayWidth: PropTypes.number.isRequired,
15+
onOpenMedia: PropTypes.func.isRequired,
1216
};
1317

1418
state = {
15-
visible: displayMedia !== 'hide_all' && !this.props.media.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
19+
visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
20+
loaded: false,
1621
};
1722

18-
handleClick = () => {
19-
if (!this.state.visible) {
20-
this.setState({ visible: true });
21-
return true;
23+
componentDidMount () {
24+
if (this.props.attachment.get('blurhash')) {
25+
this._decode();
2226
}
27+
}
2328

24-
return false;
29+
componentDidUpdate (prevProps) {
30+
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
31+
this._decode();
32+
}
2533
}
2634

27-
render () {
28-
const { media } = this.props;
29-
const { visible } = this.state;
30-
const status = media.get('status');
31-
const focusX = media.getIn(['meta', 'focus', 'x']);
32-
const focusY = media.getIn(['meta', 'focus', 'y']);
33-
const x = ((focusX / 2) + .5) * 100;
34-
const y = ((focusY / -2) + .5) * 100;
35-
const style = {};
36-
37-
let label, icon;
38-
39-
if (media.get('type') === 'gifv') {
40-
label = <span className='media-gallery__gifv__label'>GIF</span>;
35+
_decode () {
36+
const hash = this.props.attachment.get('blurhash');
37+
const pixels = decode(hash, 32, 32);
38+
39+
if (pixels) {
40+
const ctx = this.canvas.getContext('2d');
41+
const imageData = new ImageData(pixels, 32, 32);
42+
43+
ctx.putImageData(imageData, 0, 0);
44+
}
45+
}
46+
47+
setCanvasRef = c => {
48+
this.canvas = c;
49+
}
50+
51+
handleImageLoad = () => {
52+
this.setState({ loaded: true });
53+
}
54+
55+
handleMouseEnter = e => {
56+
if (this.hoverToPlay()) {
57+
e.target.play();
58+
}
59+
}
60+
61+
handleMouseLeave = e => {
62+
if (this.hoverToPlay()) {
63+
e.target.pause();
64+
e.target.currentTime = 0;
4165
}
66+
}
67+
68+
hoverToPlay () {
69+
return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
70+
}
71+
72+
handleClick = e => {
73+
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
74+
e.preventDefault();
75+
76+
if (this.state.visible) {
77+
this.props.onOpenMedia(this.props.attachment);
78+
} else {
79+
this.setState({ visible: true });
80+
}
81+
}
82+
}
83+
84+
render () {
85+
const { attachment, displayWidth } = this.props;
86+
const { visible, loaded } = this.state;
87+
88+
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
89+
const height = width;
90+
const status = attachment.get('status');
91+
92+
let thumbnail = '';
93+
94+
if (attachment.get('type') === 'unknown') {
95+
// Skip
96+
} else if (attachment.get('type') === 'image') {
97+
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
98+
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
99+
const x = ((focusX / 2) + .5) * 100;
100+
const y = ((focusY / -2) + .5) * 100;
101+
102+
thumbnail = (
103+
<img
104+
src={attachment.get('preview_url')}
105+
alt={attachment.get('description')}
106+
title={attachment.get('description')}
107+
style={{ objectPosition: `${x}% ${y}%` }}
108+
onLoad={this.handleImageLoad}
109+
/>
110+
);
111+
} else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) {
112+
const autoPlay = !isIOS() && autoPlayGif;
113+
114+
thumbnail = (
115+
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
116+
<video
117+
className='media-gallery__item-gifv-thumbnail'
118+
aria-label={attachment.get('description')}
119+
title={attachment.get('description')}
120+
role='application'
121+
src={attachment.get('url')}
122+
onMouseEnter={this.handleMouseEnter}
123+
onMouseLeave={this.handleMouseLeave}
124+
autoPlay={autoPlay}
125+
loop
126+
muted
127+
/>
42128

43-
if (visible) {
44-
style.backgroundImage = `url(${media.get('preview_url')})`;
45-
style.backgroundPosition = `${x}% ${y}%`;
46-
} else {
47-
icon = (
48-
<span className='account-gallery__item__icons'>
49-
<Icon id='eye-slash' />
50-
</span>
129+
<span className='media-gallery__gifv__label'>GIF</span>
130+
</div>
51131
);
52132
}
53133

54134
return (
55-
<div className='account-gallery__item'>
56-
<Permalink to={`/statuses/${status.get('id')}`} href={status.get('url')} style={style} onInterceptClick={this.handleClick}>
57-
{icon}
58-
{label}
59-
</Permalink>
135+
<div className='account-gallery__item' style={{ width, height }}>
136+
<a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' style={{ cursor: 'pointer' }} onClick={this.handleClick}>
137+
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
138+
{visible && thumbnail}
139+
</a>
60140
</div>
61141
);
62142
}

app/javascript/mastodon/features/account_gallery/index.js

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,25 @@ import React from 'react';
22
import { connect } from 'react-redux';
33
import ImmutablePropTypes from 'react-immutable-proptypes';
44
import PropTypes from 'prop-types';
5-
import { fetchAccount } from '../../actions/accounts';
5+
import { fetchAccount } from 'mastodon/actions/accounts';
66
import { expandAccountMediaTimeline } from '../../actions/timelines';
7-
import LoadingIndicator from '../../components/loading_indicator';
7+
import LoadingIndicator from 'mastodon/components/loading_indicator';
88
import Column from '../ui/components/column';
9-
import ColumnBackButton from '../../components/column_back_button';
9+
import ColumnBackButton from 'mastodon/components/column_back_button';
1010
import ImmutablePureComponent from 'react-immutable-pure-component';
11-
import { getAccountGallery } from '../../selectors';
11+
import { getAccountGallery } from 'mastodon/selectors';
1212
import MediaItem from './components/media_item';
1313
import HeaderContainer from '../account_timeline/containers/header_container';
1414
import { ScrollContainer } from 'react-router-scroll-4';
15-
import LoadMore from '../../components/load_more';
15+
import LoadMore from 'mastodon/components/load_more';
1616
import MissingIndicator from 'mastodon/components/missing_indicator';
17+
import { openModal } from 'mastodon/actions/modal';
1718

1819
const mapStateToProps = (state, props) => ({
1920
isAccount: !!state.getIn(['accounts', props.params.accountId]),
20-
medias: getAccountGallery(state, props.params.accountId),
21+
attachments: getAccountGallery(state, props.params.accountId),
2122
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
22-
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
23+
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
2324
});
2425

2526
class LoadMoreMedia extends ImmutablePureComponent {
@@ -51,12 +52,16 @@ class AccountGallery extends ImmutablePureComponent {
5152
static propTypes = {
5253
params: PropTypes.object.isRequired,
5354
dispatch: PropTypes.func.isRequired,
54-
medias: ImmutablePropTypes.list.isRequired,
55+
attachments: ImmutablePropTypes.list.isRequired,
5556
isLoading: PropTypes.bool,
5657
hasMore: PropTypes.bool,
5758
isAccount: PropTypes.bool,
5859
};
5960

61+
state = {
62+
width: 323,
63+
};
64+
6065
componentDidMount () {
6166
this.props.dispatch(fetchAccount(this.props.params.accountId));
6267
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
@@ -71,11 +76,11 @@ class AccountGallery extends ImmutablePureComponent {
7176

7277
handleScrollToBottom = () => {
7378
if (this.props.hasMore) {
74-
this.handleLoadMore(this.props.medias.size > 0 ? this.props.medias.last().getIn(['status', 'id']) : undefined);
79+
this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
7580
}
7681
}
7782

78-
handleScroll = (e) => {
83+
handleScroll = e => {
7984
const { scrollTop, scrollHeight, clientHeight } = e.target;
8085
const offset = scrollHeight - scrollTop - clientHeight;
8186

@@ -88,13 +93,31 @@ class AccountGallery extends ImmutablePureComponent {
8893
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
8994
};
9095

91-
handleLoadOlder = (e) => {
96+
handleLoadOlder = e => {
9297
e.preventDefault();
9398
this.handleScrollToBottom();
9499
}
95100

101+
handleOpenMedia = attachment => {
102+
if (attachment.get('type') === 'video') {
103+
this.props.dispatch(openModal('VIDEO', { media: attachment }));
104+
} else {
105+
const media = attachment.getIn(['status', 'media_attachments']);
106+
const index = media.findIndex(x => x.get('id') === attachment.get('id'));
107+
108+
this.props.dispatch(openModal('MEDIA', { media, index }));
109+
}
110+
}
111+
112+
handleRef = c => {
113+
if (c) {
114+
this.setState({ width: c.offsetWidth });
115+
}
116+
}
117+
96118
render () {
97-
const { medias, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
119+
const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
120+
const { width } = this.state;
98121

99122
if (!isAccount) {
100123
return (
@@ -104,17 +127,17 @@ class AccountGallery extends ImmutablePureComponent {
104127
);
105128
}
106129

107-
let loadOlder = null;
108-
109-
if (!medias && isLoading) {
130+
if (!attachments && isLoading) {
110131
return (
111132
<Column>
112133
<LoadingIndicator />
113134
</Column>
114135
);
115136
}
116137

117-
if (hasMore && !(isLoading && medias.size === 0)) {
138+
let loadOlder = null;
139+
140+
if (hasMore && !(isLoading && attachments.size === 0)) {
118141
loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
119142
}
120143

@@ -126,23 +149,17 @@ class AccountGallery extends ImmutablePureComponent {
126149
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
127150
<HeaderContainer accountId={this.props.params.accountId} />
128151

129-
<div role='feed' className='account-gallery__container'>
130-
{medias.map((media, index) => media === null ? (
131-
<LoadMoreMedia
132-
key={'more:' + medias.getIn(index + 1, 'id')}
133-
maxId={index > 0 ? medias.getIn(index - 1, 'id') : null}
134-
onLoadMore={this.handleLoadMore}
135-
/>
152+
<div role='feed' className='account-gallery__container' ref={this.handleRef}>
153+
{attachments.map((attachment, index) => attachment === null ? (
154+
<LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
136155
) : (
137-
<MediaItem
138-
key={media.get('id')}
139-
media={media}
140-
/>
156+
<MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
141157
))}
158+
142159
{loadOlder}
143160
</div>
144161

145-
{isLoading && medias.size === 0 && (
162+
{isLoading && attachments.size === 0 && (
146163
<div className='scrollable__append'>
147164
<LoadingIndicator />
148165
</div>

0 commit comments

Comments
 (0)