Merge commit '3dcf5e12b1ffb8e57a7c1e1f3f1145c9bae477e8' into glitch-soc/merge-upstream

Conflicts:
- `app/javascript/styles/mastodon/components.scss`:
  Conflict because one of the removed lines was modified to accomodate for
  glitch-soc's slightly different build system.
  Removed the lines as upstream did.
This commit is contained in:
Claire 2025-01-21 19:23:25 +01:00
commit 05e5db91c7
44 changed files with 939 additions and 908 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -416,7 +416,7 @@ export function initMediaEditModal(id) {
dispatch(openModal({
modalType: 'FOCAL_POINT',
modalProps: { id },
modalProps: { mediaId: id },
}));
};
}

View File

@ -0,0 +1,70 @@
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { apiUpdateMedia } from 'mastodon/api/compose';
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
unattached?: boolean;
};
const simulateModifiedApiResponse = (
media: MediaAttachment,
params: { description?: string; focus?: string },
): SimulatedMediaAttachmentJSON => {
const [x, y] = (params.focus ?? '').split(',');
const data = {
...media.toJS(),
...params,
meta: {
focus: {
x: parseFloat(x ?? '0'),
y: parseFloat(y ?? '0'),
},
},
} as unknown as SimulatedMediaAttachmentJSON;
return data;
};
export const changeUploadCompose = createDataLoadingThunk(
'compose/changeUpload',
async (
{
id,
...params
}: {
id: string;
description: string;
focus: string;
},
{ getState },
) => {
const media = (
(getState().compose as ImmutableMap<string, unknown>).get(
'media_attachments',
) as ImmutableList<MediaAttachment>
).find((item) => item.get('id') === id);
// Editing already-attached media is deferred to editing the post itself.
// For simplicity's sake, fake an API reply.
if (media && !media.get('unattached')) {
return new Promise<SimulatedMediaAttachmentJSON>((resolve) => {
resolve(simulateModifiedApiResponse(media, params));
});
}
return apiUpdateMedia(id, params);
},
(media: SimulatedMediaAttachmentJSON) => {
return {
media,
attached: typeof media.unattached !== 'undefined' && !media.unattached,
};
},
{
useLoadingBar: false,
},
);

View File

@ -9,6 +9,7 @@ export type ModalType = keyof typeof MODAL_COMPONENTS;
interface OpenModalPayload {
modalType: ModalType;
modalProps: ModalProps;
previousModalProps?: ModalProps;
}
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');

View File

@ -0,0 +1,7 @@
import { apiRequestPut } from 'mastodon/api';
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
export const apiUpdateMedia = (
id: string,
params?: { description?: string; focus?: string },
) => apiRequestPut<ApiMediaAttachmentJSON>(`v1/media/${id}`, params);

View File

@ -7,6 +7,7 @@ interface BaseProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
block?: boolean;
secondary?: boolean;
compact?: boolean;
dangerous?: boolean;
}
@ -27,6 +28,7 @@ export const Button: React.FC<Props> = ({
disabled,
block,
secondary,
compact,
dangerous,
className,
title,
@ -47,6 +49,7 @@ export const Button: React.FC<Props> = ({
<button
className={classNames('button', className, {
'button-secondary': secondary,
'button--compact': compact,
'button--block': block,
'button--dangerous': dangerous,
})}

View File

@ -2,6 +2,8 @@ import { useCallback, useEffect } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { useIdentity } from '@/mastodon/identity_context';
import { fetchRelationships, followAccount } from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
@ -20,7 +22,8 @@ const messages = defineMessages({
export const FollowButton: React.FC<{
accountId?: string;
}> = ({ accountId }) => {
compact?: boolean;
}> = ({ accountId, compact }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { signedIn } = useIdentity();
@ -89,7 +92,9 @@ export const FollowButton: React.FC<{
href='/settings/profile'
target='_blank'
rel='noopener'
className='button button-secondary'
className={classNames('button button-secondary', {
'button--compact': compact,
})}
>
{label}
</a>
@ -106,6 +111,7 @@ export const FollowButton: React.FC<{
(account?.suspended || !!account?.moved))
}
secondary={following}
compact={compact}
className={following ? 'button--destructive' : undefined}
>
{label}

View File

@ -1,70 +1,70 @@
import { useCallback, useState } from 'react';
import { useCallback, useState, forwardRef } from 'react';
interface Props {
src: string;
key: string;
alt?: string;
lang?: string;
width: number;
height: number;
onClick?: () => void;
width?: number;
height?: number;
onClick?: React.MouseEventHandler;
onMouseDown?: React.MouseEventHandler;
onTouchStart?: React.TouchEventHandler;
}
export const GIFV: React.FC<Props> = ({
src,
alt,
lang,
width,
height,
onClick,
}) => {
const [loading, setLoading] = useState(true);
export const GIFV = forwardRef<HTMLVideoElement, Props>(
(
{ src, alt, lang, width, height, onClick, onMouseDown, onTouchStart },
ref,
) => {
const [loading, setLoading] = useState(true);
const handleLoadedData: React.ReactEventHandler<HTMLVideoElement> =
useCallback(() => {
const handleLoadedData = useCallback(() => {
setLoading(false);
}, [setLoading]);
const handleClick: React.MouseEventHandler = useCallback(
(e) => {
if (onClick) {
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onClick();
}
},
[onClick],
);
onClick?.(e);
},
[onClick],
);
return (
<div className='gifv' style={{ position: 'relative' }}>
{loading && (
<canvas
width={width}
height={height}
return (
<div className='gifv'>
{loading && (
<canvas
role='button'
tabIndex={0}
aria-label={alt}
title={alt}
lang={lang}
onClick={handleClick}
/>
)}
<video
ref={ref}
src={src}
role='button'
tabIndex={0}
aria-label={alt}
title={alt}
lang={lang}
width={width}
height={height}
muted
loop
autoPlay
playsInline
onClick={handleClick}
onLoadedData={handleLoadedData}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
/>
)}
</div>
);
},
);
<video
src={src}
role='button'
tabIndex={0}
aria-label={alt}
title={alt}
lang={lang}
muted
loop
autoPlay
playsInline
onClick={handleClick}
onLoadedData={handleLoadedData}
style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }}
/>
</div>
);
};
GIFV.displayName = 'GIFV';

View 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';

View File

@ -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>

View File

@ -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,
};

View File

@ -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>;
};

View File

@ -301,6 +301,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()}
/>

View File

@ -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 } =

View File

@ -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(

View File

@ -65,7 +65,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} />;
}
}

View File

@ -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,
);

View File

@ -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 }));

View File

@ -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,
@ -37,7 +38,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';
@ -64,7 +64,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,
'COMPARE_HISTORY': CompareHistoryModal,
'FILTER': FilterModal,
@ -139,8 +139,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>

View File

@ -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({

View File

@ -86,6 +86,13 @@
"alert.unexpected.message": "An unexpected error occurred.",
"alert.unexpected.title": "Oops!",
"alt_text_badge.title": "Alt text",
"alt_text_modal.add_alt_text": "Add alt text",
"alt_text_modal.add_text_from_image": "Add text from image",
"alt_text_modal.cancel": "Cancel",
"alt_text_modal.change_thumbnail": "Change thumbnail",
"alt_text_modal.describe_for_people_with_hearing_impairments": "Describe this for people with hearing impairments…",
"alt_text_modal.describe_for_people_with_visual_impairments": "Describe this for people with visual impairments…",
"alt_text_modal.done": "Done",
"announcement.announcement": "Announcement",
"annual_report.summary.archetype.booster": "The cool-hunter",
"annual_report.summary.archetype.lurker": "The lurker",
@ -875,26 +882,12 @@
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people who are deaf or hard of hearing",
"upload_form.description": "Describe for people who are blind or have low vision",
"upload_form.drag_and_drop.instructions": "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.",
"upload_form.drag_and_drop.on_drag_cancel": "Dragging was cancelled. Media attachment {item} was dropped.",
"upload_form.drag_and_drop.on_drag_end": "Media attachment {item} was dropped.",
"upload_form.drag_and_drop.on_drag_over": "Media attachment {item} was moved.",
"upload_form.drag_and_drop.on_drag_start": "Picked up media attachment {item}.",
"upload_form.edit": "Edit",
"upload_form.thumbnail": "Change thumbnail",
"upload_form.video_description": "Describe for people who are deaf, hard of hearing, blind or have low vision",
"upload_modal.analyzing_picture": "Analyzing picture…",
"upload_modal.apply": "Apply",
"upload_modal.applying": "Applying…",
"upload_modal.choose_image": "Choose image",
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
"upload_modal.detect_text": "Detect text from picture",
"upload_modal.edit_media": "Edit media",
"upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.",
"upload_modal.preparing_ocr": "Preparing OCR…",
"upload_modal.preview_label": "Preview ({ratio})",
"upload_progress.label": "Uploading...",
"upload_progress.processing": "Processing…",
"username.taken": "That username is taken. Try another",

View File

@ -1,5 +1,6 @@
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import { changeUploadCompose } from 'mastodon/actions/compose_typed';
import { timelineDelete } from 'mastodon/actions/timelines_typed';
import {
@ -36,17 +37,11 @@ import {
COMPOSE_LANGUAGE_CHANGE,
COMPOSE_COMPOSING_CHANGE,
COMPOSE_EMOJI_INSERT,
COMPOSE_UPLOAD_CHANGE_REQUEST,
COMPOSE_UPLOAD_CHANGE_SUCCESS,
COMPOSE_UPLOAD_CHANGE_FAIL,
COMPOSE_RESET,
COMPOSE_POLL_ADD,
COMPOSE_POLL_REMOVE,
COMPOSE_POLL_OPTION_CHANGE,
COMPOSE_POLL_SETTINGS_CHANGE,
INIT_MEDIA_EDIT_MODAL,
COMPOSE_CHANGE_MEDIA_DESCRIPTION,
COMPOSE_CHANGE_MEDIA_FOCUS,
COMPOSE_CHANGE_MEDIA_ORDER,
COMPOSE_SET_STATUS,
COMPOSE_FOCUS,
@ -87,13 +82,6 @@ const initialState = ImmutableMap({
resetFileKey: Math.floor((Math.random() * 0x10000)),
idempotencyKey: null,
tagHistory: ImmutableList(),
media_modal: ImmutableMap({
id: null,
description: '',
focusX: 0,
focusY: 0,
dirty: false,
}),
});
const initialPoll = ImmutableMap({
@ -294,7 +282,24 @@ const updatePoll = (state, index, value, maxOptions) => state.updateIn(['poll',
return tmp;
});
export default function compose(state = initialState, action) {
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
export const composeReducer = (state = initialState, action) => {
if (changeUploadCompose.fulfilled.match(action)) {
return state
.set('is_changing_upload', false)
.update('media_attachments', list => list.map(item => {
if (item.get('id') === action.payload.media.id) {
return fromJS(action.payload.media).set('unattached', !action.payload.attached);
}
return item;
}));
} else if (changeUploadCompose.pending.match(action)) {
return state.set('is_changing_upload', true);
} else if (changeUploadCompose.rejected.match(action)) {
return state.set('is_changing_upload', false);
}
switch(action.type) {
case STORE_HYDRATE:
return hydrate(state, action.state.get('compose'));
@ -369,16 +374,13 @@ export default function compose(state = initialState, action) {
});
case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true);
case COMPOSE_UPLOAD_CHANGE_REQUEST:
return state.set('is_changing_upload', true);
case COMPOSE_REPLY_CANCEL:
case COMPOSE_RESET:
case COMPOSE_SUBMIT_SUCCESS:
return clearAll(state);
case COMPOSE_SUBMIT_FAIL:
return state.set('is_submitting', false);
case COMPOSE_UPLOAD_CHANGE_FAIL:
return state.set('is_changing_upload', false);
case COMPOSE_UPLOAD_REQUEST:
return state.set('is_uploading', true).update('pending_media_attachments', n => n + 1);
case COMPOSE_UPLOAD_PROCESSING:
@ -407,20 +409,6 @@ export default function compose(state = initialState, action) {
return item;
}));
case INIT_MEDIA_EDIT_MODAL: {
const media = state.get('media_attachments').find(item => item.get('id') === action.id);
return state.set('media_modal', ImmutableMap({
id: action.id,
description: media.get('description') || '',
focusX: media.getIn(['meta', 'focus', 'x'], 0),
focusY: media.getIn(['meta', 'focus', 'y'], 0),
dirty: false,
}));
}
case COMPOSE_CHANGE_MEDIA_DESCRIPTION:
return state.setIn(['media_modal', 'description'], action.description).setIn(['media_modal', 'dirty'], true);
case COMPOSE_CHANGE_MEDIA_FOCUS:
return state.setIn(['media_modal', 'focusX'], action.focusX).setIn(['media_modal', 'focusY'], action.focusY).setIn(['media_modal', 'dirty'], true);
case COMPOSE_MENTION:
return state.withMutations(map => {
map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
@ -458,17 +446,6 @@ export default function compose(state = initialState, action) {
}
case COMPOSE_EMOJI_INSERT:
return insertEmoji(state, action.position, action.emoji, action.needsSpace);
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
return state
.set('is_changing_upload', false)
.setIn(['media_modal', 'dirty'], false)
.update('media_attachments', list => list.map(item => {
if (item.get('id') === action.media.id) {
return fromJS(action.media).set('unattached', !action.attached);
}
return item;
}));
case REDRAFT:
return state.withMutations(map => {
map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
@ -550,4 +527,4 @@ export default function compose(state = initialState, action) {
default:
return state;
}
}
};

View File

@ -7,7 +7,7 @@ import { accountsReducer } from './accounts';
import accounts_map from './accounts_map';
import alerts from './alerts';
import announcements from './announcements';
import compose from './compose';
import { composeReducer } from './compose';
import contexts from './contexts';
import conversations from './conversations';
import custom_emojis from './custom_emojis';
@ -59,7 +59,7 @@ const reducers = {
push_notifications,
server,
contexts,
compose,
compose: composeReducer,
search: searchReducer,
media_attachments,
notifications,

View File

@ -3,7 +3,6 @@ import { Record as ImmutableRecord, Stack } from 'immutable';
import { timelineDelete } from 'mastodon/actions/timelines_typed';
import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose';
import type { ModalType } from '../actions/modal';
import { openModal, closeModal } from '../actions/modal';
@ -53,12 +52,36 @@ const pushModal = (
state: State,
modalType: ModalType,
modalProps: ModalProps,
previousModalProps?: ModalProps,
): State => {
return state.withMutations((record) => {
record.set('ignoreFocus', false);
record.update('stack', (stack) =>
stack.unshift(Modal({ modalType, modalProps })),
);
record.update('stack', (stack) => {
let tmp = stack;
// With this option, we update the previously opened modal, so that when the
// current (new) modal is closed, the previous modal is re-opened with different
// props. Specifically, this is useful for the confirmation modal.
if (previousModalProps) {
const previousModal = tmp.first() as Modal | undefined;
if (previousModal) {
tmp = tmp.shift().unshift(
Modal({
modalType: previousModal.modalType,
modalProps: {
...previousModal.modalProps,
...previousModalProps,
},
}),
);
}
}
tmp = tmp.unshift(Modal({ modalType, modalProps }));
return tmp;
});
});
};
@ -68,11 +91,10 @@ export const modalReducer: Reducer<State> = (state = initialState, action) => {
state,
action.payload.modalType,
action.payload.modalProps,
action.payload.previousModalProps,
);
else if (closeModal.match(action)) return popModal(state, action.payload);
// TODO: type those actions
else if (action.type === COMPOSE_UPLOAD_CHANGE_SUCCESS)
return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false });
else if (timelineDelete.match(action))
return state.update('stack', (stack) =>
stack.filterNot(

View File

@ -127,9 +127,7 @@
.actions-modal ul li:not(:empty) a:focus button,
.actions-modal ul li:not(:empty) a:hover,
.actions-modal ul li:not(:empty) a:hover button,
.simple_form .block-button,
.simple_form .button,
.simple_form button {
.simple_form button:not(.button, .link-button) {
color: $white;
}
@ -142,6 +140,11 @@
border-top-color: lighten($ui-base-color, 4%);
}
.dialog-modal__content__preview {
background: #fff;
border-bottom: 1px solid var(--modal-border-color);
}
.reactions-bar__item:hover,
.reactions-bar__item:focus,
.reactions-bar__item:active {

View File

@ -85,6 +85,14 @@
outline: $ui-button-icon-focus-outline;
}
&--compact {
font-size: 14px;
line-height: normal;
font-weight: 700;
padding: 5px 12px;
border-radius: 4px;
}
&--dangerous {
background-color: var(--error-background-color);
color: var(--on-error-color);
@ -3734,58 +3742,6 @@ $ui-header-logo-wordmark-width: 99px;
}
}
.setting-text {
display: block;
box-sizing: border-box;
margin: 0;
color: $primary-text-color;
background: $ui-base-color;
padding: 7px 10px;
font-family: inherit;
font-size: 14px;
line-height: 22px;
border-radius: 4px;
border: 1px solid var(--background-border-color);
&:focus {
outline: 0;
}
&__wrapper {
background: $ui-base-color;
border: 1px solid var(--background-border-color);
margin-bottom: 10px;
border-radius: 4px;
.setting-text {
border: 0;
margin-bottom: 0;
border-radius: 0;
&:focus {
border: 0;
}
}
&__modifiers {
color: $inverted-text-color;
font-family: inherit;
font-size: 14px;
background: $white;
}
}
&__toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
@media screen and (width <= 600px) {
font-size: 16px;
}
}
.status-card {
display: flex;
align-items: center;
@ -6104,6 +6060,35 @@ a.status-card {
gap: 16px;
padding: 24px;
}
&__preview {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
justify-content: center;
padding: 24px;
background: #000;
img {
display: block;
}
img,
.gifv video {
outline: 1px solid var(--media-outline-color);
outline-offset: -1px;
border-radius: 8px;
}
img,
.gifv video,
.video-player,
.audio-player {
max-width: 360px;
max-height: 45vh;
}
}
}
.copy-paste-text {
@ -6450,62 +6435,6 @@ a.status-card {
margin-bottom: 29px;
}
.report-modal__comment {
padding: 20px;
border-inline-end: 1px solid var(--background-border-color);
max-width: 320px;
p {
font-size: 14px;
line-height: 20px;
margin-bottom: 20px;
}
.setting-text-label {
display: block;
color: $secondary-text-color;
font-size: 14px;
font-weight: 500;
margin-bottom: 10px;
}
.setting-text {
width: 100%;
resize: none;
min-height: 100px;
max-height: 50vh;
border: 0;
@media screen and (height <= 600px) {
max-height: 20vh;
}
@media screen and (max-width: $no-columns-breakpoint) {
max-height: 20vh;
}
}
.setting-toggle {
margin-top: 20px;
margin-bottom: 24px;
&__label {
color: $inverted-text-color;
font-size: 14px;
}
}
@media screen and (width <= 480px) {
padding: 10px;
max-width: 100%;
order: 2;
.setting-toggle {
margin-bottom: 4px;
}
}
}
.actions-modal {
max-height: 80vh;
max-width: 80vw;
@ -6998,11 +6927,6 @@ a.status-card {
outline: 1px solid var(--media-outline-color);
outline-offset: -1px;
&.editable {
border-radius: 0;
height: 100%;
}
&.inactive {
audio,
.video-player__controls {
@ -7071,11 +6995,6 @@ a.status-card {
outline-offset: -1px;
z-index: 2;
&.editable {
border-radius: 0;
height: 100% !important;
}
video {
display: block;
z-index: -2;
@ -7381,6 +7300,14 @@ a.status-card {
}
.gifv {
position: relative;
canvas {
position: absolute;
width: 100%;
height: 100%;
}
video {
max-width: 100vw;
max-height: 80vh;
@ -7686,24 +7613,14 @@ noscript {
.focal-point {
position: relative;
cursor: move;
cursor: grab;
overflow: hidden;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: $base-shadow-color;
img,
video,
canvas {
display: block;
max-height: 80vh;
width: 100%;
height: auto;
margin: 0;
object-fit: contain;
background: $base-shadow-color;
&.dragging {
cursor: grabbing;
}
&__reticle {
@ -7711,54 +7628,10 @@ noscript {
width: 100px;
height: 100px;
transform: translate(-50%, -50%);
background: url('~images/reticle.png') no-repeat 0 0;
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
}
&__overlay {
position: absolute;
width: 100%;
height: 100%;
top: 0;
inset-inline-start: 0;
}
&__preview {
position: absolute;
bottom: 10px;
inset-inline-end: 10px;
z-index: 2;
cursor: move;
transition: opacity 0.1s ease;
&:hover {
opacity: 0.5;
}
strong {
color: $primary-text-color;
font-size: 14px;
font-weight: 500;
display: block;
margin-bottom: 5px;
}
div {
border-radius: 4px;
box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);
}
}
@media screen and (width <= 480px) {
img,
video {
max-height: 100%;
}
&__preview {
display: none;
}
pointer-events: none;
}
}
@ -10468,12 +10341,7 @@ noscript {
.compose-form__actions {
.button {
display: block; // Otherwise text-ellipsis doesn't work
font-size: 14px;
line-height: normal;
font-weight: 700;
flex: 1 1 auto;
padding: 5px 12px;
border-radius: 4px;
}
}

View File

@ -76,6 +76,18 @@ code {
margin-bottom: 16px;
overflow: hidden;
&:last-child {
margin-bottom: 0;
}
&__toolbar {
margin-top: 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
&.hidden {
margin: 0;
}
@ -540,6 +552,7 @@ code {
.actions {
margin-top: 30px;
display: flex;
gap: 10px;
&.actions--top {
margin-top: 0;
@ -552,9 +565,7 @@ code {
margin-bottom: 15px;
}
button,
.button,
.block-button {
button:not(.button, .link-button) {
display: block;
width: 100%;
border: 0;
@ -629,6 +640,18 @@ code {
}
.label_input {
position: relative;
&__loading-indicator {
box-sizing: border-box;
position: absolute;
top: 0;
inset-inline-start: 0;
border: 1px solid transparent;
padding: 10px 16px;
width: 100%;
}
&__wrapper {
position: relative;
}

3
app/javascript/types/tesseract.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare module 'tesseract.js-core/tesseract-core.wasm.js';
declare module 'tesseract.js/dist/worker.min.js';

View File

@ -34,6 +34,7 @@ class NotifyService < BaseService
@sender = notification.from_account
@notification = notification
@policy = NotificationPolicy.find_or_initialize_by(account: @recipient)
@from_staff = @sender.local? && @sender.user.present? && @sender.user_role&.bypass_block?(@recipient.user_role)
end
private
@ -63,6 +64,14 @@ class NotifyService < BaseService
@sender.silenced? && not_following?
end
def message?
@notification.type == :mention
end
def from_staff?
@from_staff
end
def private_mention_not_in_response?
@notification.type == :mention && @notification.target_status.direct_visibility? && !response_to_recipient?
end
@ -129,14 +138,6 @@ class NotifyService < BaseService
FeedManager.instance.filter?(:mentions, @notification.target_status, @recipient)
end
def message?
@notification.type == :mention
end
def from_staff?
@sender.local? && @sender.user.present? && @sender.user_role&.bypass_block?(@recipient.user_role)
end
def from_self?
@recipient.id == @sender.id
end
@ -174,6 +175,7 @@ class NotifyService < BaseService
def filter?
return false unless filterable_type?
return false if override_for_sender?
return false if message? && from_staff?
filtered_by_limited_accounts_policy? ||
filtered_by_not_following_policy? ||

View File

@ -39,7 +39,7 @@
.actions
%button.button= t('admin.accounts.search')
= link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button negative'
= link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button button--dangerous'
%hr.spacer/

View File

@ -35,7 +35,7 @@
.actions
%button.button= t('admin.accounts.search')
= link_to t('admin.accounts.reset'), admin_custom_emojis_path, class: 'button negative'
= link_to t('admin.accounts.reset'), admin_custom_emojis_path, class: 'button button--dangerous'
= form_with model: @form, url: batch_admin_custom_emojis_path do |f|
= hidden_field_tag :page, params[:page] || 1

View File

@ -19,4 +19,4 @@
.actions
= link_to t('.cancel'), admin_instances_path, class: 'button button-tertiary'
= f.button :submit, t('.confirm'), class: 'button negative', name: :confirm
= f.button :submit, t('.confirm'), class: 'button button--dangerous', name: :confirm

View File

@ -42,7 +42,7 @@
.actions
%button.button= t('admin.accounts.search')
= link_to t('admin.accounts.reset'), admin_instances_path, class: 'button negative'
= link_to t('admin.accounts.reset'), admin_instances_path, class: 'button button--dangerous'
%hr.spacer/

View File

@ -3,7 +3,7 @@
.simple_form
%p.hint= t('admin.relays.description_html')
= link_to @relays.empty? ? t('admin.relays.setup') : t('admin.relays.add_new'), new_admin_relay_path, class: 'block-button'
= link_to @relays.empty? ? t('admin.relays.setup') : t('admin.relays.add_new'), new_admin_relay_path, class: 'button button--block'
- unless @relays.empty?
%hr.spacer

View File

@ -28,7 +28,7 @@
.actions
%button.button= t('admin.accounts.search')
= link_to t('admin.accounts.reset'), admin_reports_path, class: 'button negative'
= link_to t('admin.accounts.reset'), admin_reports_path, class: 'button button--dangerous'
- @reports.group_by(&:target_account_id).each_value do |reports|
- target_account = reports.first.target_account

View File

@ -25,7 +25,7 @@
.actions
%button.button= t('admin.tags.search')
= link_to t('admin.tags.reset'), admin_tags_path, class: 'button negative'
= link_to t('admin.tags.reset'), admin_tags_path, class: 'button button--dangerous'
%hr.spacer/

View File

@ -6,4 +6,4 @@
%hr.spacer/
= link_to t('otp_authentication.setup'), settings_otp_authentication_path, data: { method: :post }, class: 'block-button'
= link_to t('otp_authentication.setup'), settings_otp_authentication_path, data: { method: :post }, class: 'button button--block'

View File

@ -14,4 +14,4 @@
%hr.spacer/
.simple_form
= link_to t('webauthn_credentials.add'), new_settings_webauthn_credential_path, class: 'block-button'
= link_to t('webauthn_credentials.add'), new_settings_webauthn_credential_path, class: 'button button--block'

View File

@ -38,4 +38,4 @@
%hr.spacer/
.simple_form
= link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button'
= link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'button button--block'

View File

@ -1,9 +1,8 @@
module.exports = {
test: [
/tesseract\.js\/dist\/worker\.min\.js$/,
/tesseract\.js\/dist\/worker\.min\.js.map$/,
/tesseract\.js-core\/tesseract-core\.wasm$/,
/tesseract\.js-core\/tesseract-core\.wasm.js$/,
/tesseract\.js\/dist\/worker\.min\.js\.map$/,
/tesseract\.js-core\/tesseract-core\.wasm\.js$/,
],
use: {
loader: 'file-loader',

View File

@ -126,7 +126,7 @@
"stringz": "^2.1.0",
"substring-trie": "^1.0.2",
"terser-webpack-plugin": "^4.2.3",
"tesseract.js": "^2.1.5",
"tesseract.js": "^6.0.0",
"tiny-queue": "^0.2.1",
"twitter-text": "3.1.0",
"use-debounce": "^10.0.0",

View File

@ -319,6 +319,16 @@ RSpec.describe NotifyService do
end
end
context 'when sender is a moderator' do
let(:sender_role) { Fabricate(:user_role, highlighted: true, permissions: UserRole::FLAGS[:manage_users]) }
let(:sender) { Fabricate(:user, role: sender_role).account }
let(:activity) { Fabricate(:mention, status: Fabricate(:status, account: sender)) }
it 'returns false' do
expect(subject.filter?).to be false
end
end
context 'when sender is followed by recipient' do
before do
notification.account.follow!(notification.from_account)

View File

@ -26,8 +26,8 @@ RSpec.describe 'OCR', :attachment_processing, :inline_jobs, :js, :streaming do
end
end
click_on('Detect text from picture')
click_on('Add text from image')
expect(page).to have_css('#upload-modal__description', text: /Hello Mastodon\s*/, wait: 10)
expect(page).to have_css('#description', text: /Hello Mastodon\s*/, wait: 10)
end
end

121
yarn.lock
View File

@ -2961,7 +2961,7 @@ __metadata:
stylelint-config-standard-scss: "npm:^14.0.0"
substring-trie: "npm:^1.0.2"
terser-webpack-plugin: "npm:^4.2.3"
tesseract.js: "npm:^2.1.5"
tesseract.js: "npm:^6.0.0"
tiny-queue: "npm:^0.2.1"
twitter-text: "npm:3.1.0"
typescript: "npm:^5.0.4"
@ -5590,13 +5590,6 @@ __metadata:
languageName: node
linkType: hard
"blueimp-load-image@npm:^3.0.0":
version: 3.0.0
resolution: "blueimp-load-image@npm:3.0.0"
checksum: 10c0/e860da4113afd8e58bc026fb17240007e15dc155287a70fb57b3048fc8f0aa5f7dbd052efed8bff19d1208eeab4d058dc6788684a721c50ccd08b68d836a8d18
languageName: node
linkType: hard
"blurhash@npm:^2.0.5":
version: 2.0.5
resolution: "blurhash@npm:2.0.5"
@ -6334,13 +6327,6 @@ __metadata:
languageName: node
linkType: hard
"colors@npm:^1.4.0":
version: 1.4.0
resolution: "colors@npm:1.4.0"
checksum: 10c0/9af357c019da3c5a098a301cf64e3799d27549d8f185d86f79af23069e4f4303110d115da98483519331f6fb71c8568d5688fa1c6523600044fd4a54e97c4efb
languageName: node
linkType: hard
"combined-stream@npm:^1.0.8":
version: 1.0.8
resolution: "combined-stream@npm:1.0.8"
@ -8690,13 +8676,6 @@ __metadata:
languageName: node
linkType: hard
"file-type@npm:^12.4.1":
version: 12.4.2
resolution: "file-type@npm:12.4.2"
checksum: 10c0/26a307262a2a0b41ea83136550fbe83d8b502d080778b6577e0336fbfe9e919e1f871a286a6eb59f668425f60ebb19402fcb6c0443af58446d33c63362554e1d
languageName: node
linkType: hard
"file-uri-to-path@npm:1.0.0":
version: 1.0.0
resolution: "file-uri-to-path@npm:1.0.0"
@ -9728,10 +9707,10 @@ __metadata:
languageName: node
linkType: hard
"idb-keyval@npm:^3.2.0":
version: 3.2.0
resolution: "idb-keyval@npm:3.2.0"
checksum: 10c0/9b1f65d5f08630ef444a89334370c394175b1543f157621b36a3bc5e5208946f3f0ab5d5e24c74e81f2ef54b55b742b4e5b439c561f62695ffb69a06b0bce8e1
"idb-keyval@npm:^6.2.0":
version: 6.2.1
resolution: "idb-keyval@npm:6.2.1"
checksum: 10c0/9f0c83703a365e00bd0b4ed6380ce509a06dedfc6ec39b2ba5740085069fd2f2ff5c14ba19356488e3612a2f9c49985971982d836460a982a5d0b4019eeba48a
languageName: node
linkType: hard
@ -10189,13 +10168,6 @@ __metadata:
languageName: node
linkType: hard
"is-electron@npm:^2.2.0":
version: 2.2.2
resolution: "is-electron@npm:2.2.2"
checksum: 10c0/327bb373f7be01b16cdff3998b5ddaa87d28f576092affaa7fe0659571b3306fdd458afbf0683a66841e7999af13f46ad0e1b51647b469526cd05a4dd736438a
languageName: node
linkType: hard
"is-extendable@npm:^0.1.0, is-extendable@npm:^0.1.1":
version: 0.1.1
resolution: "is-extendable@npm:0.1.1"
@ -11169,28 +11141,6 @@ __metadata:
languageName: node
linkType: hard
"jpeg-autorotate@npm:^7.1.1":
version: 7.1.1
resolution: "jpeg-autorotate@npm:7.1.1"
dependencies:
colors: "npm:^1.4.0"
glob: "npm:^7.1.6"
jpeg-js: "npm:^0.4.2"
piexifjs: "npm:^1.0.6"
yargs-parser: "npm:^20.2.1"
bin:
jpeg-autorotate: src/cli.js
checksum: 10c0/75328e15b7abcaf8b36c980495cb0b37ffabeb8921e8576312deac8139a9e8a66f85d9196f314120e4633c76623d1b595e65ca7a87b679511ffb804f880a1644
languageName: node
linkType: hard
"jpeg-js@npm:^0.4.2":
version: 0.4.4
resolution: "jpeg-js@npm:0.4.4"
checksum: 10c0/4d0d5097f8e55d8bbce6f1dc32ffaf3f43f321f6222e4e6490734fdc6d005322e3bd6fb992c2df7f5b587343b1441a1c333281dc3285bc9116e369fd2a2b43a7
languageName: node
linkType: hard
"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0
resolution: "js-tokens@npm:4.0.0"
@ -12431,9 +12381,9 @@ __metadata:
languageName: node
linkType: hard
"node-fetch@npm:^2.6.0":
version: 2.6.11
resolution: "node-fetch@npm:2.6.11"
"node-fetch@npm:^2.6.9":
version: 2.7.0
resolution: "node-fetch@npm:2.7.0"
dependencies:
whatwg-url: "npm:^5.0.0"
peerDependencies:
@ -12441,7 +12391,7 @@ __metadata:
peerDependenciesMeta:
encoding:
optional: true
checksum: 10c0/3ec847ca43f678d07b80abfd85bdf06523c2554ee9a494c992c5fc61f5d9cde9f9f16aa33ff09a62f19eee9d54813b8850d7f054cdfee8b2daf789c57f8eeaea
checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8
languageName: node
linkType: hard
@ -12810,7 +12760,7 @@ __metadata:
languageName: node
linkType: hard
"opencollective-postinstall@npm:^2.0.2":
"opencollective-postinstall@npm:^2.0.3":
version: 2.0.3
resolution: "opencollective-postinstall@npm:2.0.3"
bin:
@ -13293,13 +13243,6 @@ __metadata:
languageName: node
linkType: hard
"piexifjs@npm:^1.0.6":
version: 1.0.6
resolution: "piexifjs@npm:1.0.6"
checksum: 10c0/69a10fe09c08f1e67e653844ac79e720324a7fa34689b020359d60d98b3a601c070e1759df8f2d97d022298bd2f5b79eed4c92de86c5f215300c8a63adf947b1
languageName: node
linkType: hard
"pify@npm:^2.0.0":
version: 2.3.0
resolution: "pify@npm:2.3.0"
@ -17124,31 +17067,27 @@ __metadata:
languageName: node
linkType: hard
"tesseract.js-core@npm:^2.2.0":
version: 2.2.0
resolution: "tesseract.js-core@npm:2.2.0"
checksum: 10c0/9ef569529f1ee96f8bf18388ef086d4940d3f02d28b4252df133a9bd36f6a9d140085e77a12ff1963cf9b4cd85bd1c644b61eca266ecfc51bb83adb30a1f11e3
"tesseract.js-core@npm:^6.0.0":
version: 6.0.0
resolution: "tesseract.js-core@npm:6.0.0"
checksum: 10c0/c04be8bbaa296be658664496754f21e857bdffff84113f08adf02f03a1f84596d68b3542ed2fda4a6dc138abb84b09b30ab07c04ee5950879e780876d343955f
languageName: node
linkType: hard
"tesseract.js@npm:^2.1.5":
version: 2.1.5
resolution: "tesseract.js@npm:2.1.5"
"tesseract.js@npm:^6.0.0":
version: 6.0.0
resolution: "tesseract.js@npm:6.0.0"
dependencies:
blueimp-load-image: "npm:^3.0.0"
bmp-js: "npm:^0.1.0"
file-type: "npm:^12.4.1"
idb-keyval: "npm:^3.2.0"
is-electron: "npm:^2.2.0"
idb-keyval: "npm:^6.2.0"
is-url: "npm:^1.2.4"
jpeg-autorotate: "npm:^7.1.1"
node-fetch: "npm:^2.6.0"
opencollective-postinstall: "npm:^2.0.2"
node-fetch: "npm:^2.6.9"
opencollective-postinstall: "npm:^2.0.3"
regenerator-runtime: "npm:^0.13.3"
resolve-url: "npm:^0.2.1"
tesseract.js-core: "npm:^2.2.0"
tesseract.js-core: "npm:^6.0.0"
wasm-feature-detect: "npm:^1.2.11"
zlibjs: "npm:^0.3.1"
checksum: 10c0/b3aaee9189f3bc7f4217b83e110d0dd4d9afcafc3045b842f72b7ca9beb00bec732bc6b4b00eca14167c16b014c437fcf83dd272a640c9c8b5e1e9b55ea00ff5
checksum: 10c0/f65b816eabc16266bfa74ea61db73afa2d21ce0f57041b87b96abdff8954e042ee16637edea20aaf752227bc075052ca12021f4f68d5d25d52f062ebc4c644e1
languageName: node
linkType: hard
@ -17994,6 +17933,13 @@ __metadata:
languageName: node
linkType: hard
"wasm-feature-detect@npm:^1.2.11":
version: 1.8.0
resolution: "wasm-feature-detect@npm:1.8.0"
checksum: 10c0/2cb43e91bbf7aa7c121bc76b3133de3ab6dc4f482acc1d2dc46c528e8adb7a51c72df5c2aacf1d219f113c04efd1706f18274d5790542aa5dd49e0644e3ee665
languageName: node
linkType: hard
"watchpack-chokidar2@npm:^2.0.1":
version: 2.0.1
resolution: "watchpack-chokidar2@npm:2.0.1"
@ -18846,13 +18792,6 @@ __metadata:
languageName: node
linkType: hard
"yargs-parser@npm:^20.2.1":
version: 20.2.9
resolution: "yargs-parser@npm:20.2.9"
checksum: 10c0/0685a8e58bbfb57fab6aefe03c6da904a59769bd803a722bb098bd5b0f29d274a1357762c7258fb487512811b8063fb5d2824a3415a0a4540598335b3b086c72
languageName: node
linkType: hard
"yargs-parser@npm:^21.1.1":
version: 21.1.1
resolution: "yargs-parser@npm:21.1.1"