Merge commit '26e7fe97714d077930621f9111b7eaad2774df65' into glitch-soc/merge-upstream
This commit is contained in:
commit
a7ba4ba446
72 files changed
+1365
-1152
No files matched your search
@@ -56,7 +56,6 @@ export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
|
||||
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
|
||||
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
|
||||
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
|
||||
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
|
||||
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
|
||||
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
|
||||
|
||||
@@ -796,13 +795,6 @@ export function changeComposeSpoilerText(text) {
|
||||
};
|
||||
}
|
||||
|
||||
export function changeComposeVisibility(value) {
|
||||
return {
|
||||
type: COMPOSE_VISIBILITY_CHANGE,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertEmojiCompose(position, emoji, needsSpace) {
|
||||
return {
|
||||
type: COMPOSE_EMOJI_INSERT,
|
||||
|
||||
@@ -13,10 +13,11 @@ import {
|
||||
} from 'mastodon/store/typed_functions';
|
||||
|
||||
import type { ApiQuotePolicy } from '../api_types/quotes';
|
||||
import type { Status } from '../models/status';
|
||||
import type { Status, StatusVisibility } from '../models/status';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
import { showAlert } from './alerts';
|
||||
import { focusCompose } from './compose';
|
||||
import { changeCompose, focusCompose } from './compose';
|
||||
import { importFetchedStatuses } from './importer';
|
||||
import { openModal } from './modal';
|
||||
|
||||
@@ -41,6 +42,10 @@ const messages = defineMessages({
|
||||
id: 'quote_error.unauthorized',
|
||||
defaultMessage: 'You are not authorized to quote this post.',
|
||||
},
|
||||
quoteErrorPrivateMention: {
|
||||
id: 'quote_error.private_mentions',
|
||||
defaultMessage: 'Quoting is not allowed with direct mentions.',
|
||||
},
|
||||
});
|
||||
|
||||
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
|
||||
@@ -67,6 +72,39 @@ const simulateModifiedApiResponse = (
|
||||
return data;
|
||||
};
|
||||
|
||||
export const changeComposeVisibility = createAppThunk(
|
||||
'compose/visibility_change',
|
||||
(visibility: StatusVisibility, { dispatch, getState }) => {
|
||||
if (visibility !== 'direct') {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
const quotedStatusId = state.compose.get('quoted_status_id') as
|
||||
| string
|
||||
| null;
|
||||
if (!quotedStatusId) {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
// Remove the quoted status
|
||||
dispatch(quoteComposeCancel());
|
||||
const quotedStatus = state.statuses.get(quotedStatusId) as Status | null;
|
||||
if (!quotedStatus) {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
// Append the quoted status URL to the compose text
|
||||
const url = quotedStatus.get('url') as string;
|
||||
const text = state.compose.get('text') as string;
|
||||
if (!text.includes(url)) {
|
||||
const newText = text.trim() ? `${text}\n\n${url}` : url;
|
||||
dispatch(changeCompose(newText));
|
||||
}
|
||||
return visibility;
|
||||
},
|
||||
);
|
||||
|
||||
export const changeUploadCompose = createDataLoadingThunk(
|
||||
'compose/changeUpload',
|
||||
async (
|
||||
@@ -130,6 +168,8 @@ export const quoteComposeByStatus = createAppThunk(
|
||||
|
||||
if (composeState.get('id')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorEdit }));
|
||||
} else if (composeState.get('privacy') === 'direct') {
|
||||
dispatch(showAlert({ message: messages.quoteErrorPrivateMention }));
|
||||
} else if (composeState.get('poll')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
||||
} else if (
|
||||
@@ -173,6 +213,17 @@ export const quoteComposeById = createAppThunk(
|
||||
},
|
||||
);
|
||||
|
||||
const composeStateForbidsLink = (composeState: RootState['compose']) => {
|
||||
return (
|
||||
composeState.get('quoted_status_id') ||
|
||||
composeState.get('is_submitting') ||
|
||||
composeState.get('poll') ||
|
||||
composeState.get('is_uploading') ||
|
||||
composeState.get('id') ||
|
||||
composeState.get('privacy') === 'direct'
|
||||
);
|
||||
};
|
||||
|
||||
export const pasteLinkCompose = createDataLoadingThunk(
|
||||
'compose/pasteLink',
|
||||
async ({ url }: { url: string }) => {
|
||||
@@ -183,16 +234,12 @@ export const pasteLinkCompose = createDataLoadingThunk(
|
||||
limit: 2,
|
||||
});
|
||||
},
|
||||
(data, { dispatch, getState }) => {
|
||||
(data, { dispatch, getState, requestId }) => {
|
||||
const composeState = getState().compose;
|
||||
|
||||
if (
|
||||
composeState.get('quoted_status_id') ||
|
||||
composeState.get('is_submitting') ||
|
||||
composeState.get('poll') ||
|
||||
composeState.get('is_uploading') ||
|
||||
composeState.get('id') ||
|
||||
composeState.get('privacy') === 'direct'
|
||||
composeStateForbidsLink(composeState) ||
|
||||
composeState.get('fetching_link') !== requestId // Request has been cancelled
|
||||
)
|
||||
return;
|
||||
|
||||
@@ -208,6 +255,17 @@ export const pasteLinkCompose = createDataLoadingThunk(
|
||||
dispatch(quoteComposeById(data.statuses[0].id));
|
||||
}
|
||||
},
|
||||
{
|
||||
useLoadingBar: false,
|
||||
condition: (_, { getState }) =>
|
||||
!getState().compose.get('fetching_link') &&
|
||||
!composeStateForbidsLink(getState().compose),
|
||||
},
|
||||
);
|
||||
|
||||
// Ideally this would cancel the action and the HTTP request, but this is good enough
|
||||
export const cancelPasteLinkCompose = createAction(
|
||||
'compose/cancelPasteLinkCompose',
|
||||
);
|
||||
|
||||
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
|
||||
|
||||
@@ -10,7 +10,7 @@ import ModalRoot from 'mastodon/components/modal_root';
|
||||
import { Poll } from 'mastodon/components/poll';
|
||||
import { Audio } from 'mastodon/features/audio';
|
||||
import Card from 'mastodon/features/status/components/card';
|
||||
import MediaModal from 'mastodon/features/ui/components/media_modal';
|
||||
import { MediaModal } from 'mastodon/features/ui/components/media_modal';
|
||||
import { Video } from 'mastodon/features/video';
|
||||
import { IntlProvider } from 'mastodon/locales';
|
||||
import { createPollFromServerJSON } from 'mastodon/models/poll';
|
||||
|
||||
@@ -140,7 +140,10 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onSubmit(missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct');
|
||||
this.props.onSubmit({
|
||||
missingAltText: missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct',
|
||||
quoteToPrivate: this.props.quoteToPrivate,
|
||||
});
|
||||
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { cancelPasteLinkCompose } from '@/mastodon/actions/compose_typed';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
import CancelFillIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
|
||||
const messages = defineMessages({
|
||||
quote_cancel: { id: 'status.quote.cancel', defaultMessage: 'Cancel quote' },
|
||||
});
|
||||
|
||||
export const QuotePlaceholder: FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const handleQuoteCancel = useCallback(() => {
|
||||
dispatch(cancelPasteLinkCompose());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div className='status__quote'>
|
||||
<div className='status'>
|
||||
<div className='status__info'>
|
||||
<div className='status__avatar'>
|
||||
<Skeleton width='32px' height='32px' />
|
||||
</div>
|
||||
<div className='status__display-name'>
|
||||
<DisplayName />
|
||||
</div>
|
||||
<IconButton
|
||||
onClick={handleQuoteCancel}
|
||||
className='status__quote-cancel'
|
||||
title={intl.formatMessage(messages.quote_cancel)}
|
||||
icon='cancel-fill'
|
||||
iconComponent={CancelFillIcon}
|
||||
/>
|
||||
</div>
|
||||
<div className='status__content'>
|
||||
<Skeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,11 +7,17 @@ import { quoteComposeCancel } from '@/mastodon/actions/compose_typed';
|
||||
import { QuotedStatus } from '@/mastodon/components/status_quoted';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { QuotePlaceholder } from './quote_placeholder';
|
||||
|
||||
export const ComposeQuotedStatus: FC = () => {
|
||||
const quotedStatusId = useAppSelector(
|
||||
(state) => state.compose.get('quoted_status_id') as string | null,
|
||||
);
|
||||
|
||||
const isFetchingLink = useAppSelector(
|
||||
(state) => !!state.compose.get('fetching_link'),
|
||||
);
|
||||
|
||||
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
|
||||
|
||||
const quote = useMemo(
|
||||
@@ -30,7 +36,9 @@ export const ComposeQuotedStatus: FC = () => {
|
||||
dispatch(quoteComposeCancel());
|
||||
}, [dispatch]);
|
||||
|
||||
if (!quote) {
|
||||
if (isFetchingLink && !quote) {
|
||||
return <QuotePlaceholder />;
|
||||
} else if (!quote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { changeComposeVisibility } from '@/mastodon/actions/compose';
|
||||
import { setComposeQuotePolicy } from '@/mastodon/actions/compose_typed';
|
||||
import {
|
||||
changeComposeVisibility,
|
||||
setComposeQuotePolicy,
|
||||
} from '@/mastodon/actions/compose_typed';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
|
||||
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'mastodon/actions/compose';
|
||||
import { pasteLinkCompose } from 'mastodon/actions/compose_typed';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { PRIVATE_QUOTE_MODAL_ID } from 'mastodon/features/ui/components/confirmation_modals/private_quote_notify';
|
||||
|
||||
import ComposeForm from '../components/compose_form';
|
||||
|
||||
@@ -32,6 +33,10 @@ const mapStateToProps = state => ({
|
||||
isUploading: state.getIn(['compose', 'is_uploading']),
|
||||
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
|
||||
missingAltText: state.getIn(['compose', 'media_attachments']).some(media => ['image', 'gifv'].includes(media.get('type')) && (media.get('description') ?? '').length === 0),
|
||||
quoteToPrivate:
|
||||
!!state.getIn(['compose', 'quoted_status_id'])
|
||||
&& state.getIn(['compose', 'privacy']) === 'private'
|
||||
&& !state.getIn(['settings', 'dismissed_banners', PRIVATE_QUOTE_MODAL_ID]),
|
||||
isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
|
||||
lang: state.getIn(['compose', 'language']),
|
||||
maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),
|
||||
@@ -43,12 +48,17 @@ const mapDispatchToProps = (dispatch, props) => ({
|
||||
dispatch(changeCompose(text));
|
||||
},
|
||||
|
||||
onSubmit (missingAltText) {
|
||||
onSubmit ({ missingAltText, quoteToPrivate }) {
|
||||
if (missingAltText) {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM_MISSING_ALT_TEXT',
|
||||
modalProps: {},
|
||||
}));
|
||||
} else if (quoteToPrivate) {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM_PRIVATE_QUOTE_NOTIFY',
|
||||
modalProps: {},
|
||||
}));
|
||||
} else {
|
||||
dispatch(submitCompose((status) => {
|
||||
if (props.redirectOnSuccess) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { changeComposeVisibility } from '../../../actions/compose';
|
||||
import { openModal, closeModal } from '../../../actions/modal';
|
||||
import { isUserTouching } from '../../../is_mobile';
|
||||
import { changeComposeVisibility } from '@/mastodon/actions/compose_typed';
|
||||
|
||||
import PrivacyDropdown from '../components/privacy_dropdown';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
|
||||
@@ -299,6 +299,12 @@ class Status extends ImmutablePureComponent {
|
||||
dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId, onChange: handleChange } }));
|
||||
};
|
||||
|
||||
handleQuote = (status) => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(quoteComposeById(status.get('id')));
|
||||
};
|
||||
|
||||
handleEditClick = (status) => {
|
||||
const { dispatch, askReplyConfirmation } = this.props;
|
||||
|
||||
@@ -592,7 +598,7 @@ class Status extends ImmutablePureComponent {
|
||||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
extraButton={(
|
||||
<button type='button' className='column-header__button' title={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll}><Icon id={status.get('hidden') ? 'eye-slash' : 'eye'} icon={status.get('hidden') ? VisibilityOffIcon : VisibilityIcon} /></button>
|
||||
<button type='button' className='column-header__button' title={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll}><Icon id={status.get('hidden') ? 'eye' : 'eye-slash'} icon={status.get('hidden') ? VisibilityIcon : VisibilityOffIcon} /></button>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -625,6 +631,7 @@ class Status extends ImmutablePureComponent {
|
||||
onDelete={this.handleDeleteClick}
|
||||
onRevokeQuote={this.handleRevokeQuoteClick}
|
||||
onQuotePolicyChange={this.handleQuotePolicyChange}
|
||||
onQuote={this.handleQuote}
|
||||
onEdit={this.handleEditClick}
|
||||
onDirect={this.handleDirectClick}
|
||||
onMention={this.handleMentionClick}
|
||||
|
||||
+4
@@ -18,6 +18,7 @@ export const ConfirmationModal: React.FC<
|
||||
onSecondary?: () => void;
|
||||
onConfirm: () => void;
|
||||
closeWhenConfirm?: boolean;
|
||||
extraContent?: React.ReactNode;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({
|
||||
title,
|
||||
@@ -29,6 +30,7 @@ export const ConfirmationModal: React.FC<
|
||||
secondary,
|
||||
onSecondary,
|
||||
closeWhenConfirm = true,
|
||||
extraContent,
|
||||
}) => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (closeWhenConfirm) {
|
||||
@@ -49,6 +51,8 @@ export const ConfirmationModal: React.FC<
|
||||
<div className='safety-action-modal__confirmation'>
|
||||
<h1>{title}</h1>
|
||||
{message && <p>{message}</p>}
|
||||
|
||||
{extraContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { forwardRef, useCallback, useState } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { submitCompose } from '@/mastodon/actions/compose';
|
||||
import { changeSetting } from '@/mastodon/actions/settings';
|
||||
import { CheckBox } from '@/mastodon/components/check_box';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
|
||||
import { ConfirmationModal } from './confirmation_modal';
|
||||
import type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||
import classes from './styles.module.css';
|
||||
|
||||
export const PRIVATE_QUOTE_MODAL_ID = 'quote/private_notify';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'confirmations.private_quote_notify.title',
|
||||
defaultMessage: 'Share with followers and mentioned users?',
|
||||
},
|
||||
message: {
|
||||
id: 'confirmations.private_quote_notify.message',
|
||||
defaultMessage:
|
||||
'The person you are quoting and other mentions ' +
|
||||
"will be notified and will be able to view your post, even if they're not following you.",
|
||||
},
|
||||
confirm: {
|
||||
id: 'confirmations.private_quote_notify.confirm',
|
||||
defaultMessage: 'Publish post',
|
||||
},
|
||||
cancel: {
|
||||
id: 'confirmations.private_quote_notify.cancel',
|
||||
defaultMessage: 'Back to editing',
|
||||
},
|
||||
});
|
||||
|
||||
export const PrivateQuoteNotify = forwardRef<
|
||||
HTMLDivElement,
|
||||
BaseConfirmationModalProps
|
||||
>(
|
||||
(
|
||||
{ onClose },
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_ref,
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [dismiss, setDismissed] = useState(false);
|
||||
const handleDismissToggle = useCallback(() => {
|
||||
setDismissed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleConfirm = useCallback(() => {
|
||||
dispatch(submitCompose());
|
||||
if (dismiss) {
|
||||
dispatch(
|
||||
changeSetting(['dismissed_banners', PRIVATE_QUOTE_MODAL_ID], true),
|
||||
);
|
||||
}
|
||||
}, [dismiss, dispatch]);
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(messages.title)}
|
||||
message={intl.formatMessage(messages.message)}
|
||||
confirm={intl.formatMessage(messages.confirm)}
|
||||
cancel={intl.formatMessage(messages.cancel)}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={onClose}
|
||||
extraContent={
|
||||
<label className={classes.checkbox_wrapper}>
|
||||
<CheckBox
|
||||
value='hide'
|
||||
checked={dismiss}
|
||||
onChange={handleDismissToggle}
|
||||
/>{' '}
|
||||
<FormattedMessage
|
||||
id='confirmations.private_quote_notify.do_not_show_again'
|
||||
defaultMessage="Don't show me this message again"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
PrivateQuoteNotify.displayName = 'PrivateQuoteNotify';
|
||||
@@ -0,0 +1,7 @@
|
||||
.checkbox_wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import ReactSwipeableViews from 'react-swipeable-views';
|
||||
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import FitScreenIcon from '@/material-icons/400-24px/fit_screen.svg?react';
|
||||
import ActualSizeIcon from '@/svg-icons/actual_size.svg?react';
|
||||
import { getAverageFromBlurhash } from 'mastodon/blurhash';
|
||||
import { GIFV } from 'mastodon/components/gifv';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { Footer } from 'mastodon/features/picture_in_picture/components/footer';
|
||||
import { Video } from 'mastodon/features/video';
|
||||
import { disableSwiping } from 'mastodon/initial_state';
|
||||
|
||||
import { ZoomableImage } from './zoomable_image';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||
zoomIn: { id: 'lightbox.zoom_in', defaultMessage: 'Zoom to actual size' },
|
||||
zoomOut: { id: 'lightbox.zoom_out', defaultMessage: 'Zoom to fit' },
|
||||
});
|
||||
|
||||
class MediaModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.list.isRequired,
|
||||
statusId: PropTypes.string,
|
||||
lang: PropTypes.string,
|
||||
index: PropTypes.number.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onChangeBackgroundColor: PropTypes.func.isRequired,
|
||||
currentTime: PropTypes.number,
|
||||
autoPlay: PropTypes.bool,
|
||||
volume: PropTypes.number,
|
||||
};
|
||||
|
||||
state = {
|
||||
index: null,
|
||||
navigationHidden: false,
|
||||
zoomedIn: false,
|
||||
};
|
||||
|
||||
handleZoomClick = () => {
|
||||
this.setState(prevState => ({
|
||||
zoomedIn: !prevState.zoomedIn,
|
||||
}));
|
||||
};
|
||||
|
||||
handleZoomChange = (zoomedIn) => {
|
||||
this.setState({
|
||||
zoomedIn,
|
||||
});
|
||||
};
|
||||
|
||||
handleSwipe = (index) => {
|
||||
this.setState({
|
||||
index: index % this.props.media.size,
|
||||
zoomedIn: false,
|
||||
});
|
||||
};
|
||||
|
||||
handleTransitionEnd = () => {
|
||||
this.setState({
|
||||
zoomedIn: false,
|
||||
});
|
||||
};
|
||||
|
||||
handleNextClick = () => {
|
||||
this.setState({
|
||||
index: (this.getIndex() + 1) % this.props.media.size,
|
||||
zoomedIn: false,
|
||||
});
|
||||
};
|
||||
|
||||
handlePrevClick = () => {
|
||||
this.setState({
|
||||
index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size,
|
||||
zoomedIn: false,
|
||||
});
|
||||
};
|
||||
|
||||
handleChangeIndex = (e) => {
|
||||
const index = Number(e.currentTarget.getAttribute('data-index'));
|
||||
|
||||
this.setState({
|
||||
index: index % this.props.media.size,
|
||||
zoomedIn: false,
|
||||
});
|
||||
};
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
switch(e.key) {
|
||||
case 'ArrowLeft':
|
||||
this.handlePrevClick();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
this.handleNextClick();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
window.addEventListener('keydown', this.handleKeyDown, false);
|
||||
|
||||
this._sendBackgroundColor();
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps, prevState) {
|
||||
if (prevState.index !== this.state.index) {
|
||||
this._sendBackgroundColor();
|
||||
}
|
||||
}
|
||||
|
||||
_sendBackgroundColor () {
|
||||
const { media, onChangeBackgroundColor } = this.props;
|
||||
const index = this.getIndex();
|
||||
const blurhash = media.getIn([index, 'blurhash']);
|
||||
|
||||
if (blurhash) {
|
||||
const backgroundColor = getAverageFromBlurhash(blurhash);
|
||||
onChangeBackgroundColor(backgroundColor);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('keydown', this.handleKeyDown);
|
||||
|
||||
this.props.onChangeBackgroundColor(null);
|
||||
}
|
||||
|
||||
getIndex () {
|
||||
return this.state.index !== null ? this.state.index : this.props.index;
|
||||
}
|
||||
|
||||
handleToggleNavigation = () => {
|
||||
this.setState(prevState => ({
|
||||
navigationHidden: !prevState.navigationHidden,
|
||||
}));
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.setState({
|
||||
viewportWidth: c?.clientWidth,
|
||||
viewportHeight: c?.clientHeight,
|
||||
});
|
||||
};
|
||||
|
||||
render () {
|
||||
const { media, statusId, lang, intl, onClose } = this.props;
|
||||
const { navigationHidden, zoomedIn, viewportWidth, viewportHeight } = this.state;
|
||||
|
||||
const index = this.getIndex();
|
||||
|
||||
const leftNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--prev' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>;
|
||||
const rightNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--next' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>;
|
||||
|
||||
const content = media.map((image, idx) => {
|
||||
const width = image.getIn(['meta', 'original', 'width']) || null;
|
||||
const height = image.getIn(['meta', 'original', 'height']) || null;
|
||||
const description = image.getIn(['translation', 'description']) || image.get('description');
|
||||
|
||||
if (image.get('type') === 'image') {
|
||||
return (
|
||||
<ZoomableImage
|
||||
src={image.get('url')}
|
||||
blurhash={image.get('blurhash')}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={description}
|
||||
lang={lang}
|
||||
key={image.get('url')}
|
||||
onClick={this.handleToggleNavigation}
|
||||
onDoubleClick={this.handleZoomClick}
|
||||
onClose={onClose}
|
||||
onZoomChange={this.handleZoomChange}
|
||||
zoomedIn={zoomedIn && idx === index}
|
||||
/>
|
||||
);
|
||||
} else if (image.get('type') === 'video') {
|
||||
const { currentTime, autoPlay, volume } = this.props;
|
||||
|
||||
return (
|
||||
<Video
|
||||
preview={image.get('preview_url')}
|
||||
blurhash={image.get('blurhash')}
|
||||
src={image.get('url')}
|
||||
frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
|
||||
aspectRatio={`${image.getIn(['meta', 'original', 'width'])} / ${image.getIn(['meta', 'original', 'height'])}`}
|
||||
startTime={currentTime || 0}
|
||||
startPlaying={autoPlay || false}
|
||||
startVolume={volume || 1}
|
||||
onCloseVideo={onClose}
|
||||
detailed
|
||||
alt={description}
|
||||
lang={lang}
|
||||
key={image.get('url')}
|
||||
/>
|
||||
);
|
||||
} else if (image.get('type') === 'gifv') {
|
||||
return (
|
||||
<GIFV
|
||||
src={image.get('url')}
|
||||
key={image.get('url')}
|
||||
alt={description}
|
||||
lang={lang}
|
||||
onClick={this.toggleNavigation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}).toArray();
|
||||
|
||||
// you can't use 100vh, because the viewport height is taller
|
||||
// than the visible part of the document in some mobile
|
||||
// browsers when it's address bar is visible.
|
||||
// https://developers.google.com/web/updates/2016/12/url-bar-resizing
|
||||
const swipeableViewsStyle = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
const containerStyle = {
|
||||
alignItems: 'center', // center vertically
|
||||
};
|
||||
|
||||
const navigationClassName = classNames('media-modal__navigation', {
|
||||
'media-modal__navigation--hidden': navigationHidden,
|
||||
});
|
||||
|
||||
let pagination;
|
||||
|
||||
if (media.size > 1) {
|
||||
pagination = media.map((item, i) => (
|
||||
<button key={i} className={classNames('media-modal__page-dot', { active: i === index })} data-index={i} onClick={this.handleChangeIndex}>
|
||||
{i + 1}
|
||||
</button>
|
||||
));
|
||||
}
|
||||
|
||||
const currentMedia = media.get(index);
|
||||
const zoomable = currentMedia.get('type') === 'image' && (currentMedia.getIn(['meta', 'original', 'width']) > viewportWidth || currentMedia.getIn(['meta', 'original', 'height']) > viewportHeight);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal media-modal' ref={this.setRef}>
|
||||
<div className='media-modal__closer' role='presentation' onClick={onClose}>
|
||||
<ReactSwipeableViews
|
||||
style={swipeableViewsStyle}
|
||||
containerStyle={containerStyle}
|
||||
onChangeIndex={this.handleSwipe}
|
||||
onTransitionEnd={this.handleTransitionEnd}
|
||||
index={index}
|
||||
disabled={disableSwiping || zoomedIn}
|
||||
>
|
||||
{content}
|
||||
</ReactSwipeableViews>
|
||||
</div>
|
||||
|
||||
<div className={navigationClassName}>
|
||||
<div className='media-modal__buttons'>
|
||||
{zoomable && <IconButton title={intl.formatMessage(zoomedIn ? messages.zoomOut : messages.zoomIn)} iconComponent={zoomedIn ? FitScreenIcon : ActualSizeIcon} onClick={this.handleZoomClick} />}
|
||||
<IconButton title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} />
|
||||
</div>
|
||||
|
||||
{leftNav}
|
||||
{rightNav}
|
||||
|
||||
<div className='media-modal__overlay'>
|
||||
{pagination && <ul className='media-modal__pagination'>{pagination}</ul>}
|
||||
{statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(MediaModal);
|
||||
@@ -0,0 +1,363 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { RefCallback, FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
import { useDrag } from '@use-gesture/react';
|
||||
|
||||
import type { MediaAttachment } from '@/mastodon/models/status';
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import FitScreenIcon from '@/material-icons/400-24px/fit_screen.svg?react';
|
||||
import ActualSizeIcon from '@/svg-icons/actual_size.svg?react';
|
||||
import type { RGB } from 'mastodon/blurhash';
|
||||
import { getAverageFromBlurhash } from 'mastodon/blurhash';
|
||||
import { GIFV } from 'mastodon/components/gifv';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { Footer } from 'mastodon/features/picture_in_picture/components/footer';
|
||||
import { Video } from 'mastodon/features/video';
|
||||
|
||||
import { ZoomableImage } from './zoomable_image';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||
zoomIn: { id: 'lightbox.zoom_in', defaultMessage: 'Zoom to actual size' },
|
||||
zoomOut: { id: 'lightbox.zoom_out', defaultMessage: 'Zoom to fit' },
|
||||
});
|
||||
|
||||
interface MediaModalProps {
|
||||
media: ImmutableList<MediaAttachment>;
|
||||
statusId?: string;
|
||||
lang?: string;
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
onChangeBackgroundColor: (color: RGB | null) => void;
|
||||
currentTime?: number;
|
||||
autoPlay?: boolean;
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
export const MediaModal: FC<MediaModalProps> = forwardRef<
|
||||
HTMLDivElement,
|
||||
MediaModalProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
media,
|
||||
onClose,
|
||||
index: startIndex,
|
||||
lang,
|
||||
currentTime,
|
||||
autoPlay,
|
||||
volume,
|
||||
statusId,
|
||||
onChangeBackgroundColor,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- _ref is required to keep the ref forwarding working
|
||||
_ref,
|
||||
) => {
|
||||
const [index, setIndex] = useState(startIndex);
|
||||
const currentMedia = media.get(index);
|
||||
|
||||
const handleChangeIndex = useCallback(
|
||||
(newIndex: number) => {
|
||||
if (newIndex < 0) {
|
||||
newIndex = media.size + newIndex;
|
||||
}
|
||||
setIndex(newIndex % media.size);
|
||||
setZoomedIn(false);
|
||||
},
|
||||
[media.size],
|
||||
);
|
||||
const handlePrevClick = useCallback(() => {
|
||||
handleChangeIndex(index - 1);
|
||||
}, [handleChangeIndex, index]);
|
||||
const handleNextClick = useCallback(() => {
|
||||
handleChangeIndex(index + 1);
|
||||
}, [handleChangeIndex, index]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
handlePrevClick();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
handleNextClick();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
[handleNextClick, handlePrevClick],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown, false);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
useEffect(() => {
|
||||
const blurhash = currentMedia?.get('blurhash') as string | undefined;
|
||||
if (blurhash) {
|
||||
const backgroundColor = getAverageFromBlurhash(blurhash);
|
||||
if (backgroundColor) {
|
||||
onChangeBackgroundColor(backgroundColor);
|
||||
}
|
||||
}
|
||||
}, [currentMedia, onChangeBackgroundColor]);
|
||||
|
||||
const [viewportDimensions, setViewportDimensions] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
}>({ width: 0, height: 0 });
|
||||
const handleRef: RefCallback<HTMLDivElement> = useCallback((ele) => {
|
||||
if (ele?.clientWidth && ele.clientHeight) {
|
||||
setViewportDimensions({
|
||||
width: ele.clientWidth,
|
||||
height: ele.clientHeight,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [zoomedIn, setZoomedIn] = useState(false);
|
||||
const zoomable =
|
||||
currentMedia?.get('type') === 'image' &&
|
||||
((currentMedia.getIn(['meta', 'original', 'width']) as number) >
|
||||
viewportDimensions.width ||
|
||||
(currentMedia.getIn(['meta', 'original', 'height']) as number) >
|
||||
viewportDimensions.height);
|
||||
const handleZoomClick = useCallback(() => {
|
||||
setZoomedIn((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const wrapperStyles = useSpring({
|
||||
x: `-${index * 100}%`,
|
||||
});
|
||||
const bind = useDrag(
|
||||
({ swipe: [swipeX] }) => {
|
||||
if (swipeX === 0) return;
|
||||
handleChangeIndex(index + swipeX * -1); // Invert swipe as swiping left loads the next slide.
|
||||
},
|
||||
{ pointer: { capture: false } },
|
||||
);
|
||||
|
||||
const [navigationHidden, setNavigationHidden] = useState(false);
|
||||
const handleToggleNavigation = useCallback(() => {
|
||||
setNavigationHidden((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const content = useMemo(
|
||||
() =>
|
||||
media.map((item, idx) => {
|
||||
const url = item.get('url') as string;
|
||||
const blurhash = item.get('blurhash') as string;
|
||||
const width = item.getIn(['meta', 'original', 'width'], 0) as number;
|
||||
const height = item.getIn(
|
||||
['meta', 'original', 'height'],
|
||||
0,
|
||||
) as number;
|
||||
const description = item.getIn(
|
||||
['translation', 'description'],
|
||||
item.get('description'),
|
||||
) as string;
|
||||
if (item.get('type') === 'image') {
|
||||
return (
|
||||
<ZoomableImage
|
||||
src={url}
|
||||
blurhash={blurhash}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={description}
|
||||
lang={lang}
|
||||
key={url}
|
||||
onClick={handleToggleNavigation}
|
||||
onDoubleClick={handleZoomClick}
|
||||
onClose={onClose}
|
||||
onZoomChange={setZoomedIn}
|
||||
zoomedIn={zoomedIn && idx === index}
|
||||
/>
|
||||
);
|
||||
} else if (item.get('type') === 'video') {
|
||||
return (
|
||||
<Video
|
||||
preview={item.get('preview_url') as string | undefined}
|
||||
blurhash={blurhash}
|
||||
src={url}
|
||||
frameRate={
|
||||
item.getIn(['meta', 'original', 'frame_rate']) as
|
||||
| string
|
||||
| undefined
|
||||
}
|
||||
aspectRatio={`${width} / ${height}`}
|
||||
startTime={currentTime ?? 0}
|
||||
startPlaying={autoPlay ?? false}
|
||||
startVolume={volume ?? 1}
|
||||
onCloseVideo={onClose}
|
||||
detailed
|
||||
alt={description}
|
||||
lang={lang}
|
||||
key={url}
|
||||
/>
|
||||
);
|
||||
} else if (item.get('type') === 'gifv') {
|
||||
return (
|
||||
<GIFV
|
||||
src={url}
|
||||
key={url}
|
||||
alt={description}
|
||||
lang={lang}
|
||||
onClick={handleToggleNavigation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
[
|
||||
autoPlay,
|
||||
currentTime,
|
||||
handleToggleNavigation,
|
||||
handleZoomClick,
|
||||
index,
|
||||
lang,
|
||||
media,
|
||||
onClose,
|
||||
volume,
|
||||
zoomedIn,
|
||||
],
|
||||
);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
const leftNav = media.size > 1 && (
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='media-modal__nav media-modal__nav--prev'
|
||||
onClick={handlePrevClick}
|
||||
aria-label={intl.formatMessage(messages.previous)}
|
||||
>
|
||||
<Icon id='chevron-left' icon={ChevronLeftIcon} />
|
||||
</button>
|
||||
);
|
||||
const rightNav = media.size > 1 && (
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='media-modal__nav media-modal__nav--next'
|
||||
onClick={handleNextClick}
|
||||
aria-label={intl.formatMessage(messages.next)}
|
||||
>
|
||||
<Icon id='chevron-right' icon={ChevronRightIcon} />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...bind()}
|
||||
className='modal-root__modal media-modal'
|
||||
ref={handleRef}
|
||||
>
|
||||
<animated.div
|
||||
style={wrapperStyles}
|
||||
className='media-modal__closer'
|
||||
role='presentation'
|
||||
onClick={onClose}
|
||||
>
|
||||
{content}
|
||||
</animated.div>
|
||||
|
||||
<div
|
||||
className={classNames('media-modal__navigation', {
|
||||
'media-modal__navigation--hidden': navigationHidden,
|
||||
})}
|
||||
>
|
||||
<div className='media-modal__buttons'>
|
||||
{zoomable && (
|
||||
<IconButton
|
||||
title={intl.formatMessage(
|
||||
zoomedIn ? messages.zoomOut : messages.zoomIn,
|
||||
)}
|
||||
icon=''
|
||||
iconComponent={zoomedIn ? FitScreenIcon : ActualSizeIcon}
|
||||
onClick={handleZoomClick}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.close)}
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{leftNav}
|
||||
{rightNav}
|
||||
|
||||
<div className='media-modal__overlay'>
|
||||
<MediaPagination
|
||||
itemsCount={media.size}
|
||||
index={index}
|
||||
onChangeIndex={handleChangeIndex}
|
||||
/>
|
||||
{statusId && (
|
||||
<Footer statusId={statusId} withOpenButton onClose={onClose} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
MediaModal.displayName = 'MediaModal';
|
||||
|
||||
interface MediaPaginationProps {
|
||||
itemsCount: number;
|
||||
index: number;
|
||||
onChangeIndex: (newIndex: number) => void;
|
||||
}
|
||||
|
||||
const MediaPagination: FC<MediaPaginationProps> = ({
|
||||
itemsCount,
|
||||
index,
|
||||
onChangeIndex,
|
||||
}) => {
|
||||
const handleChangeIndex = useCallback(
|
||||
(curIndex: number) => {
|
||||
return () => {
|
||||
onChangeIndex(curIndex);
|
||||
};
|
||||
},
|
||||
[onChangeIndex],
|
||||
);
|
||||
|
||||
if (itemsCount <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className='media-modal__pagination'>
|
||||
{Array.from({ length: itemsCount }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={classNames('media-modal__page-dot', {
|
||||
active: i === index,
|
||||
})}
|
||||
onClick={handleChangeIndex(i)}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
@@ -43,10 +43,11 @@ import {
|
||||
QuietPostQuoteInfoModal,
|
||||
} from './confirmation_modals';
|
||||
import { ImageModal } from './image_modal';
|
||||
import MediaModal from './media_modal';
|
||||
import { MediaModal } from './media_modal';
|
||||
import { ModalPlaceholder } from './modal_placeholder';
|
||||
import VideoModal from './video_modal';
|
||||
import { VisibilityModal } from './visibility_modal';
|
||||
import { PrivateQuoteNotify } from './confirmation_modals/private_quote_notify';
|
||||
|
||||
export const MODAL_COMPONENTS = {
|
||||
'MEDIA': () => Promise.resolve({ default: MediaModal }),
|
||||
@@ -66,6 +67,7 @@ export const MODAL_COMPONENTS = {
|
||||
'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }),
|
||||
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }),
|
||||
'CONFIRM_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }),
|
||||
'CONFIRM_PRIVATE_QUOTE_NOTIFY': () => Promise.resolve({ default: PrivateQuoteNotify }),
|
||||
'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }),
|
||||
'CONFIRM_QUIET_QUOTE': () => Promise.resolve({ default: QuietPostQuoteInfoModal }),
|
||||
'MUTE': MuteModal,
|
||||
|
||||
@@ -128,9 +128,12 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
||||
const disableVisibility = !!statusId;
|
||||
const disableQuotePolicy =
|
||||
visibility === 'private' || visibility === 'direct';
|
||||
const disablePublicVisibilities: boolean = useAppSelector(
|
||||
const disablePublicVisibilities = useAppSelector(
|
||||
selectDisablePublicVisibilities,
|
||||
);
|
||||
const isQuotePost = useAppSelector(
|
||||
(state) => state.compose.get('quoted_status_id') !== null,
|
||||
);
|
||||
|
||||
const visibilityItems = useMemo<SelectItem<StatusVisibility>[]>(() => {
|
||||
const items: SelectItem<StatusVisibility>[] = [
|
||||
@@ -315,6 +318,21 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
||||
id={quoteDescriptionId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isQuotePost && visibility === 'direct' && (
|
||||
<div className='visibility-modal__quote-warning'>
|
||||
<FormattedMessage
|
||||
id='visibility_modal.direct_quote_warning.title'
|
||||
defaultMessage="Quotes can't be embedded in private mentions"
|
||||
tagName='h3'
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='visibility_modal.direct_quote_warning.text'
|
||||
defaultMessage='If you save the current settings, the embedded quote will be converted to a link.'
|
||||
tagName='p'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='dialog-modal__content__actions'>
|
||||
<Button onClick={onClose} secondary>
|
||||
|
||||
@@ -35,7 +35,7 @@ interface InitialStateMeta {
|
||||
streaming_api_base_url: string;
|
||||
local_live_feed_access: 'public' | 'authenticated' | 'disabled';
|
||||
remote_live_feed_access: 'public' | 'authenticated' | 'disabled';
|
||||
local_topic_feed_access: 'public' | 'authenticated' | 'disabled';
|
||||
local_topic_feed_access: 'public' | 'authenticated';
|
||||
remote_topic_feed_access: 'public' | 'authenticated' | 'disabled';
|
||||
title: string;
|
||||
show_trends: boolean;
|
||||
|
||||
@@ -157,6 +157,8 @@
|
||||
"bundle_modal_error.close": "Cau",
|
||||
"bundle_modal_error.message": "Aeth rhywbeth o'i le wrth lwytho'r sgrin hon.",
|
||||
"bundle_modal_error.retry": "Ceisiwch eto",
|
||||
"carousel.current": "<sr>Sleid</sr> {current, number} / {max, number}",
|
||||
"carousel.slide": "Sleid {current, number} o {max, number}",
|
||||
"closed_registrations.other_server_instructions": "Gan fod Mastodon yn ddatganoledig, gallwch greu cyfrif ar weinydd arall a dal i ryngweithio gyda hwn.",
|
||||
"closed_registrations_modal.description": "Ar hyn o bryd nid yw'n bosib creu cyfrif ar {domain}, ond cadwch mewn cof nad oes raid i chi gael cyfrif yn benodol ar {domain} i ddefnyddio Mastodon.",
|
||||
"closed_registrations_modal.find_another_server": "Dod o hyd i weinydd arall",
|
||||
@@ -173,6 +175,8 @@
|
||||
"column.edit_list": "Golygu rhestr",
|
||||
"column.favourites": "Ffefrynnau",
|
||||
"column.firehose": "Ffrydiau byw",
|
||||
"column.firehose_local": "Ffrwd fyw ar gyfer y gweinydd hwn",
|
||||
"column.firehose_singular": "Ffrwd fyw",
|
||||
"column.follow_requests": "Ceisiadau dilyn",
|
||||
"column.home": "Cartref",
|
||||
"column.list_members": "Rheoli aelodau rhestr",
|
||||
@@ -192,6 +196,7 @@
|
||||
"community.column_settings.local_only": "Lleol yn unig",
|
||||
"community.column_settings.media_only": "Cyfryngau yn unig",
|
||||
"community.column_settings.remote_only": "Pell yn unig",
|
||||
"compose.error.blank_post": "Gall postiad ddim bod yn wag.",
|
||||
"compose.language.change": "Newid iaith",
|
||||
"compose.language.search": "Chwilio ieithoedd...",
|
||||
"compose.published.body": "Postiad wedi ei gyhoeddi.",
|
||||
@@ -358,7 +363,9 @@
|
||||
"explore.trending_links": "Newyddion",
|
||||
"explore.trending_statuses": "Postiadau",
|
||||
"explore.trending_tags": "Hashnodau",
|
||||
"featured_carousel.current": "<sr>Postiad</sr> {current, number} / {max, number}",
|
||||
"featured_carousel.header": "{count, plural, one {Postiad wedi'i binio} other {Postiadau wedi'u pinio}}",
|
||||
"featured_carousel.slide": "Postiad {current, number} of {max, number}",
|
||||
"filter_modal.added.context_mismatch_explanation": "Dyw'r categori hidlo hwn ddim yn berthnasol i'r cyd-destun yr ydych wedi cyrchu'r postiad hwn ynddo. Os ydych chi am i'r postiad gael ei hidlo yn y cyd-destun hwn hefyd, bydd yn rhaid i chi olygu'r hidlydd.",
|
||||
"filter_modal.added.context_mismatch_title": "Diffyg cyfatebiaeth cyd-destun!",
|
||||
"filter_modal.added.expired_explanation": "Mae'r categori hidlydd hwn wedi dod i ben, bydd angen i chi newid y dyddiad dod i ben er mwyn iddo fod yn berthnasol.",
|
||||
@@ -401,6 +408,7 @@
|
||||
"follow_suggestions.who_to_follow": "Pwy i ddilyn",
|
||||
"followed_tags": "Hashnodau rydych yn eu dilyn",
|
||||
"footer.about": "Ynghylch",
|
||||
"footer.about_this_server": "Ynghylch",
|
||||
"footer.directory": "Cyfeiriadur proffiliau",
|
||||
"footer.get_app": "Llwytho'r ap i lawr",
|
||||
"footer.keyboard_shortcuts": "Bysellau brys",
|
||||
@@ -905,9 +913,12 @@
|
||||
"status.pin": "Pinio ar y proffil",
|
||||
"status.quote": "Dyfynnu",
|
||||
"status.quote.cancel": "Diddymu'r dyfyniad",
|
||||
"status.quote_error.blocked_account_hint.title": "Mae'r postiad hwn wedi'i guddio oherwydd eich bod wedi rhwystro @{name}.",
|
||||
"status.quote_error.blocked_domain_hint.title": "Mae'r postiad hwn wedi'i guddio oherwydd eich bod wedi rhwystro {domain}.",
|
||||
"status.quote_error.filtered": "Wedi'i guddio oherwydd un o'ch hidlwyr",
|
||||
"status.quote_error.limited_account_hint.action": "Dangos beth bynnag",
|
||||
"status.quote_error.limited_account_hint.title": "Mae'r cyfrif hwn wedi'i guddio gan gymedrolwyr {domain}.",
|
||||
"status.quote_error.muted_account_hint.title": "Mae'r postiad hwn wedi'i guddio oherwydd eich bod wedi mudo @{name}.",
|
||||
"status.quote_error.not_available": "Postiad ddim ar gael",
|
||||
"status.quote_error.pending_approval": "Postiad yn yr arfaeth",
|
||||
"status.quote_error.pending_approval_popout.body": "Ar Mastodon, gallwch reoli os yw rhywun yn gallu eich dyfynnu. Mae'r postiad hwn yn cael ei ddal nôl tra'n bod yn cael cymeradwyaeth yr awdur gwreiddiol.",
|
||||
|
||||
@@ -284,7 +284,7 @@
|
||||
"directory.recently_active": "Aktive for nyligt",
|
||||
"disabled_account_banner.account_settings": "Kontoindstillinger",
|
||||
"disabled_account_banner.text": "Din konto {disabledAccount} er pt. deaktiveret.",
|
||||
"dismissable_banner.community_timeline": "Disse er de seneste offentlige indlæg fra personer med konti hostet af {domain}.",
|
||||
"dismissable_banner.community_timeline": "Dette er de seneste offentlige indlæg fra personer med konti hostet af {domain}.",
|
||||
"dismissable_banner.dismiss": "Afvis",
|
||||
"dismissable_banner.public_timeline": "Dette er de seneste offentlige indlæg fra personer på fediverset, som folk på {domain} følger.",
|
||||
"domain_block_modal.block": "Blokér server",
|
||||
|
||||
@@ -249,6 +249,11 @@
|
||||
"confirmations.missing_alt_text.secondary": "Post anyway",
|
||||
"confirmations.missing_alt_text.title": "Add alt text?",
|
||||
"confirmations.mute.confirm": "Mute",
|
||||
"confirmations.private_quote_notify.cancel": "Back to editing",
|
||||
"confirmations.private_quote_notify.confirm": "Publish post",
|
||||
"confirmations.private_quote_notify.do_not_show_again": "Don't show me this message again",
|
||||
"confirmations.private_quote_notify.message": "The person you are quoting and other mentions will be notified and will be able to view your post, even if they're not following you.",
|
||||
"confirmations.private_quote_notify.title": "Share with followers and mentioned users?",
|
||||
"confirmations.quiet_post_quote_info.dismiss": "Don't remind me again",
|
||||
"confirmations.quiet_post_quote_info.got_it": "Got it",
|
||||
"confirmations.quiet_post_quote_info.message": "When quoting a quiet public post, your post will be hidden from trending timelines.",
|
||||
@@ -760,6 +765,7 @@
|
||||
"privacy_policy.title": "Privacy Policy",
|
||||
"quote_error.edit": "Quotes cannot be added when editing a post.",
|
||||
"quote_error.poll": "Quoting is not allowed with polls.",
|
||||
"quote_error.private_mentions": "Quoting is not allowed with direct mentions.",
|
||||
"quote_error.quote": "Only one quote at a time is allowed.",
|
||||
"quote_error.unauthorized": "You are not authorized to quote this post.",
|
||||
"quote_error.upload": "Quoting is not allowed with media attachments.",
|
||||
@@ -1013,6 +1019,8 @@
|
||||
"video.volume_down": "Volume down",
|
||||
"video.volume_up": "Volume up",
|
||||
"visibility_modal.button_title": "Set visibility",
|
||||
"visibility_modal.direct_quote_warning.text": "If you save the current settings, the embedded quote will be converted to a link.",
|
||||
"visibility_modal.direct_quote_warning.title": "Quotes can't be embedded in private mentions",
|
||||
"visibility_modal.header": "Visibility and interaction",
|
||||
"visibility_modal.helper.direct_quoting": "Private mentions authored on Mastodon can't be quoted by others.",
|
||||
"visibility_modal.helper.privacy_editing": "Visibility can't be changed after a post is published.",
|
||||
|
||||
@@ -743,7 +743,7 @@
|
||||
"poll.votes": "{votes, plural, one {# stem} other {# stemmen}}",
|
||||
"poll_button.add_poll": "Peiling toevoegen",
|
||||
"poll_button.remove_poll": "Peiling verwijderen",
|
||||
"privacy.change": "Privacy voor een bericht aanpassen",
|
||||
"privacy.change": "Privacy van dit bericht aanpassen",
|
||||
"privacy.direct.long": "Alleen voor mensen die specifiek in het bericht worden vermeld",
|
||||
"privacy.direct.short": "Privébericht",
|
||||
"privacy.private.long": "Alleen jouw volgers",
|
||||
|
||||
@@ -192,6 +192,7 @@
|
||||
"community.column_settings.local_only": "Vetëm vendore",
|
||||
"community.column_settings.media_only": "Vetëm Media",
|
||||
"community.column_settings.remote_only": "Vetëm të largëta",
|
||||
"compose.error.blank_post": "Postimi s’mund të jetë i zbrazët.",
|
||||
"compose.language.change": "Ndryshoni gjuhën",
|
||||
"compose.language.search": "Kërkoni te gjuhët…",
|
||||
"compose.published.body": "Postimi u botua.",
|
||||
@@ -403,6 +404,7 @@
|
||||
"follow_suggestions.who_to_follow": "Cilët të ndiqen",
|
||||
"followed_tags": "Hashtag-ë të ndjekur",
|
||||
"footer.about": "Mbi",
|
||||
"footer.about_this_server": "Mbi",
|
||||
"footer.directory": "Drejtori profilesh",
|
||||
"footer.get_app": "Merreni aplikacionin",
|
||||
"footer.keyboard_shortcuts": "Shkurtore tastiere",
|
||||
|
||||
@@ -606,8 +606,8 @@
|
||||
"notification.annual_report.view": "檢視 #Wrapstodon",
|
||||
"notification.favourite": "{name} 已將您的嘟文加入最愛",
|
||||
"notification.favourite.name_and_others_with_link": "{name} 與<a>{count, plural, other {其他 # 個人}}</a>已將您的嘟文加入最愛",
|
||||
"notification.favourite_pm": "{name} 將您的私人提及加入最愛",
|
||||
"notification.favourite_pm.name_and_others_with_link": "{name} 與<a>{count, plural, other {其他 # 個人}}</a>已將您的私人提及加入最愛",
|
||||
"notification.favourite_pm": "{name} 將您的私訊加入最愛",
|
||||
"notification.favourite_pm.name_and_others_with_link": "{name} 與<a>{count, plural, other {其他 # 個人}}</a>已將您的私訊加入最愛",
|
||||
"notification.follow": "{name} 已跟隨您",
|
||||
"notification.follow.name_and_others": "{name} 與<a>{count, plural, other {其他 # 個人}}</a>已跟隨您",
|
||||
"notification.follow_request": "{name} 要求跟隨您",
|
||||
@@ -1014,7 +1014,7 @@
|
||||
"video.volume_up": "提高音量",
|
||||
"visibility_modal.button_title": "設定可見性",
|
||||
"visibility_modal.header": "可見性與互動",
|
||||
"visibility_modal.helper.direct_quoting": "Mastodon 上發佈之私人提及嘟文無法被其他使用者引用。",
|
||||
"visibility_modal.helper.direct_quoting": "Mastodon 上發佈之私訊嘟文無法被其他使用者引用。",
|
||||
"visibility_modal.helper.privacy_editing": "嘟文發布後無法變更可見性。",
|
||||
"visibility_modal.helper.privacy_private_self_quote": "自我引用之私嘟無法設為公開可見。",
|
||||
"visibility_modal.helper.private_quoting": "Mastodon 上發佈之僅限跟隨者嘟文無法被其他使用者引用。",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
|
||||
|
||||
import {
|
||||
changeComposeVisibility,
|
||||
changeUploadCompose,
|
||||
quoteCompose,
|
||||
quoteComposeCancel,
|
||||
setComposeQuotePolicy,
|
||||
} from 'mastodon/actions/compose_typed';
|
||||
pasteLinkCompose,
|
||||
cancelPasteLinkCompose,
|
||||
} from '@/mastodon/actions/compose_typed';
|
||||
import { timelineDelete } from 'mastodon/actions/timelines_typed';
|
||||
|
||||
import {
|
||||
@@ -38,7 +41,6 @@ import {
|
||||
COMPOSE_SENSITIVITY_CHANGE,
|
||||
COMPOSE_SPOILERNESS_CHANGE,
|
||||
COMPOSE_SPOILER_TEXT_CHANGE,
|
||||
COMPOSE_VISIBILITY_CHANGE,
|
||||
COMPOSE_LANGUAGE_CHANGE,
|
||||
COMPOSE_COMPOSING_CHANGE,
|
||||
COMPOSE_EMOJI_INSERT,
|
||||
@@ -93,6 +95,7 @@ const initialState = ImmutableMap({
|
||||
quoted_status_id: null,
|
||||
quote_policy: 'public',
|
||||
default_quote_policy: 'public', // Set in hydration.
|
||||
fetching_link: null,
|
||||
});
|
||||
|
||||
const initialPoll = ImmutableMap({
|
||||
@@ -315,7 +318,11 @@ const calculateProgress = (loaded, total) => Math.min(Math.round((loaded / total
|
||||
|
||||
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
|
||||
export const composeReducer = (state = initialState, action) => {
|
||||
if (changeUploadCompose.fulfilled.match(action)) {
|
||||
if (changeComposeVisibility.match(action)) {
|
||||
return state
|
||||
.set('privacy', action.payload)
|
||||
.set('idempotencyKey', uuid());
|
||||
} else if (changeUploadCompose.fulfilled.match(action)) {
|
||||
return state
|
||||
.set('is_changing_upload', false)
|
||||
.update('media_attachments', list => list.map(item => {
|
||||
@@ -331,15 +338,27 @@ export const composeReducer = (state = initialState, action) => {
|
||||
return state.set('is_changing_upload', false);
|
||||
} else if (quoteCompose.match(action)) {
|
||||
const status = action.payload;
|
||||
const isDirect = state.get('privacy') === 'direct';
|
||||
return state
|
||||
.set('quoted_status_id', status.get('id'))
|
||||
.set('quoted_status_id', isDirect ? null : status.get('id'))
|
||||
.set('spoiler', status.get('sensitive'))
|
||||
.set('spoiler_text', status.get('spoiler_text'))
|
||||
.update('privacy', (visibility) => ['public', 'unlisted'].includes(visibility) && status.get('visibility') === 'private' ? 'private' : visibility);
|
||||
.update('privacy', (visibility) => {
|
||||
if (['public', 'unlisted'].includes(visibility) && status.get('visibility') === 'private') {
|
||||
return 'private';
|
||||
}
|
||||
return visibility;
|
||||
});
|
||||
} else if (quoteComposeCancel.match(action)) {
|
||||
return state.set('quoted_status_id', null);
|
||||
} else if (setComposeQuotePolicy.match(action)) {
|
||||
return state.set('quote_policy', action.payload);
|
||||
} else if (pasteLinkCompose.pending.match(action)) {
|
||||
return state.set('fetching_link', action.meta.requestId);
|
||||
} else if (pasteLinkCompose.fulfilled.match(action) || pasteLinkCompose.rejected.match(action)) {
|
||||
return action.meta.requestId === state.get('fetching_link') ? state.set('fetching_link', null) : state;
|
||||
} else if (cancelPasteLinkCompose.match(action)) {
|
||||
return state.set('fetching_link', null);
|
||||
}
|
||||
|
||||
switch(action.type) {
|
||||
@@ -383,10 +402,6 @@ export const composeReducer = (state = initialState, action) => {
|
||||
return state
|
||||
.set('spoiler_text', action.text)
|
||||
.set('idempotencyKey', uuid());
|
||||
case COMPOSE_VISIBILITY_CHANGE:
|
||||
return state
|
||||
.set('privacy', action.value)
|
||||
.set('idempotencyKey', uuid());
|
||||
case COMPOSE_CHANGE:
|
||||
return state
|
||||
.set('text', action.text)
|
||||
|
||||
@@ -42,7 +42,7 @@ interface AppThunkConfig {
|
||||
}
|
||||
export type AppThunkApi = Pick<
|
||||
GetThunkAPI<AppThunkConfig>,
|
||||
'getState' | 'dispatch'
|
||||
'getState' | 'dispatch' | 'requestId'
|
||||
>;
|
||||
|
||||
interface AppThunkOptions<Arg> {
|
||||
@@ -60,7 +60,7 @@ type AppThunk<Arg = void, Returned = void> = (
|
||||
|
||||
type AppThunkCreator<Arg = void, Returned = void, ExtraArg = unknown> = (
|
||||
arg: Arg,
|
||||
api: AppThunkApi,
|
||||
api: Pick<AppThunkApi, 'getState' | 'dispatch'>,
|
||||
extra?: ExtraArg,
|
||||
) => Returned;
|
||||
|
||||
@@ -143,10 +143,10 @@ export function createAsyncThunk<Arg = void, Returned = void>(
|
||||
name,
|
||||
async (
|
||||
arg: Arg,
|
||||
{ getState, dispatch, fulfillWithValue, rejectWithValue },
|
||||
{ getState, dispatch, requestId, fulfillWithValue, rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const result = await creator(arg, { dispatch, getState });
|
||||
const result = await creator(arg, { dispatch, getState, requestId });
|
||||
|
||||
return fulfillWithValue(result, {
|
||||
useLoadingBar: options.useLoadingBar,
|
||||
@@ -280,10 +280,11 @@ export function createDataLoadingThunk<
|
||||
|
||||
return createAsyncThunk<Args, Returned>(
|
||||
name,
|
||||
async (arg, { getState, dispatch }) => {
|
||||
async (arg, { getState, dispatch, requestId }) => {
|
||||
const data = await loadData(arg, {
|
||||
dispatch,
|
||||
getState,
|
||||
requestId,
|
||||
});
|
||||
|
||||
if (!onData) return data as Returned;
|
||||
@@ -291,6 +292,7 @@ export function createDataLoadingThunk<
|
||||
const result = await onData(data, {
|
||||
dispatch,
|
||||
getState,
|
||||
requestId,
|
||||
discardLoadData: discardLoadDataInPayload,
|
||||
actionArg: arg,
|
||||
});
|
||||
|
||||
@@ -1325,6 +1325,10 @@ a.sparkline {
|
||||
line-height: 1;
|
||||
width: 100%;
|
||||
animation: skeleton 1.2s ease-in-out infinite;
|
||||
|
||||
.reduce-motion & {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
|
||||
@@ -2685,6 +2685,7 @@ a.account__display-name {
|
||||
outline-offset: -1px;
|
||||
border-radius: 8px;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&--zoomed-in {
|
||||
@@ -5743,6 +5744,34 @@ a.status-card {
|
||||
}
|
||||
}
|
||||
|
||||
.visibility-modal {
|
||||
&__quote-warning {
|
||||
color: var(--nested-card-text);
|
||||
background:
|
||||
/* This is a bit of a silly hack for layering two background colours
|
||||
* since --nested-card-background is too transparent for a tooltip */
|
||||
linear-gradient(
|
||||
var(--nested-card-background),
|
||||
var(--nested-card-background)
|
||||
),
|
||||
linear-gradient(var(--background-color), var(--background-color));
|
||||
border: var(--nested-card-border);
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
|
||||
h3 {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: $darker-text-color;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.8em;
|
||||
color: $dark-text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.visibility-dropdown {
|
||||
&__overlay[data-popper-placement] {
|
||||
z-index: 9999;
|
||||
@@ -6096,6 +6125,7 @@ a.status-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
touch-action: pan-y;
|
||||
|
||||
&__buttons {
|
||||
position: absolute;
|
||||
@@ -6131,11 +6161,17 @@ a.status-card {
|
||||
}
|
||||
|
||||
.media-modal__closer {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
bottom: 0;
|
||||
|
||||
> div {
|
||||
flex-shrink: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.media-modal__navigation {
|
||||
@@ -8936,6 +8972,10 @@ noscript {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.layout-multiple-columns & {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: (124px + 300px)) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user