Change design of media tab on profiles in web UI (#31967)

This commit is contained in:
Eugen Rochko 2024-09-26 14:31:32 +02:00 committed by GitHub
parent 00aaf77e04
commit 89df27a06c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 330 additions and 245 deletions

View file

@ -11,6 +11,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { debounce } from 'lodash';
import { Blurhash } from 'mastodon/components/blurhash';
import { formatTime } from 'mastodon/features/video';
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
@ -57,7 +58,7 @@ class Item extends PureComponent {
hoverToPlay () {
const { attachment } = this.props;
return !this.getAutoPlay() && attachment.get('type') === 'gifv';
return !this.getAutoPlay() && ['gifv', 'video'].includes(attachment.get('type'));
}
handleClick = (e) => {
@ -150,10 +151,15 @@ class Item extends PureComponent {
/>
</a>
);
} else if (attachment.get('type') === 'gifv') {
} else if (['gifv', 'video'].includes(attachment.get('type'))) {
const autoPlay = this.getAutoPlay();
const duration = attachment.getIn(['meta', 'original', 'duration']);
badges.push(<span key='gif' className='media-gallery__gifv__label'>GIF</span>);
if (attachment.get('type') === 'gifv') {
badges.push(<span key='gif' className='media-gallery__gifv__label'>GIF</span>);
} else {
badges.push(<span key='video' className='media-gallery__gifv__label'>{formatTime(Math.floor(duration))}</span>);
}
thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
@ -167,6 +173,7 @@ class Item extends PureComponent {
onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
onLoadedData={this.handleImageLoad}
autoPlay={autoPlay}
playsInline
loop

View file

@ -449,7 +449,25 @@ class Status extends ImmutablePureComponent {
} else if (status.get('media_attachments').size > 0) {
const language = status.getIn(['translation', 'language']) || status.get('language');
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
if (['image', 'gifv'].includes(status.getIn(['media_attachments', 0, 'type'])) || status.get('media_attachments').size > 1) {
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
{Component => (
<Component
media={status.get('media_attachments')}
lang={language}
sensitive={status.get('sensitive')}
height={110}
onOpenMedia={this.handleOpenMedia}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
@ -501,24 +519,6 @@ class Status extends ImmutablePureComponent {
)}
</Bundle>
);
} else {
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
{Component => (
<Component
media={status.get('media_attachments')}
lang={language}
sensitive={status.get('sensitive')}
height={110}
onOpenMedia={this.handleOpenMedia}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>
);
}
} else if (status.get('spoiler_text').length === 0 && status.get('card')) {
media = (

View file

@ -1,158 +0,0 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import AudiotrackIcon from '@/material-icons/400-24px/music_note.svg?react';
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow.svg?react';
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state';
export default class MediaItem extends ImmutablePureComponent {
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
displayWidth: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired,
};
state = {
visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
loaded: false,
};
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');
const title = status.get('spoiler_text') || attachment.get('description');
let thumbnail, label, icon, content;
if (!visible) {
icon = (
<span className='account-gallery__item__icons'>
<Icon id='eye-slash' icon={VisibilityOffIcon} />
</span>
);
} else {
if (['audio', 'video'].includes(attachment.get('type'))) {
content = (
<img
src={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
alt={attachment.get('description')}
lang={status.get('language')}
onLoad={this.handleImageLoad}
/>
);
if (attachment.get('type') === 'audio') {
label = <Icon id='music' icon={AudiotrackIcon} />;
} else {
label = <Icon id='play' icon={PlayArrowIcon} />;
}
} 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;
content = (
<img
src={attachment.get('preview_url')}
alt={attachment.get('description')}
lang={status.get('language')}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={this.handleImageLoad}
/>
);
} else if (attachment.get('type') === 'gifv') {
content = (
<video
className='media-gallery__item-gifv-thumbnail'
aria-label={attachment.get('description')}
title={attachment.get('description')}
lang={status.get('language')}
role='application'
src={attachment.get('url')}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
autoPlay={autoPlayGif}
playsInline
loop
muted
/>
);
label = 'GIF';
}
thumbnail = (
<div className='media-gallery__gifv'>
{content}
{label && (
<div className='media-gallery__item__badges'>
<span className='media-gallery__gifv__label'>{label}</span>
</div>
)}
</div>
);
}
return (
<div className='account-gallery__item' style={{ width, height }}>
<a className='media-gallery__item-thumbnail' href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'>
<Blurhash
hash={attachment.get('blurhash')}
className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })}
dummy={!useBlurhash}
/>
{visible ? thumbnail : icon}
</a>
</div>
);
}
}

View file

@ -0,0 +1,197 @@
import { useState, useCallback } from 'react';
import classNames from 'classnames';
import HeadphonesIcon from '@/material-icons/400-24px/headphones-fill.svg?react';
import MovieIcon from '@/material-icons/400-24px/movie-fill.svg?react';
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
import { formatTime } from 'mastodon/features/video';
import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state';
import type { Status, MediaAttachment } from 'mastodon/models/status';
export const MediaItem: React.FC<{
attachment: MediaAttachment;
onOpenMedia: (arg0: MediaAttachment) => void;
}> = ({ attachment, onOpenMedia }) => {
const [visible, setVisible] = useState(
(displayMedia !== 'hide_all' &&
!attachment.getIn(['status', 'sensitive'])) ||
displayMedia === 'show_all',
);
const [loaded, setLoaded] = useState(false);
const handleImageLoad = useCallback(() => {
setLoaded(true);
}, [setLoaded]);
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLVideoElement>) => {
if (e.target instanceof HTMLVideoElement) {
void e.target.play();
}
},
[],
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLVideoElement>) => {
if (e.target instanceof HTMLVideoElement) {
e.target.pause();
e.target.currentTime = 0;
}
},
[],
);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (visible) {
onOpenMedia(attachment);
} else {
setVisible(true);
}
}
},
[attachment, visible, onOpenMedia, setVisible],
);
const status = attachment.get('status') as Status;
const description = (attachment.getIn(['translation', 'description']) ||
attachment.get('description')) as string | undefined;
const previewUrl = attachment.get('preview_url') as string;
const fullUrl = attachment.get('url') as string;
const avatarUrl = status.getIn(['account', 'avatar_static']) as string;
const lang = status.get('language') as string;
const blurhash = attachment.get('blurhash') as string;
const statusId = status.get('id') as string;
const acct = status.getIn(['account', 'acct']) as string;
const type = attachment.get('type') as string;
let thumbnail;
const badges = [];
if (description && description.length > 0) {
badges.push(
<span key='alt' className='media-gallery__alt__label'>
ALT
</span>,
);
}
if (!visible) {
thumbnail = (
<div className='media-gallery__item__overlay'>
<Icon id='eye-slash' icon={VisibilityOffIcon} />
</div>
);
} else if (type === 'audio') {
thumbnail = (
<>
<img
src={previewUrl || avatarUrl}
alt={description}
title={description}
lang={lang}
onLoad={handleImageLoad}
/>
<div className='media-gallery__item__overlay media-gallery__item__overlay--corner'>
<Icon id='music' icon={HeadphonesIcon} />
</div>
</>
);
} else if (type === 'image') {
const focusX = (attachment.getIn(['meta', 'focus', 'x']) || 0) as number;
const focusY = (attachment.getIn(['meta', 'focus', 'y']) || 0) as number;
const x = (focusX / 2 + 0.5) * 100;
const y = (focusY / -2 + 0.5) * 100;
thumbnail = (
<img
src={previewUrl}
alt={description}
title={description}
lang={lang}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={handleImageLoad}
/>
);
} else if (['video', 'gifv'].includes(type)) {
const duration = attachment.getIn([
'meta',
'original',
'duration',
]) as number;
thumbnail = (
<div className='media-gallery__gifv'>
<video
className='media-gallery__item-gifv-thumbnail'
aria-label={description}
title={description}
lang={lang}
src={fullUrl}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onLoadedData={handleImageLoad}
autoPlay={autoPlayGif}
playsInline
loop
muted
/>
{type === 'video' && (
<div className='media-gallery__item__overlay media-gallery__item__overlay--corner'>
<Icon id='play' icon={MovieIcon} />
</div>
)}
</div>
);
if (type === 'gifv') {
badges.push(
<span key='gif' className='media-gallery__gifv__label'>
GIF
</span>,
);
} else {
badges.push(
<span key='video' className='media-gallery__gifv__label'>
{formatTime(Math.floor(duration))}
</span>,
);
}
}
return (
<div className='media-gallery__item media-gallery__item--square'>
<Blurhash
hash={blurhash}
className={classNames('media-gallery__preview', {
'media-gallery__preview--hidden': visible && loaded,
})}
dummy={!useBlurhash}
/>
<a
className='media-gallery__item-thumbnail'
href={`/@${acct}/${statusId}`}
onClick={handleClick}
target='_blank'
rel='noopener noreferrer'
>
{thumbnail}
</a>
{badges.length > 0 && (
<div className='media-gallery__item__badges'>{badges}</div>
)}
</div>
);
};

View file

@ -20,7 +20,7 @@ import { expandAccountMediaTimeline } from '../../actions/timelines';
import HeaderContainer from '../account_timeline/containers/header_container';
import Column from '../ui/components/column';
import MediaItem from './components/media_item';
import { MediaItem } from './components/media_item';
const mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);

View file

@ -151,7 +151,25 @@ export const DetailedStatus: React.FC<{
if (pictureInPicture.get('inUse')) {
media = <PictureInPicturePlaceholder aspectRatio={attachmentAspectRatio} />;
} else if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
if (
['image', 'gifv'].includes(
status.getIn(['media_attachments', 0, 'type']) as string,
) ||
status.get('media_attachments').size > 1
) {
media = (
<MediaGallery
standalone
sensitive={status.get('sensitive')}
media={status.get('media_attachments')}
lang={language}
height={300}
onOpenMedia={onOpenMedia}
visible={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
const description =
attachment.getIn(['translation', 'description']) ||
@ -200,19 +218,6 @@ export const DetailedStatus: React.FC<{
onToggleVisibility={onToggleMediaVisibility}
/>
);
} else {
media = (
<MediaGallery
standalone
sensitive={status.get('sensitive')}
media={status.get('media_attachments')}
lang={language}
height={300}
onOpenMedia={onOpenMedia}
visible={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
);
}
} else if (status.get('spoiler_text').length === 0) {
media = (

View file

@ -10,3 +10,5 @@ export type Status = Immutable.Map<string, unknown>;
type CardShape = Required<ApiPreviewCardJSON>;
export type Card = RecordOf<CardShape>;
export type MediaAttachment = Immutable.Map<string, unknown>;