Merge remote-tracking branch 'parent/main' into upstream-20240913

This commit is contained in:
KMY 2024-09-13 10:31:48 +09:00
commit fc9d27ff91
392 changed files with 3757 additions and 3233 deletions

View file

@ -132,7 +132,7 @@ class LoginForm extends React.PureComponent {
try {
new URL(url);
return true;
} catch(_) {
} catch {
return false;
}
};

View file

@ -1,5 +1,7 @@
import { FormattedMessage } from 'react-intl';
import { isEqual } from 'lodash';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
import { me } from 'mastodon/initial_state';
@ -47,7 +49,7 @@ export const NotificationMention: React.FC<{
status.get('visibility') === 'direct',
status.get('in_reply_to_account_id') === me,
] as const;
});
}, isEqual);
let labelRenderer = mentionLabelRenderer;

View file

@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { isEqual } from 'lodash';
import { useDebouncedCallback } from 'use-debounce';
import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
@ -62,7 +63,7 @@ export const Notifications: React.FC<{
multiColumn?: boolean;
}> = ({ columnId, multiColumn }) => {
const intl = useIntl();
const notifications = useAppSelector(selectNotificationGroups);
const notifications = useAppSelector(selectNotificationGroups, isEqual);
const dispatch = useAppDispatch();
const isLoading = useAppSelector((s) => s.notificationGroups.isLoading);
const hasMore = notifications.at(-1)?.type === 'gap';

View file

@ -10,8 +10,8 @@ import { Link } from 'react-router-dom';
import SwipeableViews from 'react-swipeable-views';
import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react';
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { CopyPasteText } from 'mastodon/components/copy_paste_text';
import { Icon } from 'mastodon/components/icon';
import { me, domain } from 'mastodon/initial_state';
import { useAppSelector } from 'mastodon/store';
@ -20,67 +20,6 @@ const messages = defineMessages({
shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on #Mastodon! Come follow me at {url}' },
});
class CopyPasteText extends PureComponent {
static propTypes = {
value: PropTypes.string,
};
state = {
copied: false,
focused: false,
};
setRef = c => {
this.input = c;
};
handleInputClick = () => {
this.setState({ copied: false });
this.input.focus();
this.input.select();
this.input.setSelectionRange(0, this.props.value.length);
};
handleButtonClick = e => {
e.stopPropagation();
const { value } = this.props;
navigator.clipboard.writeText(value);
this.input.blur();
this.setState({ copied: true });
this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
};
handleFocus = () => {
this.setState({ focused: true });
};
handleBlur = () => {
this.setState({ focused: false });
};
componentWillUnmount () {
if (this.timeout) clearTimeout(this.timeout);
}
render () {
const { value } = this.props;
const { copied, focused } = this.state;
return (
<div className={classNames('copy-paste-text', { copied, focused })} tabIndex='0' role='button' onClick={this.handleInputClick}>
<textarea readOnly value={value} ref={this.setRef} onClick={this.handleInputClick} onFocus={this.handleFocus} onBlur={this.handleBlur} />
<button className='button' onClick={this.handleButtonClick}>
<Icon id='copy' icon={ContentCopyIcon} /> {copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : <FormattedMessage id='copypaste.copy_to_clipboard' defaultMessage='Copy to clipboard' />}
</button>
</div>
);
}
}
class TipCarousel extends PureComponent {
static propTypes = {

View file

@ -0,0 +1,87 @@
/* eslint-disable @typescript-eslint/no-unsafe-return,
@typescript-eslint/no-explicit-any,
@typescript-eslint/no-unsafe-assignment */
import { useEffect, useCallback } from 'react';
import { Provider } from 'react-redux';
import { useRenderSignal } from 'mastodon/../hooks/useRenderSignal';
import { fetchStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
import { hydrateStore } from 'mastodon/actions/store';
import { Router } from 'mastodon/components/router';
import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
import initialState from 'mastodon/initial_state';
import { IntlProvider } from 'mastodon/locales';
import { makeGetStatus, makeGetPictureInPicture } from 'mastodon/selectors';
import { store, useAppSelector, useAppDispatch } from 'mastodon/store';
const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any;
const getPictureInPicture = makeGetPictureInPicture() as unknown as (
arg0: any,
arg1: any,
) => any;
const Embed: React.FC<{ id: string }> = ({ id }) => {
const status = useAppSelector((state) => getStatus(state, { id }));
const pictureInPicture = useAppSelector((state) =>
getPictureInPicture(state, { id }),
);
const domain = useAppSelector((state) => state.meta.get('domain'));
const dispatch = useAppDispatch();
const dispatchRenderSignal = useRenderSignal();
useEffect(() => {
dispatch(fetchStatus(id, false, false));
}, [dispatch, id]);
const handleToggleHidden = useCallback(() => {
dispatch(toggleStatusSpoilers(id));
}, [dispatch, id]);
// This allows us to calculate the correct page height for embeds
if (status) {
dispatchRenderSignal();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const permalink = status?.get('url') as string;
return (
<div className='embed'>
<DetailedStatus
status={status}
domain={domain}
pictureInPicture={pictureInPicture}
onToggleHidden={handleToggleHidden}
withLogo
/>
<a
className='embed__overlay'
href={permalink}
target='_blank'
rel='noreferrer noopener'
aria-label=''
/>
</div>
);
};
export const Status: React.FC<{ id: string }> = ({ id }) => {
useEffect(() => {
if (initialState) {
store.dispatch(hydrateStore(initialState));
}
}, []);
return (
<IntlProvider>
<Provider store={store}>
<Router>
<Embed id={id} />
</Router>
</Provider>
</IntlProvider>
);
};

View file

@ -58,7 +58,7 @@ const messages = defineMessages({
share: { id: 'status.share', defaultMessage: 'Share' },
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
embed: { id: 'status.embed', defaultMessage: 'Embed' },
embed: { id: 'status.embed', defaultMessage: 'Get embed code' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },

View file

@ -1,391 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedDate, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link, withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import { AnimatedNumber } from 'mastodon/components/animated_number';
import { ContentWarning } from 'mastodon/components/content_warning';
import EditedTimestamp from 'mastodon/components/edited_timestamp';
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
import { Icon } from 'mastodon/components/icon';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import { SearchabilityIcon } from 'mastodon/components/searchability_icon';
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
import { enableEmojiReaction, isHideItem } from 'mastodon/initial_state';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import MediaGallery from '../../../components/media_gallery';
import StatusContent from '../../../components/status_content';
import StatusEmojiReactionsBar from '../../../components/status_emoji_reactions_bar';
import CompactedStatusContainer from '../../../containers/compacted_status_container';
import Audio from '../../audio';
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import Video from '../../video';
import Card from './card';
class DetailedStatus extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func.isRequired,
onTranslate: PropTypes.func.isRequired,
measureHeight: PropTypes.bool,
onHeightChange: PropTypes.func,
domain: PropTypes.string.isRequired,
compact: PropTypes.bool,
showMedia: PropTypes.bool,
pictureInPicture: ImmutablePropTypes.contains({
inUse: PropTypes.bool,
available: PropTypes.bool,
}),
onToggleMediaVisibility: PropTypes.func,
onEmojiReact: PropTypes.func,
onUnEmojiReact: PropTypes.func,
...WithRouterPropTypes,
};
state = {
height: null,
};
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.props.history) {
e.preventDefault();
this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
}
e.stopPropagation();
};
handleOpenVideo = (options) => {
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options);
};
handleExpandedToggle = () => {
this.props.onToggleHidden(this.props.status);
};
_measureHeight (heightJustChanged) {
if (this.props.measureHeight && this.node) {
scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
if (this.props.onHeightChange && heightJustChanged) {
this.props.onHeightChange();
}
}
}
setRef = c => {
this.node = c;
this._measureHeight();
};
componentDidUpdate (prevProps, prevState) {
this._measureHeight(prevState.height !== this.state.height);
}
handleModalLink = e => {
e.preventDefault();
let href;
if (e.target.nodeName !== 'A') {
href = e.target.parentNode.href;
} else {
href = e.target.href;
}
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
};
handleTranslate = () => {
const { onTranslate, status } = this.props;
onTranslate(status);
};
_properStatus () {
const { status } = this.props;
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
return status.get('reblog');
} else {
return status;
}
}
getAttachmentAspectRatio () {
const attachments = this._properStatus().get('media_attachments');
if (attachments.getIn([0, 'type']) === 'video') {
return `${attachments.getIn([0, 'meta', 'original', 'width'])} / ${attachments.getIn([0, 'meta', 'original', 'height'])}`;
} else if (attachments.getIn([0, 'type']) === 'audio') {
return '16 / 9';
} else {
return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2';
}
}
render () {
const status = this._properStatus();
const outerStyle = { boxSizing: 'border-box' };
const { compact, pictureInPicture } = this.props;
if (!status) {
return null;
}
let media = '';
let applicationLink = '';
let reblogLink = '';
let favouriteLink = '';
let emojiReactionsLink = '';
let statusReferencesLink = '';
if (this.props.measureHeight) {
outerStyle.height = `${this.state.height}px`;
}
const language = status.getIn(['translation', 'language']) || status.get('language');
if (pictureInPicture.get('inUse')) {
media = <PictureInPicturePlaceholder aspectRatio={this.getAttachmentAspectRatio()} />;
} else if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
media = (
<Audio
src={attachment.get('url')}
alt={description}
lang={language}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
sensitive={status.get('sensitive')}
visible={this.props.showMedia}
blurhash={attachment.get('blurhash')}
height={150}
onToggleVisibility={this.props.onToggleMediaVisibility}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
media = (
<Video
preview={attachment.get('preview_url')}
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
aspectRatio={`${attachment.getIn(['meta', 'original', 'width'])} / ${attachment.getIn(['meta', 'original', 'height'])}`}
blurhash={attachment.get('blurhash')}
src={attachment.get('url')}
alt={description}
lang={language}
width={300}
height={150}
onOpenVideo={this.handleOpenVideo}
sensitive={status.get('sensitive')}
visible={this.props.showMedia}
onToggleVisibility={this.props.onToggleMediaVisibility}
/>
);
} else {
media = (
<MediaGallery
standalone
sensitive={status.get('sensitive')}
media={status.get('media_attachments')}
lang={language}
height={300}
onOpenMedia={this.props.onOpenMedia}
visible={this.props.showMedia}
onToggleVisibility={this.props.onToggleMediaVisibility}
/>
);
}
} else if (status.get('card')) {
media = <Card sensitive={status.get('sensitive') && !status.get('spoiler_text')} onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
}
let emojiReactionsBar = null;
const emojiReactionAvailableServer = !isHideItem('emoji_reaction_unavailable_server') || status.getIn(['account', 'server_features', 'emoji_reaction']);
if (status.get('emoji_reactions')) {
const emojiReactions = status.get('emoji_reactions');
if (emojiReactions.size > 0 && enableEmojiReaction && emojiReactionAvailableServer) {
emojiReactionsBar = <StatusEmojiReactionsBar emojiReactions={emojiReactions} status={status} onEmojiReact={this.props.onEmojiReact} onUnEmojiReact={this.props.onUnEmojiReact} />;
}
}
if (status.get('application')) {
applicationLink = <>·<a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></>;
}
const visibilityLink = <>·<VisibilityIcon visibility={status.get('limited_scope') || status.get('visibility_ex')} /></>;
const searchabilityLink = <>·<SearchabilityIcon searchability={status.get('searchability')} /></>;
if (['private', 'direct'].includes(status.get('visibility_ex'))) {
reblogLink = '';
} else if (this.props.history) {
reblogLink = (
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'>
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
</span>
<FormattedMessage id='status.reblogs' defaultMessage='{count, plural, one {boost} other {boosts}}' values={{ count: status.get('reblogs_count') }} />
</Link>
);
} else {
reblogLink = (
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
</span>
<FormattedMessage id='status.reblogs' defaultMessage='{count, plural, one {boost} other {boosts}}' values={{ count: status.get('reblogs_count') }} />
</a>
);
}
if (this.props.history) {
favouriteLink = (
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`} className='detailed-status__link'>
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('favourites_count')} />
</span>
<FormattedMessage id='status.favourites' defaultMessage='{count, plural, one {favorite} other {favorites}}' values={{ count: status.get('favourites_count') }} />
</Link>
);
} else {
favouriteLink = (
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('favourites_count')} />
</span>
<FormattedMessage id='status.favourites' defaultMessage='{count, plural, one {favorite} other {favorites}}' values={{ count: status.get('favourites_count') }} />
</a>
);
}
if (!enableEmojiReaction || !emojiReactionAvailableServer) {
emojiReactionsLink = null;
} else if (this.props.history) {
emojiReactionsLink = (
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/emoji_reactions`} className='detailed-status__link'>
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('emoji_reactions_count')} />
</span>
<FormattedMessage id='status.emoji_reactions' defaultMessage='{count, plural, one {emoji} other {emojis}}' values={{ count: status.get('emoji_reactions_count') }} />
</Link>
);
} else {
emojiReactionsLink = (
<a href={`/interact/${status.get('id')}?type=emoji_reactions`} className='detailed-status__link' onClick={this.handleModalLink}>
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('emoji_reactions_count')} />
</span>
<FormattedMessage id='status.emoji_reactions' defaultMessage='{count, plural, one {emoji} other {emojis}}' values={{ count: status.get('emoji_reactions_count') }} />
</a>
);
}
if (this.props.history) {
statusReferencesLink = (
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/references`} className='detailed-status__link'>
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('status_referred_by_count')} />
</span>
<FormattedMessage id='status.quotes' defaultMessage='{count, plural, one {quote} other {quotes}}' values={{ count: status.get('status_referred_by_count') }} />
</Link>
);
} else {
statusReferencesLink = (
<a href={`/interact/${status.get('id')}?type=references`} className='detailed-status__link' onClick={this.handleModalLink}>
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('status_referred_by_count')} />
</span>
<FormattedMessage id='status.quotes' defaultMessage='{count, plural, one {quote} other {quotes}}' values={{ count: status.get('status_referred_by_count') }} />
</a>
);
}
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0;
const quote = !this.props.muted && status.get('quote_id') && <CompactedStatusContainer id={status.get('quote_id')} history={this.props.history} />;
return (
<div style={outerStyle}>
<div ref={this.setRef} className={classNames('detailed-status', { compact })}>
{status.get('visibility_ex') === 'direct' && (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='at' icon={AlternateEmailIcon} className='status__prepend-icon' /></div>
<FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
</div>
)}
<a href={`/@${status.getIn(['account', 'acct'])}`} data-hover-card-account={status.getIn(['account', 'id'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={46} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</a>
{status.get('spoiler_text').length > 0 && <ContentWarning text={status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml')} expanded={expanded} onClick={this.handleExpandedToggle} />}
{expanded && (
<>
<StatusContent
status={status}
onTranslate={this.handleTranslate}
{...statusContentProps}
/>
{media}
{hashtagBar}
{quote}
{emojiReactionsBar}
</>
)}
<div className='detailed-status__meta'>
<div className='detailed-status__meta__line'>
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} target='_blank' rel='noopener noreferrer'>
<FormattedDate value={new Date(status.get('created_at'))} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a>
{visibilityLink}
{searchabilityLink}
{applicationLink}
</div>
{status.get('edited_at') && <div className='detailed-status__meta__line'><EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} /></div>}
<div className='detailed-status__meta__line'>
{reblogLink}
{reblogLink && <>·</>}
{favouriteLink}
·
{emojiReactionsLink}
·
{statusReferencesLink}
</div>
</div>
</div>
</div>
);
}
}
export default withRouter(DetailedStatus);

View file

@ -0,0 +1,477 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access,
@typescript-eslint/no-unsafe-call,
@typescript-eslint/no-explicit-any,
@typescript-eslint/no-unsafe-assignment */
import type { CSSProperties } from 'react';
import { useState, useRef, useCallback } from 'react';
import { FormattedDate, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import { AnimatedNumber } from 'mastodon/components/animated_number';
import { ContentWarning } from 'mastodon/components/content_warning';
import EditedTimestamp from 'mastodon/components/edited_timestamp';
import type { StatusLike } from 'mastodon/components/hashtag_bar';
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
import { Icon } from 'mastodon/components/icon';
import { IconLogo } from 'mastodon/components/logo';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import { SearchabilityIcon } from 'mastodon/components/searchability_icon';
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
import { enableEmojiReaction, isHideItem } from 'mastodon/initial_state';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import MediaGallery from '../../../components/media_gallery';
import StatusContent from '../../../components/status_content';
import StatusEmojiReactionsBar from '../../../components/status_emoji_reactions_bar';
import CompactedStatusContainer from '../../../containers/compacted_status_container';
import Audio from '../../audio';
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import Video from '../../video';
import Card from './card';
interface VideoModalOptions {
startTime: number;
autoPlay?: boolean;
defaultVolume: number;
componentIndex: number;
}
export const DetailedStatus: React.FC<{
status: any;
onOpenMedia?: (status: any, index: number, lang: string) => void;
onOpenVideo?: (status: any, lang: string, options: VideoModalOptions) => void;
onTranslate?: (status: any) => void;
measureHeight?: boolean;
onHeightChange?: () => void;
domain: string;
showMedia?: boolean;
withLogo?: boolean;
pictureInPicture: any;
onToggleHidden?: (status: any) => void;
onToggleMediaVisibility?: () => void;
onEmojiReact?: (status: any, name: string) => void;
onUnEmojiReact?: (status: any, name: string) => void;
muted?: boolean;
}> = ({
status,
onOpenMedia,
onOpenVideo,
onTranslate,
measureHeight,
onHeightChange,
domain,
showMedia,
withLogo,
pictureInPicture,
onToggleMediaVisibility,
onToggleHidden,
onEmojiReact,
onUnEmojiReact,
muted,
}) => {
const properStatus = status?.get('reblog') ?? status;
const [height, setHeight] = useState(0);
const nodeRef = useRef<HTMLDivElement>();
const handleOpenVideo = useCallback(
(options: VideoModalOptions) => {
const lang = (status.getIn(['translation', 'language']) ||
status.get('language')) as string;
if (onOpenVideo)
onOpenVideo(status.getIn(['media_attachments', 0]), lang, options);
},
[onOpenVideo, status],
);
const handleExpandedToggle = useCallback(() => {
if (onToggleHidden) onToggleHidden(status);
}, [onToggleHidden, status]);
const _measureHeight = useCallback(
(heightJustChanged?: boolean) => {
if (measureHeight && nodeRef.current) {
scheduleIdleTask(() => {
if (nodeRef.current)
setHeight(Math.ceil(nodeRef.current.scrollHeight) + 1);
});
if (onHeightChange && heightJustChanged) {
onHeightChange();
}
}
},
[onHeightChange, measureHeight, setHeight],
);
const handleRef = useCallback(
(c: HTMLDivElement) => {
nodeRef.current = c;
_measureHeight();
},
[_measureHeight],
);
const handleTranslate = useCallback(() => {
if (onTranslate) onTranslate(status);
}, [onTranslate, status]);
if (!properStatus) {
return null;
}
let media;
let applicationLink;
let reblogLink;
let attachmentAspectRatio;
let emojiReactionsLink;
if (properStatus.get('media_attachments').getIn([0, 'type']) === 'video') {
attachmentAspectRatio = `${properStatus.get('media_attachments').getIn([0, 'meta', 'original', 'width'])} / ${properStatus.get('media_attachments').getIn([0, 'meta', 'original', 'height'])}`;
} else if (
properStatus.get('media_attachments').getIn([0, 'type']) === 'audio'
) {
attachmentAspectRatio = '16 / 9';
} else {
attachmentAspectRatio =
properStatus.get('media_attachments').size === 1 &&
properStatus
.get('media_attachments')
.getIn([0, 'meta', 'small', 'aspect'])
? properStatus
.get('media_attachments')
.getIn([0, 'meta', 'small', 'aspect'])
: '3 / 2';
}
const outerStyle = { boxSizing: 'border-box' } as CSSProperties;
if (measureHeight) {
outerStyle.height = height;
}
const language =
status.getIn(['translation', 'language']) || status.get('language');
if (pictureInPicture.get('inUse')) {
media = <PictureInPicturePlaceholder aspectRatio={attachmentAspectRatio} />;
} else if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
const description =
attachment.getIn(['translation', 'description']) ||
attachment.get('description');
media = (
<Audio
src={attachment.get('url')}
alt={description}
lang={language}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
poster={
attachment.get('preview_url') ||
status.getIn(['account', 'avatar_static'])
}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
sensitive={status.get('sensitive')}
visible={showMedia}
blurhash={attachment.get('blurhash')}
height={150}
onToggleVisibility={onToggleMediaVisibility}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]);
const description =
attachment.getIn(['translation', 'description']) ||
attachment.get('description');
media = (
<Video
preview={attachment.get('preview_url')}
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
aspectRatio={`${attachment.getIn(['meta', 'original', 'width'])} / ${attachment.getIn(['meta', 'original', 'height'])}`}
blurhash={attachment.get('blurhash')}
src={attachment.get('url')}
alt={description}
lang={language}
width={300}
height={150}
onOpenVideo={handleOpenVideo}
sensitive={status.get('sensitive')}
visible={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
);
} else {
media = (
<MediaGallery
standalone
sensitive={status.get('sensitive')}
media={status.get('media_attachments')}
lang={language}
height={300}
onOpenMedia={onOpenMedia}
visible={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
);
}
} else if (status.get('card')) {
media = (
<Card
sensitive={status.get('sensitive') && !status.get('spoiler_text')}
onOpenMedia={onOpenMedia}
card={status.get('card', null)}
/>
);
}
let emojiReactionsBar = null;
const emojiReactionAvailableServer =
!isHideItem('emoji_reaction_unavailable_server') ||
status.getIn(['account', 'server_features', 'emoji_reaction']);
if (status.get('emoji_reactions')) {
const emojiReactions = status.get('emoji_reactions');
if (
emojiReactions.size > 0 &&
enableEmojiReaction &&
emojiReactionAvailableServer
) {
emojiReactionsBar = (
<StatusEmojiReactionsBar
emojiReactions={emojiReactions}
status={status}
onEmojiReact={onEmojiReact}
onUnEmojiReact={onUnEmojiReact}
/>
);
}
}
if (status.get('application')) {
applicationLink = (
<>
·
<a
className='detailed-status__application'
href={status.getIn(['application', 'website'])}
target='_blank'
rel='noopener noreferrer'
>
{status.getIn(['application', 'name'])}
</a>
</>
);
}
const visibilityLink = (
<>
·
<VisibilityIcon
visibility={status.get('limited_scope') || status.get('visibility_ex')}
/>
</>
);
const searchabilityLink = (
<>
·<SearchabilityIcon searchability={status.get('searchability')} />
</>
);
if (!enableEmojiReaction || !emojiReactionAvailableServer) {
emojiReactionsLink = '';
} else {
emojiReactionsLink = (
<Link
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/emoji_reactions`}
className='detailed-status__link'
>
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('emoji_reactions_count')} />
</span>
<FormattedMessage
id='status.emoji_reactions'
defaultMessage='{count, plural, one {favorite} other {favorites}}'
values={{ count: status.get('emoji_reactions_count') }}
/>
</Link>
);
}
const statusReferencesLink = (
<Link
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/references`}
className='detailed-status__link'
>
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('status_referred_by_count')} />
</span>
<FormattedMessage
id='status.quotes'
defaultMessage='{count, plural, one {boost} other {boosts}}'
values={{ count: status.get('status_referred_by_count') }}
/>
</Link>
);
if (['private', 'direct'].includes(status.get('visibility') as string)) {
reblogLink = '';
} else {
reblogLink = (
<Link
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`}
className='detailed-status__link'
>
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
</span>
<FormattedMessage
id='status.reblogs'
defaultMessage='{count, plural, one {boost} other {boosts}}'
values={{ count: status.get('reblogs_count') }}
/>
</Link>
);
}
const favouriteLink = (
<Link
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`}
className='detailed-status__link'
>
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('favourites_count')} />
</span>
<FormattedMessage
id='status.favourites'
defaultMessage='{count, plural, one {favorite} other {favorites}}'
values={{ count: status.get('favourites_count') }}
/>
</Link>
);
const { statusContentProps, hashtagBar } = getHashtagBarForStatus(
status as StatusLike,
);
const expanded =
!status.get('hidden') || status.get('spoiler_text').length === 0;
const quote = !muted && status.get('quote_id') && (
<>
{/* @ts-expect-error: CompactedStatusContainer class is not typescript still. */}
<CompactedStatusContainer id={status.get('quote_id')} history={history} />
</>
);
return (
<div style={outerStyle}>
<div ref={handleRef} className={classNames('detailed-status')}>
{status.get('visibility_ex') === 'direct' && (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'>
<Icon
id='at'
icon={AlternateEmailIcon}
className='status__prepend-icon'
/>
</div>
<FormattedMessage
id='status.direct_indicator'
defaultMessage='Private mention'
/>
</div>
)}
<Link
to={`/@${status.getIn(['account', 'acct'])}`}
data-hover-card-account={status.getIn(['account', 'id'])}
className='detailed-status__display-name'
>
<div className='detailed-status__display-avatar'>
<Avatar account={status.get('account')} size={46} />
</div>
<DisplayName account={status.get('account')} localDomain={domain} />
{withLogo && (
<>
<div className='spacer' />
<IconLogo />
</>
)}
</Link>
{status.get('spoiler_text').length > 0 && (
<ContentWarning
text={
status.getIn(['translation', 'spoilerHtml']) ||
status.get('spoilerHtml')
}
expanded={expanded}
onClick={handleExpandedToggle}
/>
)}
{expanded && (
<>
<StatusContent
status={status}
onTranslate={handleTranslate}
{...(statusContentProps as any)}
/>
{media}
{hashtagBar}
{quote}
{emojiReactionsBar}
</>
)}
<div className='detailed-status__meta'>
<div className='detailed-status__meta__line'>
<a
className='detailed-status__datetime'
href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`}
target='_blank'
rel='noopener noreferrer'
>
<FormattedDate
value={new Date(status.get('created_at') as string)}
year='numeric'
month='short'
day='2-digit'
hour='2-digit'
minute='2-digit'
/>
</a>
{visibilityLink}
{searchabilityLink}
{applicationLink}
</div>
{status.get('edited_at') && (
<div className='detailed-status__meta__line'>
<EditedTimestamp
statusId={status.get('id')}
timestamp={status.get('edited_at')}
/>
</div>
)}
<div className='detailed-status__meta__line'>
{reblogLink}
{reblogLink && <>·</>}
{favouriteLink}·{emojiReactionsLink}·{statusReferencesLink}
</div>
</div>
</div>
</div>
);
};

View file

@ -1,154 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { showAlertForError } from '../../../actions/alerts';
import { initBlockModal } from '../../../actions/blocks';
import {
replyCompose,
mentionCompose,
directCompose,
} from '../../../actions/compose';
import {
toggleReblog,
toggleFavourite,
pin,
unpin,
emojiReact,
unEmojiReact,
} from '../../../actions/interactions';
import { openModal } from '../../../actions/modal';
import { initMuteModal } from '../../../actions/mutes';
import { initReport } from '../../../actions/reports';
import {
muteStatus,
unmuteStatus,
deleteStatus,
toggleStatusSpoilers,
} from '../../../actions/statuses';
import { deleteModal } from '../../../initial_state';
import { makeGetStatus, makeGetPictureInPicture } from '../../../selectors';
import DetailedStatus from '../components/detailed_status';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture();
const mapStateToProps = (state, props) => ({
status: getStatus(state, props),
domain: state.getIn(['meta', 'domain']),
pictureInPicture: getPictureInPicture(state, props),
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch) => ({
onReply (status) {
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal({ modalType: 'CONFIRM_REPLY', modalProps: { status } }));
} else {
dispatch(replyCompose(status));
}
});
},
onReblog (status, e) {
dispatch(toggleReblog(status.get('id'), e.shiftKey));
},
onReblogForceModal (status) {
dispatch(toggleReblog(status.get('id'), true, true));
},
onFavourite (status) {
dispatch(toggleFavourite(status.get('id')));
},
onEmojiReact (status, emoji) {
dispatch(emojiReact(status, emoji));
},
onUnEmojiReact (status, emoji) {
dispatch(unEmojiReact(status, emoji));
},
onPin (status) {
if (status.get('pinned')) {
dispatch(unpin(status));
} else {
dispatch(pin(status));
}
},
onEmbed (status) {
dispatch(openModal({
modalType: 'EMBED',
modalProps: {
id: status.get('id'),
onError: error => dispatch(showAlertForError(error)),
},
}));
},
onDelete (status, withRedraft = false) {
if (!deleteModal) {
dispatch(deleteStatus(status.get('id'), withRedraft));
} else {
dispatch(openModal({ modalType: 'CONFIRM_DELETE_STATUS', modalProps: { statusId: status.get('id'), withRedraft } }));
}
},
onDirect (account) {
dispatch(directCompose(account));
},
onMention (account) {
dispatch(mentionCompose(account));
},
onOpenMedia (media, index, lang) {
dispatch(openModal({
modalType: 'MEDIA',
modalProps: { media, index, lang },
}));
},
onOpenVideo (media, lang, options) {
dispatch(openModal({
modalType: 'VIDEO',
modalProps: { media, lang, options },
}));
},
onBlock (status) {
const account = status.get('account');
dispatch(initBlockModal(account));
},
onReport (status) {
dispatch(initReport(status.get('account'), status));
},
onMute (account) {
dispatch(initMuteModal(account));
},
onMuteConversation (status) {
if (status.get('muted')) {
dispatch(unmuteStatus(status.get('id')));
} else {
dispatch(muteStatus(status.get('id')));
}
},
onToggleHidden (status) {
dispatch(toggleStatusSpoilers(status.get('id')));
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));

View file

@ -72,7 +72,7 @@ import Column from '../ui/components/column';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
import ActionBar from './components/action_bar';
import DetailedStatus from './components/detailed_status';
import { DetailedStatus } from './components/detailed_status';
const messages = defineMessages({

View file

@ -99,7 +99,7 @@ export const BlockModal = ({ accountId, acct }) => {
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
</button>
<Button onClick={handleClick}>
<Button onClick={handleClick} autoFocus>
<FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' />
</Button>
</div>

View file

@ -71,7 +71,10 @@ export const ConfirmationModal: React.FC<
/>
</button>
<Button onClick={handleClick}>{confirm}</Button>
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- we are in a modal and thus autofocusing is justified */}
<Button onClick={handleClick} autoFocus>
{confirm}
</Button>
</div>
</div>
</div>

View file

@ -88,7 +88,7 @@ export const DomainBlockModal = ({ domain, accountId, acct }) => {
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
</button>
<Button onClick={handleClick}>
<Button onClick={handleClick} autoFocus>
<FormattedMessage id='domain_block_modal.block' defaultMessage='Block server' />
</Button>
</div>

View file

@ -1,101 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import api from 'mastodon/api';
import { IconButton } from 'mastodon/components/icon_button';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
class EmbedModal extends ImmutablePureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
loading: false,
oembed: null,
};
componentDidMount () {
const { id } = this.props;
this.setState({ loading: true });
api().get(`/api/web/embeds/${id}`).then(res => {
this.setState({ loading: false, oembed: res.data });
const iframeDocument = this.iframe.contentWindow.document;
iframeDocument.open();
iframeDocument.write(res.data.html);
iframeDocument.close();
iframeDocument.body.style.margin = 0;
this.iframe.width = iframeDocument.body.scrollWidth;
this.iframe.height = iframeDocument.body.scrollHeight;
}).catch(error => {
this.props.onError(error);
});
}
setIframeRef = c => {
this.iframe = c;
};
handleTextareaClick = (e) => {
e.target.select();
};
render () {
const { intl, onClose } = this.props;
const { oembed } = this.state;
return (
<div className='modal-root__modal report-modal embed-modal'>
<div className='report-modal__target'>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={16} />
<FormattedMessage id='status.embed' defaultMessage='Embed' />
</div>
<div className='report-modal__container embed-modal__container' style={{ display: 'block' }}>
<p className='hint'>
<FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' />
</p>
<input
type='text'
className='embed-modal__html'
readOnly
value={oembed && oembed.html || ''}
onClick={this.handleTextareaClick}
/>
<p className='hint'>
<FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' />
</p>
<iframe
className='embed-modal__iframe'
frameBorder='0'
ref={this.setIframeRef}
sandbox='allow-scripts allow-same-origin'
title='preview'
/>
</div>
</div>
);
}
}
export default injectIntl(EmbedModal);

View file

@ -0,0 +1,116 @@
import { useRef, useState, useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { showAlertForError } from 'mastodon/actions/alerts';
import api from 'mastodon/api';
import { Button } from 'mastodon/components/button';
import { CopyPasteText } from 'mastodon/components/copy_paste_text';
import { useAppDispatch } from 'mastodon/store';
interface OEmbedResponse {
html: string;
}
const EmbedModal: React.FC<{
id: string;
onClose: () => void;
}> = ({ id, onClose }) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const intervalRef = useRef<ReturnType<typeof setInterval>>();
const [oembed, setOembed] = useState<OEmbedResponse | null>(null);
const dispatch = useAppDispatch();
useEffect(() => {
api()
.get(`/api/web/embeds/${id}`)
.then((res) => {
const data = res.data as OEmbedResponse;
setOembed(data);
const iframeDocument = iframeRef.current?.contentWindow?.document;
if (!iframeDocument) {
return '';
}
iframeDocument.open();
iframeDocument.write(data.html);
iframeDocument.close();
iframeDocument.body.style.margin = '0px';
// This is our best chance to ensure the parent iframe has the correct height...
intervalRef.current = setInterval(
() =>
window.requestAnimationFrame(() => {
if (iframeRef.current) {
iframeRef.current.width = `${iframeDocument.body.scrollWidth}px`;
iframeRef.current.height = `${iframeDocument.body.scrollHeight}px`;
}
}),
100,
);
return '';
})
.catch((error: unknown) => {
dispatch(showAlertForError(error));
});
}, [dispatch, id, setOembed]);
useEffect(
() => () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
},
[],
);
return (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<Button onClick={onClose}>
<FormattedMessage id='report.close' defaultMessage='Done' />
</Button>
<span className='dialog-modal__header__title'>
<FormattedMessage id='status.embed' defaultMessage='Get embed code' />
</span>
<Button secondary onClick={onClose}>
<FormattedMessage
id='confirmation_modal.cancel'
defaultMessage='Cancel'
/>
</Button>
</div>
<div className='dialog-modal__content'>
<div className='dialog-modal__content__form'>
<FormattedMessage
id='embed.instructions'
defaultMessage='Embed this status on your website by copying the code below.'
/>
<CopyPasteText value={oembed?.html ?? ''} />
<FormattedMessage
id='embed.preview'
defaultMessage='Here is what it will look like:'
/>
<iframe
frameBorder='0'
ref={iframeRef}
sandbox='allow-scripts allow-same-origin'
title='Preview'
/>
</div>
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default EmbedModal;

View file

@ -137,7 +137,7 @@ export const MuteModal = ({ accountId, acct }) => {
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
</button>
<Button onClick={handleClick}>
<Button onClick={handleClick} autoFocus>
<FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' />
</Button>
</div>

View file

@ -3,10 +3,12 @@ import { connect } from 'react-redux';
import { openModal, closeModal } from '../../../actions/modal';
import ModalRoot from '../components/modal_root';
const defaultProps = {};
const mapStateToProps = state => ({
ignoreFocus: state.getIn(['modal', 'ignoreFocus']),
type: state.getIn(['modal', 'stack', 0, 'modalType'], null),
props: state.getIn(['modal', 'stack', 0, 'modalProps'], {}),
props: state.getIn(['modal', 'stack', 0, 'modalProps'], defaultProps),
});
const mapDispatchToProps = dispatch => ({

View file

@ -4,24 +4,11 @@ import { connect } from 'react-redux';
import { NotificationStack } from 'react-notification';
import { dismissAlert } from '../../../actions/alerts';
import { getAlerts } from '../../../selectors';
const formatIfNeeded = (intl, message, values) => {
if (typeof message === 'object') {
return intl.formatMessage(message, values);
}
return message;
};
import { dismissAlert } from 'mastodon/actions/alerts';
import { getAlerts } from 'mastodon/selectors';
const mapStateToProps = (state, { intl }) => ({
notifications: getAlerts(state).map(alert => ({
...alert,
action: formatIfNeeded(intl, alert.action, alert.values),
title: formatIfNeeded(intl, alert.title, alert.values),
message: formatIfNeeded(intl, alert.message, alert.values),
})),
notifications: getAlerts(state, { intl }),
});
const mapDispatchToProps = (dispatch) => ({

View file

@ -349,8 +349,8 @@ class UI extends PureComponent {
try {
e.dataTransfer.dropEffect = 'copy';
} catch (err) {
} catch {
// do nothing
}
return false;