Merge remote-tracking branch 'parent/main' into upstream-20241001
This commit is contained in:
commit
133a263f79
222 changed files with 1834 additions and 720 deletions
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,200 @@
|
|||
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 { AltTextBadge } from 'mastodon/components/alt_text_badge';
|
||||
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(<AltTextBadge key='alt' description={description} />);
|
||||
}
|
||||
|
||||
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__alt__label media-gallery__alt__label--non-interactive'
|
||||
>
|
||||
GIF
|
||||
</span>,
|
||||
);
|
||||
} else {
|
||||
badges.push(
|
||||
<span
|
||||
key='video'
|
||||
className='media-gallery__alt__label media-gallery__alt__label--non-interactive'
|
||||
>
|
||||
{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>
|
||||
);
|
||||
};
|
|
@ -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)]);
|
||||
|
|
|
@ -1,81 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
||||
import { undoUploadCompose, initMediaEditModal } from 'mastodon/actions/compose';
|
||||
import { Blurhash } from 'mastodon/components/blurhash';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import Motion from 'mastodon/features/ui/util/optional_motion';
|
||||
|
||||
export const Upload = ({ id, onDragStart, onDragEnter, onDragEnd }) => {
|
||||
const dispatch = useDispatch();
|
||||
const media = useSelector(state => state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id));
|
||||
const sensitive = useSelector(state => state.getIn(['compose', 'spoiler']));
|
||||
|
||||
const handleUndoClick = useCallback(() => {
|
||||
dispatch(undoUploadCompose(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleFocalPointClick = useCallback(() => {
|
||||
dispatch(initMediaEditModal(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
onDragStart(id);
|
||||
}, [onDragStart, id]);
|
||||
|
||||
const handleDragEnter = useCallback(() => {
|
||||
onDragEnter(id);
|
||||
}, [onDragEnter, id]);
|
||||
|
||||
if (!media) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 missingDescription = (media.get('description') || '').length === 0;
|
||||
|
||||
return (
|
||||
<div className='compose-form__upload' draggable onDragStart={handleDragStart} onDragEnter={handleDragEnter} onDragEnd={onDragEnd}>
|
||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
||||
{({ scale }) => (
|
||||
<div className='compose-form__upload__thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: !sensitive ? `url(${media.get('preview_url')})` : null, backgroundPosition: `${x}% ${y}%` }}>
|
||||
{sensitive && <Blurhash
|
||||
hash={media.get('blurhash')}
|
||||
className='compose-form__upload__preview'
|
||||
/>}
|
||||
|
||||
<div className='compose-form__upload__actions'>
|
||||
<button type='button' className='icon-button compose-form__upload__delete' onClick={handleUndoClick}><Icon icon={CloseIcon} /></button>
|
||||
<button type='button' className='icon-button' onClick={handleFocalPointClick}><Icon icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
|
||||
</div>
|
||||
|
||||
<div className='compose-form__upload__warning'>
|
||||
<button type='button' className={classNames('icon-button', { active: missingDescription })} onClick={handleFocalPointClick}>{missingDescription && <Icon icon={WarningIcon} />} ALT</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Upload.propTypes = {
|
||||
id: PropTypes.string,
|
||||
onDragEnter: PropTypes.func,
|
||||
onDragStart: PropTypes.func,
|
||||
onDragEnd: PropTypes.func,
|
||||
};
|
130
app/javascript/mastodon/features/compose/components/upload.tsx
Normal file
130
app/javascript/mastodon/features/compose/components/upload.tsx
Normal file
|
@ -0,0 +1,130 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
||||
import {
|
||||
undoUploadCompose,
|
||||
initMediaEditModal,
|
||||
} from 'mastodon/actions/compose';
|
||||
import { Blurhash } from 'mastodon/components/blurhash';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
export const Upload: React.FC<{
|
||||
id: string;
|
||||
dragging?: boolean;
|
||||
overlay?: boolean;
|
||||
tall?: boolean;
|
||||
wide?: boolean;
|
||||
}> = ({ id, dragging, overlay, tall, wide }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const media = useAppSelector(
|
||||
(state) =>
|
||||
state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
|
||||
.get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
.find((item: MediaAttachment) => item.get('id') === id) as // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
| MediaAttachment
|
||||
| undefined,
|
||||
);
|
||||
const sensitive = useAppSelector(
|
||||
(state) => state.compose.get('spoiler') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
);
|
||||
|
||||
const handleUndoClick = useCallback(() => {
|
||||
dispatch(undoUploadCompose(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleFocalPointClick = useCallback(() => {
|
||||
dispatch(initMediaEditModal(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id });
|
||||
|
||||
if (!media) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const focusX = media.getIn(['meta', 'focus', 'x']) as number;
|
||||
const focusY = media.getIn(['meta', 'focus', 'y']) as number;
|
||||
const x = (focusX / 2 + 0.5) * 100;
|
||||
const y = (focusY / -2 + 0.5) * 100;
|
||||
const missingDescription =
|
||||
((media.get('description') as string | undefined) ?? '').length === 0;
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('compose-form__upload media-gallery__item', {
|
||||
dragging,
|
||||
overlay,
|
||||
'media-gallery__item--tall': tall,
|
||||
'media-gallery__item--wide': wide,
|
||||
})}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div
|
||||
className='compose-form__upload__thumbnail'
|
||||
style={{
|
||||
backgroundImage: !sensitive
|
||||
? `url(${media.get('preview_url') as string})`
|
||||
: undefined,
|
||||
backgroundPosition: `${x}% ${y}%`,
|
||||
}}
|
||||
>
|
||||
{sensitive && (
|
||||
<Blurhash
|
||||
hash={media.get('blurhash') as string}
|
||||
className='compose-form__upload__preview'
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='compose-form__upload__actions'>
|
||||
<button
|
||||
type='button'
|
||||
className='icon-button compose-form__upload__delete'
|
||||
onClick={handleUndoClick}
|
||||
>
|
||||
<Icon id='close' icon={CloseIcon} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='icon-button'
|
||||
onClick={handleFocalPointClick}
|
||||
>
|
||||
<Icon id='edit' icon={EditIcon} />{' '}
|
||||
<FormattedMessage id='upload_form.edit' defaultMessage='Edit' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='compose-form__upload__warning'>
|
||||
<button
|
||||
type='button'
|
||||
className={classNames('icon-button', {
|
||||
active: missingDescription,
|
||||
})}
|
||||
onClick={handleFocalPointClick}
|
||||
>
|
||||
{missingDescription && <Icon id='warning' icon={WarningIcon} />} ALT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,53 +0,0 @@
|
|||
import { useRef, useCallback } from 'react';
|
||||
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import { changeMediaOrder } from 'mastodon/actions/compose';
|
||||
|
||||
import { Upload } from './upload';
|
||||
import { UploadProgress } from './upload_progress';
|
||||
|
||||
export const UploadForm = () => {
|
||||
const dispatch = useDispatch();
|
||||
const mediaIds = useSelector(state => state.getIn(['compose', 'media_attachments']).map(item => item.get('id')));
|
||||
const active = useSelector(state => state.getIn(['compose', 'is_uploading']));
|
||||
const progress = useSelector(state => state.getIn(['compose', 'progress']));
|
||||
const isProcessing = useSelector(state => state.getIn(['compose', 'is_processing']));
|
||||
|
||||
const dragItem = useRef();
|
||||
const dragOverItem = useRef();
|
||||
|
||||
const handleDragStart = useCallback(id => {
|
||||
dragItem.current = id;
|
||||
}, [dragItem]);
|
||||
|
||||
const handleDragEnter = useCallback(id => {
|
||||
dragOverItem.current = id;
|
||||
}, [dragOverItem]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
dispatch(changeMediaOrder(dragItem.current, dragOverItem.current));
|
||||
dragItem.current = null;
|
||||
dragOverItem.current = null;
|
||||
}, [dispatch, dragItem, dragOverItem]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UploadProgress active={active} progress={progress} isProcessing={isProcessing} />
|
||||
|
||||
{mediaIds.size > 0 && (
|
||||
<div className='compose-form__uploads'>
|
||||
{mediaIds.map(id => (
|
||||
<Upload
|
||||
key={id}
|
||||
id={id}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,185 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import type { List } from 'immutable';
|
||||
|
||||
import type {
|
||||
DragStartEvent,
|
||||
DragEndEvent,
|
||||
UniqueIdentifier,
|
||||
Announcements,
|
||||
ScreenReaderInstructions,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragOverlay,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
rectSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
|
||||
import { changeMediaOrder } from 'mastodon/actions/compose';
|
||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { Upload } from './upload';
|
||||
import { UploadProgress } from './upload_progress';
|
||||
|
||||
const messages = defineMessages({
|
||||
screenReaderInstructions: {
|
||||
id: 'upload_form.drag_and_drop.instructions',
|
||||
defaultMessage:
|
||||
'To pick up a media attachment, press space or enter. While dragging, use the arrow keys to move the media attachment in any given direction. Press space or enter again to drop the media attachment in its new position, or press escape to cancel.',
|
||||
},
|
||||
onDragStart: {
|
||||
id: 'upload_form.drag_and_drop.on_drag_start',
|
||||
defaultMessage: 'Picked up media attachment {item}.',
|
||||
},
|
||||
onDragOver: {
|
||||
id: 'upload_form.drag_and_drop.on_drag_over',
|
||||
defaultMessage: 'Media attachment {item} was moved.',
|
||||
},
|
||||
onDragEnd: {
|
||||
id: 'upload_form.drag_and_drop.on_drag_end',
|
||||
defaultMessage: 'Media attachment {item} was dropped.',
|
||||
},
|
||||
onDragCancel: {
|
||||
id: 'upload_form.drag_and_drop.on_drag_cancel',
|
||||
defaultMessage:
|
||||
'Dragging was cancelled. Media attachment {item} was dropped.',
|
||||
},
|
||||
});
|
||||
|
||||
export const UploadForm: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const mediaIds = useAppSelector(
|
||||
(state) =>
|
||||
state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
|
||||
.get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
.map((item: MediaAttachment) => item.get('id')) as List<string>, // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
);
|
||||
const active = useAppSelector(
|
||||
(state) => state.compose.get('is_uploading') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
);
|
||||
const progress = useAppSelector(
|
||||
(state) => state.compose.get('progress') as number, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
);
|
||||
const isProcessing = useAppSelector(
|
||||
(state) => state.compose.get('is_processing') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
);
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 5,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: DragStartEvent) => {
|
||||
const { active } = e;
|
||||
|
||||
setActiveId(active.id);
|
||||
},
|
||||
[setActiveId],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(e: DragEndEvent) => {
|
||||
const { active, over } = e;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
dispatch(changeMediaOrder(active.id, over.id));
|
||||
}
|
||||
|
||||
setActiveId(null);
|
||||
},
|
||||
[dispatch, setActiveId],
|
||||
);
|
||||
|
||||
const accessibility: {
|
||||
screenReaderInstructions: ScreenReaderInstructions;
|
||||
announcements: Announcements;
|
||||
} = useMemo(
|
||||
() => ({
|
||||
screenReaderInstructions: {
|
||||
draggable: intl.formatMessage(messages.screenReaderInstructions),
|
||||
},
|
||||
|
||||
announcements: {
|
||||
onDragStart({ active }) {
|
||||
return intl.formatMessage(messages.onDragStart, { item: active.id });
|
||||
},
|
||||
|
||||
onDragOver({ active }) {
|
||||
return intl.formatMessage(messages.onDragOver, { item: active.id });
|
||||
},
|
||||
|
||||
onDragEnd({ active }) {
|
||||
return intl.formatMessage(messages.onDragEnd, { item: active.id });
|
||||
},
|
||||
|
||||
onDragCancel({ active }) {
|
||||
return intl.formatMessage(messages.onDragCancel, { item: active.id });
|
||||
},
|
||||
},
|
||||
}),
|
||||
[intl],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UploadProgress
|
||||
active={active}
|
||||
progress={progress}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
|
||||
{mediaIds.size > 0 && (
|
||||
<div
|
||||
className={`compose-form__uploads media-gallery media-gallery--layout-${mediaIds.size}`}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
accessibility={accessibility}
|
||||
>
|
||||
<SortableContext
|
||||
items={mediaIds.toArray()}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
{mediaIds.map((id, idx) => (
|
||||
<Upload
|
||||
key={id}
|
||||
id={id}
|
||||
dragging={id === activeId}
|
||||
tall={mediaIds.size < 3 || (mediaIds.size === 3 && idx === 0)}
|
||||
wide={mediaIds.size === 1}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId ? <Upload id={activeId as string} overlay /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -162,7 +162,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']) ||
|
||||
|
@ -211,19 +229,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('card')) {
|
||||
media = (
|
||||
|
|
|
@ -30,9 +30,9 @@ export const DomainBlockModal: React.FC<{
|
|||
}> = ({ domain, accountId, acct }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [preview, setPreview] = useState<DomainBlockPreviewResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [preview, setPreview] = useState<
|
||||
DomainBlockPreviewResponse | 'error' | null
|
||||
>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (loading) {
|
||||
|
@ -65,6 +65,7 @@ export const DomainBlockModal: React.FC<{
|
|||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setPreview('error');
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setPreview, setLoading, domain]);
|
||||
|
@ -89,7 +90,35 @@ export const DomainBlockModal: React.FC<{
|
|||
</div>
|
||||
|
||||
<div className='safety-action-modal__bullet-points'>
|
||||
{preview && preview.followers_count + preview.following_count > 0 && (
|
||||
{preview &&
|
||||
preview !== 'error' &&
|
||||
preview.followers_count + preview.following_count > 0 && (
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'>
|
||||
<Icon id='' icon={PersonRemoveIcon} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.you_will_lose_num_followers'
|
||||
defaultMessage='You will lose {followersCount, plural, one {{followersCountDisplay} follower} other {{followersCountDisplay} followers}} and {followingCount, plural, one {{followingCountDisplay} person you follow} other {{followingCountDisplay} people you follow}}.'
|
||||
values={{
|
||||
followersCount: preview.followers_count,
|
||||
followersCountDisplay: (
|
||||
<ShortNumber value={preview.followers_count} />
|
||||
),
|
||||
followingCount: preview.following_count,
|
||||
followingCountDisplay: (
|
||||
<ShortNumber value={preview.following_count} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview === 'error' && (
|
||||
<div>
|
||||
<div className='safety-action-modal__bullet-points__icon'>
|
||||
<Icon id='' icon={PersonRemoveIcon} />
|
||||
|
@ -97,18 +126,8 @@ export const DomainBlockModal: React.FC<{
|
|||
<div>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='domain_block_modal.you_will_lose_num_followers'
|
||||
defaultMessage='You will lose {followersCount, plural, one {{followersCountDisplay} follower} other {{followersCountDisplay} followers}} and {followingCount, plural, one {{followingCountDisplay} person you follow} other {{followingCountDisplay} people you follow}}.'
|
||||
values={{
|
||||
followersCount: preview.followers_count,
|
||||
followersCountDisplay: (
|
||||
<ShortNumber value={preview.followers_count} />
|
||||
),
|
||||
followingCount: preview.following_count,
|
||||
followingCountDisplay: (
|
||||
<ShortNumber value={preview.following_count} />
|
||||
),
|
||||
}}
|
||||
id='domain_block_modal.you_will_lose_relationships'
|
||||
defaultMessage='You will lose all followers and people you follow from this server.'
|
||||
/>
|
||||
</strong>
|
||||
</div>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue