Merge remote-tracking branch 'parent/main' into upstream-20240123
This commit is contained in:
commit
50ae2d9439
320 changed files with 2587 additions and 2817 deletions
|
@ -1,86 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
|
||||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import BadgeIcon from '@/material-icons/400-24px/badge.svg?react';
|
||||
import GlobeIcon from '@/material-icons/400-24px/globe.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
export const DomainPill = ({ domain, username, isSelf }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setOpen(!open);
|
||||
}, [open, setOpen]);
|
||||
|
||||
const handleExpandClick = useCallback(() => {
|
||||
setExpanded(!expanded);
|
||||
}, [expanded, setExpanded]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={classNames('account__domain-pill', { active: open })} ref={triggerRef} onClick={handleClick}>{domain}</button>
|
||||
|
||||
<Overlay show={open} rootClose onHide={handleClick} offset={[5, 5]} target={triggerRef}>
|
||||
{({ props }) => (
|
||||
<div {...props} className='account__domain-pill__popout dropdown-animation'>
|
||||
<div className='account__domain-pill__popout__header'>
|
||||
<div className='account__domain-pill__popout__header__icon'><Icon icon={BadgeIcon} /></div>
|
||||
<h3><FormattedMessage id='domain_pill.whats_in_a_handle' defaultMessage="What's in a handle?" /></h3>
|
||||
</div>
|
||||
|
||||
<div className='account__domain-pill__popout__handle'>
|
||||
<div className='account__domain-pill__popout__handle__label'>{isSelf ? <FormattedMessage id='domain_pill.your_handle' defaultMessage='Your handle:' /> : <FormattedMessage id='domain_pill.their_handle' defaultMessage='Their handle:' />}</div>
|
||||
<div className='account__domain-pill__popout__handle__handle'>@{username}@{domain}</div>
|
||||
</div>
|
||||
|
||||
<div className='account__domain-pill__popout__parts'>
|
||||
<div>
|
||||
<div className='account__domain-pill__popout__parts__icon'><Icon icon={AlternateEmailIcon} /></div>
|
||||
|
||||
<div>
|
||||
<h6><FormattedMessage id='domain_pill.username' defaultMessage='Username' /></h6>
|
||||
<p>{isSelf ? <FormattedMessage id='domain_pill.your_username' defaultMessage='Your unique identifier on this server. It’s possible to find users with the same username on different servers.' /> : <FormattedMessage id='domain_pill.their_username' defaultMessage='Their unique identifier on their server. It’s possible to find users with the same username on different servers.' />}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='account__domain-pill__popout__parts__icon'><Icon icon={GlobeIcon} /></div>
|
||||
|
||||
<div>
|
||||
<h6><FormattedMessage id='domain_pill.server' defaultMessage='Server' /></h6>
|
||||
<p>{isSelf ? <FormattedMessage id='domain_pill.your_server' defaultMessage='Your digital home, where all of your posts live. Don’t like this one? Transfer servers at any time and bring your followers, too.' /> : <FormattedMessage id='domain_pill.their_server' defaultMessage='Their digital home, where all of their posts live.' />}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>{isSelf ? <FormattedMessage id='domain_pill.who_you_are' defaultMessage='Because your handle says who you are and where you are, people can interact with you across the social web of <button>ActivityPub-powered platforms</button>.' values={{ button: x => <button onClick={handleExpandClick} className='link-button'>{x}</button> }} /> : <FormattedMessage id='domain_pill.who_they_are' defaultMessage='Since handles say who someone is and where they are, you can interact with people across the social web of <button>ActivityPub-powered platforms</button>.' values={{ button: x => <button onClick={handleExpandClick} className='link-button'>{x}</button> }} />}</p>
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
<p><FormattedMessage id='domain_pill.activitypub_like_language' defaultMessage='ActivityPub is like the language Mastodon speaks with other social networks.' /></p>
|
||||
<p><FormattedMessage id='domain_pill.activitypub_lets_connect' defaultMessage='It lets you connect and interact with people not just on Mastodon, but across different social apps too.' /></p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
DomainPill.propTypes = {
|
||||
username: PropTypes.string.isRequired,
|
||||
domain: PropTypes.string.isRequired,
|
||||
isSelf: PropTypes.bool,
|
||||
};
|
|
@ -0,0 +1,197 @@
|
|||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import BadgeIcon from '@/material-icons/400-24px/badge.svg?react';
|
||||
import GlobeIcon from '@/material-icons/400-24px/globe.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
export const DomainPill: React.FC<{
|
||||
domain: string;
|
||||
username: string;
|
||||
isSelf: boolean;
|
||||
}> = ({ domain, username, isSelf }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setOpen(!open);
|
||||
}, [open, setOpen]);
|
||||
|
||||
const handleExpandClick = useCallback(() => {
|
||||
setExpanded(!expanded);
|
||||
}, [expanded, setExpanded]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={classNames('account__domain-pill', { active: open })}
|
||||
ref={triggerRef}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{domain}
|
||||
</button>
|
||||
|
||||
<Overlay
|
||||
show={open}
|
||||
rootClose
|
||||
onHide={handleClick}
|
||||
offset={[5, 5]}
|
||||
target={triggerRef}
|
||||
>
|
||||
{({ props }) => (
|
||||
<div
|
||||
{...props}
|
||||
className='account__domain-pill__popout dropdown-animation'
|
||||
>
|
||||
<div className='account__domain-pill__popout__header'>
|
||||
<div className='account__domain-pill__popout__header__icon'>
|
||||
<Icon id='' icon={BadgeIcon} />
|
||||
</div>
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id='domain_pill.whats_in_a_handle'
|
||||
defaultMessage="What's in a handle?"
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className='account__domain-pill__popout__handle'>
|
||||
<div className='account__domain-pill__popout__handle__label'>
|
||||
{isSelf ? (
|
||||
<FormattedMessage
|
||||
id='domain_pill.your_handle'
|
||||
defaultMessage='Your handle:'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='domain_pill.their_handle'
|
||||
defaultMessage='Their handle:'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='account__domain-pill__popout__handle__handle'>
|
||||
@{username}@{domain}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='account__domain-pill__popout__parts'>
|
||||
<div>
|
||||
<div className='account__domain-pill__popout__parts__icon'>
|
||||
<Icon id='' icon={AlternateEmailIcon} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6>
|
||||
<FormattedMessage
|
||||
id='domain_pill.username'
|
||||
defaultMessage='Username'
|
||||
/>
|
||||
</h6>
|
||||
<p>
|
||||
{isSelf ? (
|
||||
<FormattedMessage
|
||||
id='domain_pill.your_username'
|
||||
defaultMessage='Your unique identifier on this server. It’s possible to find users with the same username on different servers.'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='domain_pill.their_username'
|
||||
defaultMessage='Their unique identifier on their server. It’s possible to find users with the same username on different servers.'
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='account__domain-pill__popout__parts__icon'>
|
||||
<Icon id='' icon={GlobeIcon} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6>
|
||||
<FormattedMessage
|
||||
id='domain_pill.server'
|
||||
defaultMessage='Server'
|
||||
/>
|
||||
</h6>
|
||||
<p>
|
||||
{isSelf ? (
|
||||
<FormattedMessage
|
||||
id='domain_pill.your_server'
|
||||
defaultMessage='Your digital home, where all of your posts live. Don’t like this one? Transfer servers at any time and bring your followers, too.'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='domain_pill.their_server'
|
||||
defaultMessage='Their digital home, where all of their posts live.'
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
{isSelf ? (
|
||||
<FormattedMessage
|
||||
id='domain_pill.who_you_are'
|
||||
defaultMessage='Because your handle says who you are and where you are, people can interact with you across the social web of <button>ActivityPub-powered platforms</button>.'
|
||||
values={{
|
||||
button: (x) => (
|
||||
<button
|
||||
onClick={handleExpandClick}
|
||||
className='link-button'
|
||||
>
|
||||
{x}
|
||||
</button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='domain_pill.who_they_are'
|
||||
defaultMessage='Since handles say who someone is and where they are, you can interact with people across the social web of <button>ActivityPub-powered platforms</button>.'
|
||||
values={{
|
||||
button: (x) => (
|
||||
<button
|
||||
onClick={handleExpandClick}
|
||||
className='link-button'
|
||||
>
|
||||
{x}
|
||||
</button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='domain_pill.activitypub_like_language'
|
||||
defaultMessage='ActivityPub is like the language Mastodon speaks with other social networks.'
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='domain_pill.activitypub_lets_connect'
|
||||
defaultMessage='It lets you connect and interact with people not just on Mastodon, but across different social apps too.'
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
};
|
531
app/javascript/mastodon/features/alt_text_modal/index.tsx
Normal file
531
app/javascript/mastodon/features/alt_text_modal/index.tsx
Normal file
|
@ -0,0 +1,531 @@
|
|||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
} from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
import { length } from 'stringz';
|
||||
// eslint-disable-next-line import/extensions
|
||||
import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
|
||||
|
||||
import { showAlertForError } from 'mastodon/actions/alerts';
|
||||
import { uploadThumbnail } from 'mastodon/actions/compose';
|
||||
import { changeUploadCompose } from 'mastodon/actions/compose_typed';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { GIFV } from 'mastodon/components/gifv';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
|
||||
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
|
||||
import Video, { getPointerPosition } from 'mastodon/features/video';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
import { assetHost } from 'mastodon/utils/config';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholderVisual: {
|
||||
id: 'alt_text_modal.describe_for_people_with_visual_impairments',
|
||||
defaultMessage: 'Describe this for people with visual impairments…',
|
||||
},
|
||||
placeholderHearing: {
|
||||
id: 'alt_text_modal.describe_for_people_with_hearing_impairments',
|
||||
defaultMessage: 'Describe this for people with hearing impairments…',
|
||||
},
|
||||
discardMessage: {
|
||||
id: 'confirmations.discard_edit_media.message',
|
||||
defaultMessage:
|
||||
'You have unsaved changes to the media description or preview, discard them anyway?',
|
||||
},
|
||||
discardConfirm: {
|
||||
id: 'confirmations.discard_edit_media.confirm',
|
||||
defaultMessage: 'Discard',
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_LENGTH = 1500;
|
||||
|
||||
type FocalPoint = [number, number];
|
||||
|
||||
const UploadButton: React.FC<{
|
||||
children: React.ReactNode;
|
||||
onSelectFile: (arg0: File) => void;
|
||||
mimeTypes: string;
|
||||
}> = ({ children, onSelectFile, mimeTypes }) => {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
fileRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
onSelectFile(file);
|
||||
}
|
||||
},
|
||||
[onSelectFile],
|
||||
);
|
||||
|
||||
return (
|
||||
<label>
|
||||
<Button onClick={handleClick}>{children}</Button>
|
||||
|
||||
<input
|
||||
id='upload-modal__thumbnail'
|
||||
ref={fileRef}
|
||||
type='file'
|
||||
accept={mimeTypes}
|
||||
onChange={handleChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const Preview: React.FC<{
|
||||
mediaId: string;
|
||||
position: FocalPoint;
|
||||
onPositionChange: (arg0: FocalPoint) => void;
|
||||
}> = ({ mediaId, position, onPositionChange }) => {
|
||||
const media = useAppSelector((state) =>
|
||||
(
|
||||
(state.compose as ImmutableMap<string, unknown>).get(
|
||||
'media_attachments',
|
||||
) as ImmutableList<MediaAttachment>
|
||||
).find((x) => x.get('id') === mediaId),
|
||||
);
|
||||
const account = useAppSelector((state) =>
|
||||
me ? state.accounts.get(me) : undefined,
|
||||
);
|
||||
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [x, y] = position;
|
||||
const nodeRef = useRef<HTMLImageElement | HTMLVideoElement | null>(null);
|
||||
const draggingRef = useRef<boolean>(false);
|
||||
|
||||
const setRef = useCallback(
|
||||
(e: HTMLImageElement | HTMLVideoElement | null) => {
|
||||
nodeRef.current = e;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y } = getPointerPosition(nodeRef.current, e);
|
||||
setDragging(true);
|
||||
draggingRef.current = true;
|
||||
onPositionChange([x, y]);
|
||||
},
|
||||
[setDragging, onPositionChange],
|
||||
);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const { x, y } = getPointerPosition(nodeRef.current, e);
|
||||
setDragging(true);
|
||||
draggingRef.current = true;
|
||||
onPositionChange([x, y]);
|
||||
},
|
||||
[setDragging, onPositionChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseUp = () => {
|
||||
setDragging(false);
|
||||
draggingRef.current = false;
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (draggingRef.current) {
|
||||
const { x, y } = getPointerPosition(nodeRef.current, e);
|
||||
onPositionChange([x, y]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setDragging(false);
|
||||
draggingRef.current = false;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (draggingRef.current) {
|
||||
const { x, y } = getPointerPosition(nodeRef.current, e);
|
||||
onPositionChange([x, y]);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
document.addEventListener('touchmove', handleTouchMove);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
};
|
||||
}, [setDragging, onPositionChange]);
|
||||
|
||||
if (!media) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (media.get('type') === 'image') {
|
||||
return (
|
||||
<div className={classNames('focal-point', { dragging })}>
|
||||
<img
|
||||
ref={setRef}
|
||||
draggable={false}
|
||||
src={media.get('url') as string}
|
||||
alt=''
|
||||
role='presentation'
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
/>
|
||||
<div
|
||||
className='focal-point__reticle'
|
||||
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (media.get('type') === 'gifv') {
|
||||
return (
|
||||
<div className={classNames('focal-point', { dragging })}>
|
||||
<GIFV
|
||||
ref={setRef}
|
||||
src={media.get('url') as string}
|
||||
alt=''
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
/>
|
||||
<div
|
||||
className='focal-point__reticle'
|
||||
style={{ top: `${y * 100}%`, left: `${x * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (media.get('type') === 'video') {
|
||||
return (
|
||||
<Video
|
||||
preview={media.get('preview_url') as string}
|
||||
frameRate={media.getIn(['meta', 'original', 'frame_rate']) as string}
|
||||
blurhash={media.get('blurhash') as string}
|
||||
src={media.get('url') as string}
|
||||
detailed
|
||||
inline
|
||||
editable
|
||||
/>
|
||||
);
|
||||
} else if (media.get('type') === 'audio') {
|
||||
return (
|
||||
<Audio
|
||||
src={media.get('url') as string}
|
||||
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
|
||||
poster={
|
||||
(media.get('preview_url') as string | undefined) ??
|
||||
account?.avatar_static
|
||||
}
|
||||
backgroundColor={
|
||||
media.getIn(['meta', 'colors', 'background']) as string
|
||||
}
|
||||
foregroundColor={
|
||||
media.getIn(['meta', 'colors', 'foreground']) as string
|
||||
}
|
||||
accentColor={media.getIn(['meta', 'colors', 'accent']) as string}
|
||||
editable
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface RestoreProps {
|
||||
previousDescription: string;
|
||||
previousPosition: FocalPoint;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
mediaId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ConfirmationMessage {
|
||||
message: string;
|
||||
confirm: string;
|
||||
props?: RestoreProps;
|
||||
}
|
||||
|
||||
export interface ModalRef {
|
||||
getCloseConfirmationMessage: () => null | ConfirmationMessage;
|
||||
}
|
||||
|
||||
export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
|
||||
({ mediaId, previousDescription, previousPosition, onClose }, ref) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const media = useAppSelector((state) =>
|
||||
(
|
||||
(state.compose as ImmutableMap<string, unknown>).get(
|
||||
'media_attachments',
|
||||
) as ImmutableList<MediaAttachment>
|
||||
).find((x) => x.get('id') === mediaId),
|
||||
);
|
||||
const lang = useAppSelector(
|
||||
(state) =>
|
||||
(state.compose as ImmutableMap<string, unknown>).get('lang') as string,
|
||||
);
|
||||
const focusX =
|
||||
(media?.getIn(['meta', 'focus', 'x'], 0) as number | undefined) ?? 0;
|
||||
const focusY =
|
||||
(media?.getIn(['meta', 'focus', 'y'], 0) as number | undefined) ?? 0;
|
||||
const [description, setDescription] = useState(
|
||||
previousDescription ??
|
||||
(media?.get('description') as string | undefined) ??
|
||||
'',
|
||||
);
|
||||
const [position, setPosition] = useState<FocalPoint>(
|
||||
previousPosition ?? [focusX / 2 + 0.5, focusY / -2 + 0.5],
|
||||
);
|
||||
const [isDetecting, setIsDetecting] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const dirtyRef = useRef(
|
||||
previousDescription || previousPosition ? true : false,
|
||||
);
|
||||
const type = media?.get('type') as string;
|
||||
const valid = length(description) <= MAX_LENGTH;
|
||||
|
||||
const handleDescriptionChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setDescription(e.target.value);
|
||||
dirtyRef.current = true;
|
||||
},
|
||||
[setDescription],
|
||||
);
|
||||
|
||||
const handleThumbnailChange = useCallback(
|
||||
(file: File) => {
|
||||
dispatch(uploadThumbnail(mediaId, file));
|
||||
},
|
||||
[dispatch, mediaId],
|
||||
);
|
||||
|
||||
const handlePositionChange = useCallback(
|
||||
(position: FocalPoint) => {
|
||||
setPosition(position);
|
||||
dirtyRef.current = true;
|
||||
},
|
||||
[setPosition],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
setIsSaving(true);
|
||||
|
||||
dispatch(
|
||||
changeUploadCompose({
|
||||
id: mediaId,
|
||||
description,
|
||||
focus: `${((position[0] - 0.5) * 2).toFixed(2)},${((position[1] - 0.5) * -2).toFixed(2)}`,
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
setIsSaving(false);
|
||||
dirtyRef.current = false;
|
||||
onClose();
|
||||
return '';
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setIsSaving(false);
|
||||
dispatch(showAlertForError(err));
|
||||
});
|
||||
}, [dispatch, setIsSaving, mediaId, onClose, position, description]);
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
if (valid) {
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSubmit, valid],
|
||||
);
|
||||
|
||||
const handleDetectClick = useCallback(() => {
|
||||
setIsDetecting(true);
|
||||
|
||||
fetchTesseract()
|
||||
.then(async ({ createWorker }) => {
|
||||
const worker = await createWorker('eng', 1, {
|
||||
workerPath: tesseractWorkerPath as string,
|
||||
corePath: tesseractCorePath as string,
|
||||
langPath: `${assetHost}/ocr/lang-data`,
|
||||
cacheMethod: 'write',
|
||||
});
|
||||
|
||||
const image = URL.createObjectURL(media?.get('file') as File);
|
||||
const result = await worker.recognize(image);
|
||||
|
||||
setDescription(result.data.text);
|
||||
setIsDetecting(false);
|
||||
|
||||
await worker.terminate();
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setIsDetecting(false);
|
||||
});
|
||||
}, [setDescription, setIsDetecting, media]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getCloseConfirmationMessage: () => {
|
||||
if (dirtyRef.current) {
|
||||
return {
|
||||
message: intl.formatMessage(messages.discardMessage),
|
||||
confirm: intl.formatMessage(messages.discardConfirm),
|
||||
props: {
|
||||
previousDescription: description,
|
||||
previousPosition: position,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
[intl, description, position],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal dialog-modal'>
|
||||
<div className='dialog-modal__header'>
|
||||
<Button onClick={handleSubmit} disabled={!valid}>
|
||||
{isSaving ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='alt_text_modal.done'
|
||||
defaultMessage='Done'
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<span className='dialog-modal__header__title'>
|
||||
<FormattedMessage
|
||||
id='alt_text_modal.add_alt_text'
|
||||
defaultMessage='Add alt text'
|
||||
/>
|
||||
</span>
|
||||
|
||||
<Button secondary onClick={onClose}>
|
||||
<FormattedMessage
|
||||
id='alt_text_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='dialog-modal__content'>
|
||||
<div className='dialog-modal__content__preview'>
|
||||
<Preview
|
||||
mediaId={mediaId}
|
||||
position={position}
|
||||
onPositionChange={handlePositionChange}
|
||||
/>
|
||||
|
||||
{(type === 'audio' || type === 'video') && (
|
||||
<UploadButton
|
||||
onSelectFile={handleThumbnailChange}
|
||||
mimeTypes='image/jpeg,image/png,image/gif,image/heic,image/heif,image/webp,image/avif'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='alt_text_modal.change_thumbnail'
|
||||
defaultMessage='Change thumbnail'
|
||||
/>
|
||||
</UploadButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
className='dialog-modal__content__form simple_form'
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className='input'>
|
||||
<div className='label_input'>
|
||||
<Textarea
|
||||
id='description'
|
||||
value={isDetecting ? ' ' : description}
|
||||
onChange={handleDescriptionChange}
|
||||
onKeyUp={handleKeyUp}
|
||||
lang={lang}
|
||||
placeholder={intl.formatMessage(
|
||||
type === 'audio'
|
||||
? messages.placeholderHearing
|
||||
: messages.placeholderVisual,
|
||||
)}
|
||||
minRows={3}
|
||||
disabled={isDetecting}
|
||||
/>
|
||||
|
||||
{isDetecting && (
|
||||
<div className='label_input__loading-indicator'>
|
||||
<Skeleton width='100%' />
|
||||
<Skeleton width='100%' />
|
||||
<Skeleton width='61%' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='input__toolbar'>
|
||||
<button
|
||||
className='link-button'
|
||||
onClick={handleDetectClick}
|
||||
disabled={type !== 'image' || isDetecting}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='alt_text_modal.add_text_from_image'
|
||||
defaultMessage='Add text from image'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<CharacterCounter
|
||||
max={MAX_LENGTH}
|
||||
text={isDetecting ? '' : description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AltTextModal.displayName = 'AltTextModal';
|
|
@ -581,10 +581,14 @@ class Audio extends PureComponent {
|
|||
</div>
|
||||
|
||||
<div className='video-player__buttons right'>
|
||||
{!editable && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' icon={VisibilityOffIcon} /></button>}
|
||||
<a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
|
||||
<Icon id={'download'} icon={DownloadIcon} />
|
||||
</a>
|
||||
{!editable && (
|
||||
<>
|
||||
<button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' icon={VisibilityOffIcon} /></button>
|
||||
<a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
|
||||
<Icon id='download' icon={DownloadIcon} />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { length } from 'stringz';
|
||||
|
||||
export const CharacterCounter = ({ text, max }) => {
|
||||
const diff = max - length(text);
|
||||
|
||||
if (diff < 0) {
|
||||
return <span className='character-counter character-counter--over'>{diff}</span>;
|
||||
}
|
||||
|
||||
return <span className='character-counter'>{diff}</span>;
|
||||
};
|
||||
|
||||
CharacterCounter.propTypes = {
|
||||
text: PropTypes.string.isRequired,
|
||||
max: PropTypes.number.isRequired,
|
||||
};
|
|
@ -0,0 +1,16 @@
|
|||
import { length } from 'stringz';
|
||||
|
||||
export const CharacterCounter: React.FC<{
|
||||
text: string;
|
||||
max: number;
|
||||
}> = ({ text, max }) => {
|
||||
const diff = max - length(text);
|
||||
|
||||
if (diff < 0) {
|
||||
return (
|
||||
<span className='character-counter character-counter--over'>{diff}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className='character-counter'>{diff}</span>;
|
||||
};
|
|
@ -332,6 +332,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
<div className='compose-form__submit'>
|
||||
<Button
|
||||
type='submit'
|
||||
compact
|
||||
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
|
||||
disabled={!this.canSubmit()}
|
||||
/>
|
||||
|
|
|
@ -27,6 +27,7 @@ class LanguageDropdownMenu extends PureComponent {
|
|||
|
||||
static propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
guess: PropTypes.string,
|
||||
frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
|
@ -81,14 +82,17 @@ class LanguageDropdownMenu extends PureComponent {
|
|||
};
|
||||
|
||||
search () {
|
||||
const { languages, value, frequentlyUsedLanguages } = this.props;
|
||||
const { languages, value, frequentlyUsedLanguages, guess } = this.props;
|
||||
const { searchValue } = this.state;
|
||||
|
||||
if (searchValue === '') {
|
||||
return [...languages].sort((a, b) => {
|
||||
// Push current selection to the top of the list
|
||||
|
||||
if (a[0] === value) {
|
||||
if (guess && a[0] === guess) { // Push guessed language higher than current selection
|
||||
return -1;
|
||||
} else if (guess && b[0] === guess) {
|
||||
return 1;
|
||||
} else if (a[0] === value) { // Push current selection to the top of the list
|
||||
return -1;
|
||||
} else if (b[0] === value) {
|
||||
return 1;
|
||||
|
@ -238,6 +242,7 @@ class LanguageDropdown extends PureComponent {
|
|||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string),
|
||||
guess: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
@ -281,7 +286,7 @@ class LanguageDropdown extends PureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { value, intl, frequentlyUsedLanguages } = this.props;
|
||||
const { value, guess, intl, frequentlyUsedLanguages } = this.props;
|
||||
const { open, placement } = this.state;
|
||||
const current = preloadedLanguages.find(lang => lang[0] === value) ?? [];
|
||||
|
||||
|
@ -294,7 +299,7 @@ class LanguageDropdown extends PureComponent {
|
|||
onClick={this.handleToggle}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleButtonKeyDown}
|
||||
className={classNames('dropdown-button', { active: open })}
|
||||
className={classNames('dropdown-button', { active: open, warning: guess !== '' && guess !== value })}
|
||||
>
|
||||
<Icon icon={TranslateIcon} />
|
||||
<span className='dropdown-button__label'>{current[2] ?? value}</span>
|
||||
|
@ -306,6 +311,7 @@ class LanguageDropdown extends PureComponent {
|
|||
<div className={`dropdown-animation language-dropdown__dropdown ${placement}`} >
|
||||
<LanguageDropdownMenu
|
||||
value={value}
|
||||
guess={guess}
|
||||
frequentlyUsedLanguages={frequentlyUsedLanguages}
|
||||
onClose={this.handleClose}
|
||||
onChange={this.handleChange}
|
||||
|
|
|
@ -4,16 +4,16 @@ import { FormattedMessage } from 'react-intl';
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
|
||||
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 { undoUploadCompose } from 'mastodon/actions/compose';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { Blurhash } from 'mastodon/components/blurhash';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||
|
@ -27,16 +27,15 @@ export const Upload: React.FC<{
|
|||
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 media = useAppSelector((state) =>
|
||||
(
|
||||
(state.compose as ImmutableMap<string, unknown>).get(
|
||||
'media_attachments',
|
||||
) as ImmutableList<MediaAttachment>
|
||||
).find((item) => item.get('id') === id),
|
||||
);
|
||||
const sensitive = useAppSelector(
|
||||
(state) => state.compose.get('spoiler') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
(state) => state.compose.get('spoiler') as boolean,
|
||||
);
|
||||
|
||||
const handleUndoClick = useCallback(() => {
|
||||
|
@ -44,7 +43,9 @@ export const Upload: React.FC<{
|
|||
}, [dispatch, id]);
|
||||
|
||||
const handleFocalPointClick = useCallback(() => {
|
||||
dispatch(initMediaEditModal(id));
|
||||
dispatch(
|
||||
openModal({ modalType: 'FOCAL_POINT', modalProps: { mediaId: id } }),
|
||||
);
|
||||
}, [dispatch, id]);
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
|
|
|
@ -2,7 +2,11 @@ import { useState, useCallback, useMemo } from 'react';
|
|||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import type { List } from 'immutable';
|
||||
import type {
|
||||
List,
|
||||
Map as ImmutableMap,
|
||||
List as ImmutableList,
|
||||
} from 'immutable';
|
||||
|
||||
import type {
|
||||
DragStartEvent,
|
||||
|
@ -63,18 +67,20 @@ export const UploadForm: React.FC = () => {
|
|||
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
|
||||
(
|
||||
(state.compose as ImmutableMap<string, unknown>).get(
|
||||
'media_attachments',
|
||||
) as ImmutableList<MediaAttachment>
|
||||
).map((item: MediaAttachment) => item.get('id')) as List<string>,
|
||||
);
|
||||
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
|
||||
(state) => state.compose.get('is_uploading') as boolean,
|
||||
);
|
||||
const progress = useAppSelector(
|
||||
(state) => state.compose.get('progress') as number, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
(state) => state.compose.get('progress') as number,
|
||||
);
|
||||
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
|
||||
(state) => state.compose.get('is_processing') as boolean,
|
||||
);
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
|
||||
const sensors = useSensors(
|
||||
|
|
|
@ -2,6 +2,8 @@ import { createSelector } from '@reduxjs/toolkit';
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import lande from 'lande';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { changeComposeLanguage } from 'mastodon/actions/compose';
|
||||
|
||||
|
@ -16,9 +18,83 @@ const getFrequentlyUsedLanguages = createSelector([
|
|||
.toArray()
|
||||
));
|
||||
|
||||
const ISO_639_MAP = {
|
||||
afr: 'af', // Afrikaans
|
||||
ara: 'ar', // Arabic
|
||||
aze: 'az', // Azerbaijani
|
||||
bel: 'be', // Belarusian
|
||||
ben: 'bn', // Bengali
|
||||
bul: 'bg', // Bulgarian
|
||||
cat: 'ca', // Catalan
|
||||
ces: 'cs', // Czech
|
||||
ckb: 'ku', // Kurdish
|
||||
cmn: 'zh', // Mandarin
|
||||
dan: 'da', // Danish
|
||||
deu: 'de', // German
|
||||
ell: 'el', // Greek
|
||||
eng: 'en', // English
|
||||
est: 'et', // Estonian
|
||||
eus: 'eu', // Basque
|
||||
fin: 'fi', // Finnish
|
||||
fra: 'fr', // French
|
||||
hau: 'ha', // Hausa
|
||||
heb: 'he', // Hebrew
|
||||
hin: 'hi', // Hindi
|
||||
hrv: 'hr', // Croatian
|
||||
hun: 'hu', // Hungarian
|
||||
hye: 'hy', // Armenian
|
||||
ind: 'id', // Indonesian
|
||||
isl: 'is', // Icelandic
|
||||
ita: 'it', // Italian
|
||||
jpn: 'ja', // Japanese
|
||||
kat: 'ka', // Georgian
|
||||
kaz: 'kk', // Kazakh
|
||||
kor: 'ko', // Korean
|
||||
lit: 'lt', // Lithuanian
|
||||
mar: 'mr', // Marathi
|
||||
mkd: 'mk', // Macedonian
|
||||
nld: 'nl', // Dutch
|
||||
nob: 'no', // Norwegian
|
||||
pes: 'fa', // Persian
|
||||
pol: 'pl', // Polish
|
||||
por: 'pt', // Portuguese
|
||||
ron: 'ro', // Romanian
|
||||
run: 'rn', // Rundi
|
||||
rus: 'ru', // Russian
|
||||
slk: 'sk', // Slovak
|
||||
spa: 'es', // Spanish
|
||||
srp: 'sr', // Serbian
|
||||
swe: 'sv', // Swedish
|
||||
tgl: 'tl', // Tagalog
|
||||
tur: 'tr', // Turkish
|
||||
ukr: 'uk', // Ukrainian
|
||||
vie: 'vi', // Vietnamese
|
||||
};
|
||||
|
||||
const debouncedLande = debounce((text) => lande(text), 500, { trailing: true });
|
||||
|
||||
const detectedLanguage = createSelector([
|
||||
state => state.getIn(['compose', 'text']),
|
||||
], text => {
|
||||
if (text.length > 20) {
|
||||
const guesses = debouncedLande(text);
|
||||
if (!guesses)
|
||||
return '';
|
||||
|
||||
const [lang, confidence] = guesses[0];
|
||||
|
||||
if (confidence > 0.8) {
|
||||
return ISO_639_MAP[lang];
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
frequentlyUsedLanguages: getFrequentlyUsedLanguages(state),
|
||||
value: state.getIn(['compose', 'language']),
|
||||
guess: detectedLanguage(state),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
|
|
@ -69,7 +69,9 @@ export const NotificationFollow: React.FC<{
|
|||
const account = notification.sampleAccountIds[0];
|
||||
|
||||
if (account) {
|
||||
actions = <FollowButton accountId={notification.sampleAccountIds[0]} />;
|
||||
actions = (
|
||||
<FollowButton compact accountId={notification.sampleAccountIds[0]} />
|
||||
);
|
||||
additionalContent = <FollowerCount accountId={account} />;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,7 +28,6 @@ export const BoostModal: React.FC<{
|
|||
const intl = useIntl();
|
||||
|
||||
const defaultPrivacy = useAppSelector(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
(state) => state.compose.get('default_privacy') as StatusVisibility,
|
||||
);
|
||||
|
||||
|
|
|
@ -1,438 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
import { length } from 'stringz';
|
||||
// eslint-disable-next-line import/extensions
|
||||
import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { GIFV } from 'mastodon/components/gifv';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
|
||||
import { UploadProgress } from 'mastodon/features/compose/components/upload_progress';
|
||||
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { assetHost } from 'mastodon/utils/config';
|
||||
|
||||
import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from '../../../actions/compose';
|
||||
import Video, { getPointerPosition } from '../../video';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
|
||||
applying: { id: 'upload_modal.applying', defaultMessage: 'Applying…' },
|
||||
placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
|
||||
chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' },
|
||||
discardMessage: { id: 'confirmations.discard_edit_media.message', defaultMessage: 'You have unsaved changes to the media description or preview, discard them anyway?' },
|
||||
discardConfirm: { id: 'confirmations.discard_edit_media.confirm', defaultMessage: 'Discard' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { id }) => ({
|
||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
||||
account: state.getIn(['accounts', me]),
|
||||
isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']),
|
||||
description: state.getIn(['compose', 'media_modal', 'description']),
|
||||
lang: state.getIn(['compose', 'language']),
|
||||
focusX: state.getIn(['compose', 'media_modal', 'focusX']),
|
||||
focusY: state.getIn(['compose', 'media_modal', 'focusY']),
|
||||
dirty: state.getIn(['compose', 'media_modal', 'dirty']),
|
||||
is_changing_upload: state.getIn(['compose', 'is_changing_upload']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { id }) => ({
|
||||
|
||||
onSave: (description, x, y) => {
|
||||
dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
|
||||
},
|
||||
|
||||
onChangeDescription: (description) => {
|
||||
dispatch(onChangeMediaDescription(description));
|
||||
},
|
||||
|
||||
onChangeFocus: (focusX, focusY) => {
|
||||
dispatch(onChangeMediaFocus(focusX, focusY));
|
||||
},
|
||||
|
||||
onSelectThumbnail: files => {
|
||||
dispatch(uploadThumbnail(id, files[0]));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
|
||||
.replace(/\n/g, ' ')
|
||||
.replace(/\*\*\*\*\*\*/g, '\n\n');
|
||||
|
||||
class ImageLoader extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
};
|
||||
|
||||
state = {
|
||||
loading: true,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const image = new Image();
|
||||
image.addEventListener('load', () => this.setState({ loading: false }));
|
||||
image.src = this.props.src;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { loading } = this.state;
|
||||
|
||||
if (loading) {
|
||||
return <canvas width={this.props.width} height={this.props.height} />;
|
||||
} else {
|
||||
return <img {...this.props} alt='' />;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class FocalPointModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
account: ImmutablePropTypes.record.isRequired,
|
||||
isUploadingThumbnail: PropTypes.bool,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onChangeDescription: PropTypes.func.isRequired,
|
||||
onChangeFocus: PropTypes.func.isRequired,
|
||||
onSelectThumbnail: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
dragging: false,
|
||||
dirty: false,
|
||||
progress: 0,
|
||||
loading: true,
|
||||
ocrStatus: '',
|
||||
};
|
||||
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('mousemove', this.handleMouseMove);
|
||||
document.removeEventListener('mouseup', this.handleMouseUp);
|
||||
}
|
||||
|
||||
handleMouseDown = e => {
|
||||
document.addEventListener('mousemove', this.handleMouseMove);
|
||||
document.addEventListener('mouseup', this.handleMouseUp);
|
||||
|
||||
this.updatePosition(e);
|
||||
this.setState({ dragging: true });
|
||||
};
|
||||
|
||||
handleTouchStart = e => {
|
||||
document.addEventListener('touchmove', this.handleMouseMove);
|
||||
document.addEventListener('touchend', this.handleTouchEnd);
|
||||
|
||||
this.updatePosition(e);
|
||||
this.setState({ dragging: true });
|
||||
};
|
||||
|
||||
handleMouseMove = e => {
|
||||
this.updatePosition(e);
|
||||
};
|
||||
|
||||
handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', this.handleMouseMove);
|
||||
document.removeEventListener('mouseup', this.handleMouseUp);
|
||||
|
||||
this.setState({ dragging: false });
|
||||
};
|
||||
|
||||
handleTouchEnd = () => {
|
||||
document.removeEventListener('touchmove', this.handleMouseMove);
|
||||
document.removeEventListener('touchend', this.handleTouchEnd);
|
||||
|
||||
this.setState({ dragging: false });
|
||||
};
|
||||
|
||||
updatePosition = e => {
|
||||
const { x, y } = getPointerPosition(this.node, e);
|
||||
const focusX = (x - .5) * 2;
|
||||
const focusY = (y - .5) * -2;
|
||||
|
||||
this.props.onChangeFocus(focusX, focusY);
|
||||
};
|
||||
|
||||
handleChange = e => {
|
||||
this.props.onChangeDescription(e.target.value);
|
||||
};
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||
this.props.onChangeDescription(e.target.value);
|
||||
this.handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onSave(this.props.description, this.props.focusX, this.props.focusY);
|
||||
};
|
||||
|
||||
getCloseConfirmationMessage = () => {
|
||||
const { intl, dirty } = this.props;
|
||||
|
||||
if (dirty) {
|
||||
return {
|
||||
message: intl.formatMessage(messages.discardMessage),
|
||||
confirm: intl.formatMessage(messages.discardConfirm),
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
};
|
||||
|
||||
handleTextDetection = () => {
|
||||
this._detectText();
|
||||
};
|
||||
|
||||
_detectText = (refreshCache = false) => {
|
||||
const { media } = this.props;
|
||||
|
||||
this.setState({ detecting: true });
|
||||
|
||||
fetchTesseract().then(({ createWorker }) => {
|
||||
const worker = createWorker({
|
||||
workerPath: tesseractWorkerPath,
|
||||
corePath: tesseractCorePath,
|
||||
langPath: `${assetHost}/ocr/lang-data`,
|
||||
logger: ({ status, progress }) => {
|
||||
if (status === 'recognizing text') {
|
||||
this.setState({ ocrStatus: 'detecting', progress });
|
||||
} else {
|
||||
this.setState({ ocrStatus: 'preparing', progress });
|
||||
}
|
||||
},
|
||||
cacheMethod: refreshCache ? 'refresh' : 'write',
|
||||
});
|
||||
|
||||
let media_url = media.get('url');
|
||||
|
||||
if (window.URL && URL.createObjectURL) {
|
||||
try {
|
||||
media_url = URL.createObjectURL(media.get('file'));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
return (async () => {
|
||||
await worker.load();
|
||||
await worker.loadLanguage('eng');
|
||||
await worker.initialize('eng');
|
||||
const { data: { text } } = await worker.recognize(media_url);
|
||||
this.setState({ detecting: false });
|
||||
this.props.onChangeDescription(removeExtraLineBreaks(text));
|
||||
await worker.terminate();
|
||||
})().catch((e) => {
|
||||
if (refreshCache) {
|
||||
throw e;
|
||||
} else {
|
||||
this._detectText(true);
|
||||
}
|
||||
});
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
this.setState({ detecting: false });
|
||||
});
|
||||
};
|
||||
|
||||
handleThumbnailChange = e => {
|
||||
if (e.target.files.length > 0) {
|
||||
this.props.onSelectThumbnail(e.target.files);
|
||||
}
|
||||
};
|
||||
|
||||
setFileInputRef = c => {
|
||||
this.fileInput = c;
|
||||
};
|
||||
|
||||
handleFileInputClick = () => {
|
||||
this.fileInput.click();
|
||||
};
|
||||
|
||||
render () {
|
||||
const { media, intl, account, onClose, isUploadingThumbnail, description, lang, focusX, focusY, dirty, is_changing_upload } = this.props;
|
||||
const { dragging, detecting, progress, ocrStatus } = this.state;
|
||||
const x = (focusX / 2) + .5;
|
||||
const y = (focusY / -2) + .5;
|
||||
|
||||
const width = media.getIn(['meta', 'original', 'width']) || null;
|
||||
const height = media.getIn(['meta', 'original', 'height']) || null;
|
||||
const focals = ['image', 'gifv'].includes(media.get('type'));
|
||||
const thumbnailable = ['audio', 'video'].includes(media.get('type'));
|
||||
|
||||
const previewRatio = 16/9;
|
||||
const previewWidth = 200;
|
||||
const previewHeight = previewWidth / previewRatio;
|
||||
|
||||
let descriptionLabel = null;
|
||||
|
||||
if (media.get('type') === 'audio') {
|
||||
descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people who are hard of hearing' />;
|
||||
} else if (media.get('type') === 'video') {
|
||||
descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people who are deaf, hard of hearing, blind or have low vision' />;
|
||||
} else {
|
||||
descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for people who are blind or have low vision' />;
|
||||
}
|
||||
|
||||
let ocrMessage = '';
|
||||
if (ocrStatus === 'detecting') {
|
||||
ocrMessage = <FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />;
|
||||
} else {
|
||||
ocrMessage = <FormattedMessage id='upload_modal.preparing_ocr' defaultMessage='Preparing OCR…' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
|
||||
<div className='report-modal__target'>
|
||||
<IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={20} />
|
||||
<FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
|
||||
</div>
|
||||
|
||||
<div className='report-modal__container'>
|
||||
<form className='report-modal__comment' onSubmit={this.handleSubmit} >
|
||||
{focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>}
|
||||
|
||||
{thumbnailable && (
|
||||
<>
|
||||
<label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
|
||||
|
||||
<Button disabled={isUploadingThumbnail || !media.get('unattached')} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
|
||||
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
|
||||
|
||||
<input
|
||||
id='upload-modal__thumbnail'
|
||||
ref={this.setFileInputRef}
|
||||
type='file'
|
||||
accept='image/png,image/jpeg'
|
||||
onChange={this.handleThumbnailChange}
|
||||
style={{ display: 'none' }}
|
||||
disabled={isUploadingThumbnail || is_changing_upload}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<hr className='setting-divider' />
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className='setting-text-label' htmlFor='upload-modal__description'>
|
||||
{descriptionLabel}
|
||||
</label>
|
||||
|
||||
<div className='setting-text__wrapper'>
|
||||
<Textarea
|
||||
id='upload-modal__description'
|
||||
className='setting-text light'
|
||||
value={detecting ? '…' : description}
|
||||
lang={lang}
|
||||
onChange={this.handleChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
disabled={detecting || is_changing_upload}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className='setting-text__modifiers'>
|
||||
<UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={ocrMessage} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='setting-text__toolbar'>
|
||||
<button
|
||||
type='button'
|
||||
disabled={detecting || media.get('type') !== 'image' || is_changing_upload}
|
||||
className='link-button'
|
||||
onClick={this.handleTextDetection}
|
||||
>
|
||||
<FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' />
|
||||
</button>
|
||||
<CharacterCounter max={1500} text={detecting ? '' : description} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500 || is_changing_upload}
|
||||
text={intl.formatMessage(is_changing_upload ? messages.applying : messages.apply)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className='focal-point-modal__content'>
|
||||
{focals && (
|
||||
<div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}>
|
||||
{media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />}
|
||||
{media.get('type') === 'gifv' && <GIFV src={media.get('url')} key={media.get('url')} width={width} height={height} />}
|
||||
|
||||
<div className='focal-point__preview'>
|
||||
<strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong>
|
||||
<div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} />
|
||||
</div>
|
||||
|
||||
<div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
|
||||
<div className='focal-point__overlay' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{media.get('type') === 'video' && (
|
||||
<Video
|
||||
preview={media.get('preview_url')}
|
||||
frameRate={media.getIn(['meta', 'original', 'frame_rate'])}
|
||||
blurhash={media.get('blurhash')}
|
||||
src={media.get('url')}
|
||||
detailed
|
||||
inline
|
||||
editable
|
||||
/>
|
||||
)}
|
||||
|
||||
{media.get('type') === 'audio' && (
|
||||
<Audio
|
||||
src={media.get('url')}
|
||||
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
||||
height={150}
|
||||
poster={media.get('preview_url') || account.get('avatar_static')}
|
||||
backgroundColor={media.getIn(['meta', 'colors', 'background'])}
|
||||
foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
|
||||
accentColor={media.getIn(['meta', 'colors', 'accent'])}
|
||||
editable
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps, null, {
|
||||
forwardRef: true,
|
||||
})(injectIntl(FocalPointModal, { forwardRef: true }));
|
|
@ -4,6 +4,7 @@ import { PureComponent } from 'react';
|
|||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import Base from 'mastodon/components/modal_root';
|
||||
import { AltTextModal } from 'mastodon/features/alt_text_modal';
|
||||
import {
|
||||
MuteModal,
|
||||
BlockModal,
|
||||
|
@ -43,7 +44,6 @@ import {
|
|||
ConfirmLogOutModal,
|
||||
ConfirmFollowToListModal,
|
||||
} from './confirmation_modals';
|
||||
import FocalPointModal from './focal_point_modal';
|
||||
import ImageModal from './image_modal';
|
||||
import MediaModal from './media_modal';
|
||||
import { ModalPlaceholder } from './modal_placeholder';
|
||||
|
@ -73,7 +73,7 @@ export const MODAL_COMPONENTS = {
|
|||
'REPORT': ReportModal,
|
||||
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
|
||||
'EMBED': EmbedModal,
|
||||
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
|
||||
'FOCAL_POINT': () => Promise.resolve({ default: AltTextModal }),
|
||||
'LIST_ADDER': ListAdder,
|
||||
'ANTENNA_ADDER': AntennaAdder,
|
||||
'CIRCLE_ADDER': CircleAdder,
|
||||
|
@ -151,8 +151,7 @@ export default class ModalRoot extends PureComponent {
|
|||
<>
|
||||
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}>
|
||||
{(SpecificComponent) => {
|
||||
const ref = typeof SpecificComponent !== 'function' ? this.setModalRef : undefined;
|
||||
return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={ref} />;
|
||||
return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />;
|
||||
}}
|
||||
</BundleContainer>
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ const mapDispatchToProps = dispatch => ({
|
|||
if (confirmationMessage) {
|
||||
dispatch(
|
||||
openModal({
|
||||
previousModalProps: confirmationMessage.props,
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
message: confirmationMessage.message,
|
||||
|
@ -24,7 +25,8 @@ const mapDispatchToProps = dispatch => ({
|
|||
modalType: undefined,
|
||||
ignoreFocus: { ignoreFocus },
|
||||
})),
|
||||
} }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(closeModal({
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue