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({ dispatch(openModal({
modalType: 'FOCAL_POINT', 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 { interface OpenModalPayload {
modalType: ModalType; modalType: ModalType;
modalProps: ModalProps; modalProps: ModalProps;
previousModalProps?: ModalProps;
} }
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN'); 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'> { extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
block?: boolean; block?: boolean;
secondary?: boolean; secondary?: boolean;
compact?: boolean;
dangerous?: boolean; dangerous?: boolean;
} }
@ -27,6 +28,7 @@ export const Button: React.FC<Props> = ({
disabled, disabled,
block, block,
secondary, secondary,
compact,
dangerous, dangerous,
className, className,
title, title,
@ -47,6 +49,7 @@ export const Button: React.FC<Props> = ({
<button <button
className={classNames('button', className, { className={classNames('button', className, {
'button-secondary': secondary, 'button-secondary': secondary,
'button--compact': compact,
'button--block': block, 'button--block': block,
'button--dangerous': dangerous, 'button--dangerous': dangerous,
})} })}

View File

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

View File

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

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>
<div className='video-player__buttons right'> <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>} {!editable && (
<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} /> <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> <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> </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'> <div className='compose-form__submit'>
<Button <Button
type='submit' type='submit'
compact
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))} text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
disabled={!this.canSubmit()} disabled={!this.canSubmit()}
/> />

View File

@ -4,16 +4,16 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import CloseIcon from '@/material-icons/400-20px/close.svg?react'; import CloseIcon from '@/material-icons/400-20px/close.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react'; import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react'; import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { import { undoUploadCompose } from 'mastodon/actions/compose';
undoUploadCompose, import { openModal } from 'mastodon/actions/modal';
initMediaEditModal,
} from 'mastodon/actions/compose';
import { Blurhash } from 'mastodon/components/blurhash'; import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import type { MediaAttachment } from 'mastodon/models/media_attachment'; import type { MediaAttachment } from 'mastodon/models/media_attachment';
@ -27,16 +27,15 @@ export const Upload: React.FC<{
wide?: boolean; wide?: boolean;
}> = ({ id, dragging, overlay, tall, wide }) => { }> = ({ id, dragging, overlay, tall, wide }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const media = useAppSelector( const media = useAppSelector((state) =>
(state) => (
state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call (state.compose as ImmutableMap<string, unknown>).get(
.get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access 'media_attachments',
.find((item: MediaAttachment) => item.get('id') === id) as // eslint-disable-line @typescript-eslint/no-unsafe-member-access ) as ImmutableList<MediaAttachment>
| MediaAttachment ).find((item) => item.get('id') === id),
| undefined,
); );
const sensitive = useAppSelector( 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(() => { const handleUndoClick = useCallback(() => {
@ -44,7 +43,9 @@ export const Upload: React.FC<{
}, [dispatch, id]); }, [dispatch, id]);
const handleFocalPointClick = useCallback(() => { const handleFocalPointClick = useCallback(() => {
dispatch(initMediaEditModal(id)); dispatch(
openModal({ modalType: 'FOCAL_POINT', modalProps: { mediaId: id } }),
);
}, [dispatch, id]); }, [dispatch, id]);
const { attributes, listeners, setNodeRef, transform, transition } = 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 { useIntl, defineMessages } from 'react-intl';
import type { List } from 'immutable'; import type {
List,
Map as ImmutableMap,
List as ImmutableList,
} from 'immutable';
import type { import type {
DragStartEvent, DragStartEvent,
@ -63,18 +67,20 @@ export const UploadForm: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const mediaIds = useAppSelector( const mediaIds = useAppSelector(
(state) => (state) =>
state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call (
.get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access (state.compose as ImmutableMap<string, unknown>).get(
.map((item: MediaAttachment) => item.get('id')) as List<string>, // eslint-disable-line @typescript-eslint/no-unsafe-member-access 'media_attachments',
) as ImmutableList<MediaAttachment>
).map((item: MediaAttachment) => item.get('id')) as List<string>,
); );
const active = useAppSelector( 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( 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( 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 [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
const sensors = useSensors( const sensors = useSensors(

View File

@ -65,7 +65,9 @@ export const NotificationFollow: React.FC<{
const account = notification.sampleAccountIds[0]; const account = notification.sampleAccountIds[0];
if (account) { if (account) {
actions = <FollowButton accountId={notification.sampleAccountIds[0]} />; actions = (
<FollowButton compact accountId={notification.sampleAccountIds[0]} />
);
additionalContent = <FollowerCount accountId={account} />; additionalContent = <FollowerCount accountId={account} />;
} }
} }

View File

@ -28,7 +28,6 @@ export const BoostModal: React.FC<{
const intl = useIntl(); const intl = useIntl();
const defaultPrivacy = useAppSelector( 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, (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 { Helmet } from 'react-helmet';
import Base from 'mastodon/components/modal_root'; import Base from 'mastodon/components/modal_root';
import { AltTextModal } from 'mastodon/features/alt_text_modal';
import { import {
MuteModal, MuteModal,
BlockModal, BlockModal,
@ -37,7 +38,6 @@ import {
ConfirmLogOutModal, ConfirmLogOutModal,
ConfirmFollowToListModal, ConfirmFollowToListModal,
} from './confirmation_modals'; } from './confirmation_modals';
import FocalPointModal from './focal_point_modal';
import ImageModal from './image_modal'; import ImageModal from './image_modal';
import MediaModal from './media_modal'; import MediaModal from './media_modal';
import { ModalPlaceholder } from './modal_placeholder'; import { ModalPlaceholder } from './modal_placeholder';
@ -64,7 +64,7 @@ export const MODAL_COMPONENTS = {
'REPORT': ReportModal, 'REPORT': ReportModal,
'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal, 'EMBED': EmbedModal,
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), 'FOCAL_POINT': () => Promise.resolve({ default: AltTextModal }),
'LIST_ADDER': ListAdder, 'LIST_ADDER': ListAdder,
'COMPARE_HISTORY': CompareHistoryModal, 'COMPARE_HISTORY': CompareHistoryModal,
'FILTER': FilterModal, '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}> <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}>
{(SpecificComponent) => { {(SpecificComponent) => {
const ref = typeof SpecificComponent !== 'function' ? this.setModalRef : undefined; return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />;
return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={ref} />;
}} }}
</BundleContainer> </BundleContainer>

View File

@ -16,6 +16,7 @@ const mapDispatchToProps = dispatch => ({
if (confirmationMessage) { if (confirmationMessage) {
dispatch( dispatch(
openModal({ openModal({
previousModalProps: confirmationMessage.props,
modalType: 'CONFIRM', modalType: 'CONFIRM',
modalProps: { modalProps: {
message: confirmationMessage.message, message: confirmationMessage.message,
@ -24,7 +25,8 @@ const mapDispatchToProps = dispatch => ({
modalType: undefined, modalType: undefined,
ignoreFocus: { ignoreFocus }, ignoreFocus: { ignoreFocus },
})), })),
} }), },
}),
); );
} else { } else {
dispatch(closeModal({ dispatch(closeModal({

View File

@ -86,6 +86,13 @@
"alert.unexpected.message": "An unexpected error occurred.", "alert.unexpected.message": "An unexpected error occurred.",
"alert.unexpected.title": "Oops!", "alert.unexpected.title": "Oops!",
"alt_text_badge.title": "Alt text", "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", "announcement.announcement": "Announcement",
"annual_report.summary.archetype.booster": "The cool-hunter", "annual_report.summary.archetype.booster": "The cool-hunter",
"annual_report.summary.archetype.lurker": "The lurker", "annual_report.summary.archetype.lurker": "The lurker",
@ -875,26 +882,12 @@
"upload_button.label": "Add images, a video or an audio file", "upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.", "upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.", "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.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_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_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_over": "Media attachment {item} was moved.",
"upload_form.drag_and_drop.on_drag_start": "Picked up media attachment {item}.", "upload_form.drag_and_drop.on_drag_start": "Picked up media attachment {item}.",
"upload_form.edit": "Edit", "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.label": "Uploading...",
"upload_progress.processing": "Processing…", "upload_progress.processing": "Processing…",
"username.taken": "That username is taken. Try another", "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 { 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 { timelineDelete } from 'mastodon/actions/timelines_typed';
import { import {
@ -36,17 +37,11 @@ import {
COMPOSE_LANGUAGE_CHANGE, COMPOSE_LANGUAGE_CHANGE,
COMPOSE_COMPOSING_CHANGE, COMPOSE_COMPOSING_CHANGE,
COMPOSE_EMOJI_INSERT, COMPOSE_EMOJI_INSERT,
COMPOSE_UPLOAD_CHANGE_REQUEST,
COMPOSE_UPLOAD_CHANGE_SUCCESS,
COMPOSE_UPLOAD_CHANGE_FAIL,
COMPOSE_RESET, COMPOSE_RESET,
COMPOSE_POLL_ADD, COMPOSE_POLL_ADD,
COMPOSE_POLL_REMOVE, COMPOSE_POLL_REMOVE,
COMPOSE_POLL_OPTION_CHANGE, COMPOSE_POLL_OPTION_CHANGE,
COMPOSE_POLL_SETTINGS_CHANGE, COMPOSE_POLL_SETTINGS_CHANGE,
INIT_MEDIA_EDIT_MODAL,
COMPOSE_CHANGE_MEDIA_DESCRIPTION,
COMPOSE_CHANGE_MEDIA_FOCUS,
COMPOSE_CHANGE_MEDIA_ORDER, COMPOSE_CHANGE_MEDIA_ORDER,
COMPOSE_SET_STATUS, COMPOSE_SET_STATUS,
COMPOSE_FOCUS, COMPOSE_FOCUS,
@ -87,13 +82,6 @@ const initialState = ImmutableMap({
resetFileKey: Math.floor((Math.random() * 0x10000)), resetFileKey: Math.floor((Math.random() * 0x10000)),
idempotencyKey: null, idempotencyKey: null,
tagHistory: ImmutableList(), tagHistory: ImmutableList(),
media_modal: ImmutableMap({
id: null,
description: '',
focusX: 0,
focusY: 0,
dirty: false,
}),
}); });
const initialPoll = ImmutableMap({ const initialPoll = ImmutableMap({
@ -294,7 +282,24 @@ const updatePoll = (state, index, value, maxOptions) => state.updateIn(['poll',
return tmp; 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) { switch(action.type) {
case STORE_HYDRATE: case STORE_HYDRATE:
return hydrate(state, action.state.get('compose')); return hydrate(state, action.state.get('compose'));
@ -369,16 +374,13 @@ export default function compose(state = initialState, action) {
}); });
case COMPOSE_SUBMIT_REQUEST: case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true); return state.set('is_submitting', true);
case COMPOSE_UPLOAD_CHANGE_REQUEST:
return state.set('is_changing_upload', true);
case COMPOSE_REPLY_CANCEL: case COMPOSE_REPLY_CANCEL:
case COMPOSE_RESET: case COMPOSE_RESET:
case COMPOSE_SUBMIT_SUCCESS: case COMPOSE_SUBMIT_SUCCESS:
return clearAll(state); return clearAll(state);
case COMPOSE_SUBMIT_FAIL: case COMPOSE_SUBMIT_FAIL:
return state.set('is_submitting', false); return state.set('is_submitting', false);
case COMPOSE_UPLOAD_CHANGE_FAIL:
return state.set('is_changing_upload', false);
case COMPOSE_UPLOAD_REQUEST: case COMPOSE_UPLOAD_REQUEST:
return state.set('is_uploading', true).update('pending_media_attachments', n => n + 1); return state.set('is_uploading', true).update('pending_media_attachments', n => n + 1);
case COMPOSE_UPLOAD_PROCESSING: case COMPOSE_UPLOAD_PROCESSING:
@ -407,20 +409,6 @@ export default function compose(state = initialState, action) {
return item; 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: case COMPOSE_MENTION:
return state.withMutations(map => { return state.withMutations(map => {
map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); 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: case COMPOSE_EMOJI_INSERT:
return insertEmoji(state, action.position, action.emoji, action.needsSpace); 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: case REDRAFT:
return state.withMutations(map => { return state.withMutations(map => {
map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status))); map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
@ -550,4 +527,4 @@ export default function compose(state = initialState, action) {
default: default:
return state; return state;
} }
} };

View File

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

View File

@ -3,7 +3,6 @@ import { Record as ImmutableRecord, Stack } from 'immutable';
import { timelineDelete } from 'mastodon/actions/timelines_typed'; import { timelineDelete } from 'mastodon/actions/timelines_typed';
import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose';
import type { ModalType } from '../actions/modal'; import type { ModalType } from '../actions/modal';
import { openModal, closeModal } from '../actions/modal'; import { openModal, closeModal } from '../actions/modal';
@ -53,12 +52,36 @@ const pushModal = (
state: State, state: State,
modalType: ModalType, modalType: ModalType,
modalProps: ModalProps, modalProps: ModalProps,
previousModalProps?: ModalProps,
): State => { ): State => {
return state.withMutations((record) => { return state.withMutations((record) => {
record.set('ignoreFocus', false); record.set('ignoreFocus', false);
record.update('stack', (stack) => record.update('stack', (stack) => {
stack.unshift(Modal({ modalType, modalProps })), 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, state,
action.payload.modalType, action.payload.modalType,
action.payload.modalProps, action.payload.modalProps,
action.payload.previousModalProps,
); );
else if (closeModal.match(action)) return popModal(state, action.payload); else if (closeModal.match(action)) return popModal(state, action.payload);
// TODO: type those actions // 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)) else if (timelineDelete.match(action))
return state.update('stack', (stack) => return state.update('stack', (stack) =>
stack.filterNot( stack.filterNot(

View File

@ -127,9 +127,7 @@
.actions-modal ul li:not(:empty) a:focus button, .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,
.actions-modal ul li:not(:empty) a:hover button, .actions-modal ul li:not(:empty) a:hover button,
.simple_form .block-button, .simple_form button:not(.button, .link-button) {
.simple_form .button,
.simple_form button {
color: $white; color: $white;
} }
@ -142,6 +140,11 @@
border-top-color: lighten($ui-base-color, 4%); 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:hover,
.reactions-bar__item:focus, .reactions-bar__item:focus,
.reactions-bar__item:active { .reactions-bar__item:active {

View File

@ -85,6 +85,14 @@
outline: $ui-button-icon-focus-outline; outline: $ui-button-icon-focus-outline;
} }
&--compact {
font-size: 14px;
line-height: normal;
font-weight: 700;
padding: 5px 12px;
border-radius: 4px;
}
&--dangerous { &--dangerous {
background-color: var(--error-background-color); background-color: var(--error-background-color);
color: var(--on-error-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 { .status-card {
display: flex; display: flex;
align-items: center; align-items: center;
@ -6104,6 +6060,35 @@ a.status-card {
gap: 16px; gap: 16px;
padding: 24px; 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 { .copy-paste-text {
@ -6450,62 +6435,6 @@ a.status-card {
margin-bottom: 29px; 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 { .actions-modal {
max-height: 80vh; max-height: 80vh;
max-width: 80vw; max-width: 80vw;
@ -6998,11 +6927,6 @@ a.status-card {
outline: 1px solid var(--media-outline-color); outline: 1px solid var(--media-outline-color);
outline-offset: -1px; outline-offset: -1px;
&.editable {
border-radius: 0;
height: 100%;
}
&.inactive { &.inactive {
audio, audio,
.video-player__controls { .video-player__controls {
@ -7071,11 +6995,6 @@ a.status-card {
outline-offset: -1px; outline-offset: -1px;
z-index: 2; z-index: 2;
&.editable {
border-radius: 0;
height: 100% !important;
}
video { video {
display: block; display: block;
z-index: -2; z-index: -2;
@ -7381,6 +7300,14 @@ a.status-card {
} }
.gifv { .gifv {
position: relative;
canvas {
position: absolute;
width: 100%;
height: 100%;
}
video { video {
max-width: 100vw; max-width: 100vw;
max-height: 80vh; max-height: 80vh;
@ -7686,24 +7613,14 @@ noscript {
.focal-point { .focal-point {
position: relative; position: relative;
cursor: move; cursor: grab;
overflow: hidden; overflow: hidden;
height: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background: $base-shadow-color;
img, &.dragging {
video, cursor: grabbing;
canvas {
display: block;
max-height: 80vh;
width: 100%;
height: auto;
margin: 0;
object-fit: contain;
background: $base-shadow-color;
} }
&__reticle { &__reticle {
@ -7711,54 +7628,10 @@ noscript {
width: 100px; width: 100px;
height: 100px; height: 100px;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
background: url('~images/reticle.png') no-repeat 0 0; border: 2px solid #fff;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35); box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
} pointer-events: none;
&__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;
}
} }
} }
@ -10468,12 +10341,7 @@ noscript {
.compose-form__actions { .compose-form__actions {
.button { .button {
display: block; // Otherwise text-ellipsis doesn't work display: block; // Otherwise text-ellipsis doesn't work
font-size: 14px;
line-height: normal;
font-weight: 700;
flex: 1 1 auto; flex: 1 1 auto;
padding: 5px 12px;
border-radius: 4px;
} }
} }

View File

@ -76,6 +76,18 @@ code {
margin-bottom: 16px; margin-bottom: 16px;
overflow: hidden; overflow: hidden;
&:last-child {
margin-bottom: 0;
}
&__toolbar {
margin-top: 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
&.hidden { &.hidden {
margin: 0; margin: 0;
} }
@ -540,6 +552,7 @@ code {
.actions { .actions {
margin-top: 30px; margin-top: 30px;
display: flex; display: flex;
gap: 10px;
&.actions--top { &.actions--top {
margin-top: 0; margin-top: 0;
@ -552,9 +565,7 @@ code {
margin-bottom: 15px; margin-bottom: 15px;
} }
button, button:not(.button, .link-button) {
.button,
.block-button {
display: block; display: block;
width: 100%; width: 100%;
border: 0; border: 0;
@ -629,6 +640,18 @@ code {
} }
.label_input { .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 { &__wrapper {
position: relative; 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 @sender = notification.from_account
@notification = notification @notification = notification
@policy = NotificationPolicy.find_or_initialize_by(account: @recipient) @policy = NotificationPolicy.find_or_initialize_by(account: @recipient)
@from_staff = @sender.local? && @sender.user.present? && @sender.user_role&.bypass_block?(@recipient.user_role)
end end
private private
@ -63,6 +64,14 @@ class NotifyService < BaseService
@sender.silenced? && not_following? @sender.silenced? && not_following?
end end
def message?
@notification.type == :mention
end
def from_staff?
@from_staff
end
def private_mention_not_in_response? def private_mention_not_in_response?
@notification.type == :mention && @notification.target_status.direct_visibility? && !response_to_recipient? @notification.type == :mention && @notification.target_status.direct_visibility? && !response_to_recipient?
end end
@ -129,14 +138,6 @@ class NotifyService < BaseService
FeedManager.instance.filter?(:mentions, @notification.target_status, @recipient) FeedManager.instance.filter?(:mentions, @notification.target_status, @recipient)
end 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? def from_self?
@recipient.id == @sender.id @recipient.id == @sender.id
end end
@ -174,6 +175,7 @@ class NotifyService < BaseService
def filter? def filter?
return false unless filterable_type? return false unless filterable_type?
return false if override_for_sender? return false if override_for_sender?
return false if message? && from_staff?
filtered_by_limited_accounts_policy? || filtered_by_limited_accounts_policy? ||
filtered_by_not_following_policy? || filtered_by_not_following_policy? ||

View File

@ -39,7 +39,7 @@
.actions .actions
%button.button= t('admin.accounts.search') %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/ %hr.spacer/

View File

@ -35,7 +35,7 @@
.actions .actions
%button.button= t('admin.accounts.search') %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| = form_with model: @form, url: batch_admin_custom_emojis_path do |f|
= hidden_field_tag :page, params[:page] || 1 = hidden_field_tag :page, params[:page] || 1

View File

@ -19,4 +19,4 @@
.actions .actions
= link_to t('.cancel'), admin_instances_path, class: 'button button-tertiary' = 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 .actions
%button.button= t('admin.accounts.search') %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/ %hr.spacer/

View File

@ -3,7 +3,7 @@
.simple_form .simple_form
%p.hint= t('admin.relays.description_html') %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? - unless @relays.empty?
%hr.spacer %hr.spacer

View File

@ -28,7 +28,7 @@
.actions .actions
%button.button= t('admin.accounts.search') %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| - @reports.group_by(&:target_account_id).each_value do |reports|
- target_account = reports.first.target_account - target_account = reports.first.target_account

View File

@ -25,7 +25,7 @@
.actions .actions
%button.button= t('admin.tags.search') %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/ %hr.spacer/

View File

@ -6,4 +6,4 @@
%hr.spacer/ %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/ %hr.spacer/
.simple_form .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/ %hr.spacer/
.simple_form .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 = { module.exports = {
test: [ test: [
/tesseract\.js\/dist\/worker\.min\.js$/, /tesseract\.js\/dist\/worker\.min\.js$/,
/tesseract\.js\/dist\/worker\.min\.js.map$/, /tesseract\.js\/dist\/worker\.min\.js\.map$/,
/tesseract\.js-core\/tesseract-core\.wasm$/, /tesseract\.js-core\/tesseract-core\.wasm\.js$/,
/tesseract\.js-core\/tesseract-core\.wasm.js$/,
], ],
use: { use: {
loader: 'file-loader', loader: 'file-loader',

View File

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

View File

@ -319,6 +319,16 @@ RSpec.describe NotifyService do
end end
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 context 'when sender is followed by recipient' do
before do before do
notification.account.follow!(notification.from_account) notification.account.follow!(notification.from_account)

View File

@ -26,8 +26,8 @@ RSpec.describe 'OCR', :attachment_processing, :inline_jobs, :js, :streaming do
end end
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
end end

121
yarn.lock
View File

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