diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx
index cf6fe86c3d..3f3cb7406e 100644
--- a/app/javascript/mastodon/components/status.jsx
+++ b/app/javascript/mastodon/components/status.jsx
@@ -34,7 +34,7 @@ import { DisplayName } from './display_name';
import { getHashtagBarForStatus } from './hashtag_bar';
import { RelativeTimestamp } from './relative_timestamp';
import StatusActionBar from './status_action_bar';
-import StatusContent from './status_content';
+import { StatusContent } from './status_content';
import { StatusThreadLabel } from './status_thread_label';
import { VisibilityIcon } from './visibility_icon';
diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx
deleted file mode 100644
index 6b06b938de..0000000000
--- a/app/javascript/mastodon/components/status_content.jsx
+++ /dev/null
@@ -1,278 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import { FormattedMessage, injectIntl } from 'react-intl';
-
-import classnames from 'classnames';
-import { withRouter } from 'react-router-dom';
-
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { connect } from 'react-redux';
-
-import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
-import { Icon } from 'mastodon/components/icon';
-import PollContainer from 'mastodon/containers/poll_container';
-import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
-import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
-
-const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
-
-/**
- *
- * @param {any} status
- * @returns {string}
- */
-export function getStatusContent(status) {
- return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
-}
-
-class TranslateButton extends PureComponent {
-
- static propTypes = {
- translation: ImmutablePropTypes.map,
- onClick: PropTypes.func,
- };
-
- render () {
- const { translation, onClick } = this.props;
-
- if (translation) {
- const language = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language'));
- const languageName = language ? language[2] : translation.get('detected_source_language');
- const provider = translation.get('provider');
-
- return (
-
- );
- }
-
- return (
-
- );
- }
-
-}
-
-const mapStateToProps = state => ({
- languages: state.getIn(['server', 'translationLanguages', 'items']),
-});
-
-class StatusContent extends PureComponent {
- static propTypes = {
- identity: identityContextPropShape,
- status: ImmutablePropTypes.map.isRequired,
- statusContent: PropTypes.string,
- onTranslate: PropTypes.func,
- onClick: PropTypes.func,
- collapsible: PropTypes.bool,
- onCollapsedToggle: PropTypes.func,
- languages: ImmutablePropTypes.map,
- intl: PropTypes.object,
- // from react-router
- match: PropTypes.object.isRequired,
- location: PropTypes.object.isRequired,
- history: PropTypes.object.isRequired
- };
-
- _updateStatusLinks () {
- const node = this.node;
-
- if (!node) {
- return;
- }
-
- const { status, onCollapsedToggle } = this.props;
- const links = node.querySelectorAll('a');
-
- let link, mention;
-
- for (var i = 0; i < links.length; ++i) {
- link = links[i];
-
- if (link.classList.contains('status-link')) {
- continue;
- }
-
- link.classList.add('status-link');
-
- mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
-
- if (mention) {
- link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
- link.setAttribute('title', `@${mention.get('acct')}`);
- link.setAttribute('href', `/@${mention.get('acct')}`);
- link.setAttribute('data-hover-card-account', mention.get('id'));
- } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
- link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
- link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
- } else {
- link.setAttribute('title', link.href);
- link.classList.add('unhandled-link');
- }
- }
-
- if (status.get('collapsed', null) === null && onCollapsedToggle) {
- const { collapsible, onClick } = this.props;
-
- const collapsed =
- collapsible
- && onClick
- && node.clientHeight > MAX_HEIGHT
- && status.get('spoiler_text').length === 0;
-
- onCollapsedToggle(collapsed);
- }
- }
-
- handleMouseEnter = ({ currentTarget }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
- for (var i = 0; i < emojis.length; i++) {
- let emoji = emojis[i];
- emoji.src = emoji.getAttribute('data-original');
- }
- };
-
- handleMouseLeave = ({ currentTarget }) => {
- if (autoPlayGif) {
- return;
- }
-
- const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
- for (var i = 0; i < emojis.length; i++) {
- let emoji = emojis[i];
- emoji.src = emoji.getAttribute('data-static');
- }
- };
-
- componentDidMount () {
- this._updateStatusLinks();
- }
-
- componentDidUpdate () {
- this._updateStatusLinks();
- }
-
- onMentionClick = (mention, e) => {
- if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
- e.preventDefault();
- this.props.history.push(`/@${mention.get('acct')}`);
- }
- };
-
- onHashtagClick = (hashtag, e) => {
- hashtag = hashtag.replace(/^#/, '');
-
- if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
- e.preventDefault();
- this.props.history.push(`/tags/${hashtag}`);
- }
- };
-
- handleMouseDown = (e) => {
- this.startXY = [e.clientX, e.clientY];
- };
-
- handleMouseUp = (e) => {
- if (!this.startXY) {
- return;
- }
-
- const [ startX, startY ] = this.startXY;
- const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
-
- let element = e.target;
- while (element) {
- if (element.localName === 'button' || element.localName === 'a' || element.localName === 'label') {
- return;
- }
- element = element.parentNode;
- }
-
- if (deltaX + deltaY < 5 && (e.button === 0 || e.button === 1) && e.detail >= 1 && this.props.onClick) {
- this.props.onClick(e);
- }
-
- this.startXY = null;
- };
-
- handleTranslate = () => {
- this.props.onTranslate();
- };
-
- setRef = (c) => {
- this.node = c;
- };
-
- render () {
- const { status, intl, statusContent } = this.props;
-
- const renderReadMore = this.props.onClick && status.get('collapsed');
- const contentLocale = intl.locale.replace(/[_-].*/, '');
- const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
- const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
-
- const content = { __html: statusContent ?? getStatusContent(status) };
- const language = status.getIn(['translation', 'language']) || status.get('language');
- const classNames = classnames('status__content', {
- 'status__content--with-action': this.props.onClick && this.props.history,
- 'status__content--collapsed': renderReadMore,
- });
-
- const readMoreButton = renderReadMore && (
-
- );
-
- const translateButton = renderTranslate && (
-
- );
-
- const poll = !!status.get('poll') && (
-
- );
-
- if (this.props.onClick) {
- return (
- <>
-
-
-
- {poll}
- {translateButton}
-
-
- {readMoreButton}
- >
- );
- } else {
- return (
-
-
-
- {poll}
- {translateButton}
-
- );
- }
- }
-
-}
-
-export default withRouter(withIdentity(connect(mapStateToProps)(injectIntl(StatusContent))));
diff --git a/app/javascript/mastodon/components/status_content.tsx b/app/javascript/mastodon/components/status_content.tsx
new file mode 100644
index 0000000000..35e690d553
--- /dev/null
+++ b/app/javascript/mastodon/components/status_content.tsx
@@ -0,0 +1,372 @@
+import { useCallback, useRef, useLayoutEffect } from 'react';
+
+import { FormattedMessage, useIntl } from 'react-intl';
+
+import classnames from 'classnames';
+import { useHistory } from 'react-router-dom';
+
+import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
+
+import type { History } from 'history';
+
+import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
+import { Icon } from 'mastodon/components/icon';
+import PollContainer from 'mastodon/containers/poll_container';
+import { useIdentity } from 'mastodon/identity_context';
+import {
+ autoPlayGif,
+ languages as preloadedLanguages,
+} from 'mastodon/initial_state';
+import type { Status, Translation } from 'mastodon/models/status';
+import { useAppSelector } from 'mastodon/store';
+
+
+
+const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
+
+export const getStatusContent = (status: Status): string =>
+ status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
+
+const TranslateButton: React.FC<{
+ translation: ImmutableList;
+ onClick: () => void;
+}> = ({ translation, onClick }) => {
+ if (translation) {
+ const language = preloadedLanguages?.find(
+ (lang) => lang[0] === translation.get('detected_source_language'),
+ );
+ const languageName = language
+ ? language[2]
+ : translation.get('detected_source_language');
+ const provider = translation.get('provider');
+
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+const handleMentionClick = (
+ history: History,
+ mention: string,
+ e: MouseEvent,
+) => {
+ if (history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ history.push(`/@${mention}`);
+ }
+};
+
+const handleHashtagClick = (
+ history: History,
+ hashtag: string,
+ e: MouseEvent,
+) => {
+ hashtag = hashtag.replace(/^#/, '');
+
+ if (history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ history.push(`/tags/${hashtag}`);
+ }
+};
+
+type ClickCoordinates = [number, number];
+
+export const StatusContent: React.FC<{
+ status: Status;
+ statusContent: string;
+ onTranslate?: () => void;
+ onClick?: (arg0?: React.MouseEvent | MouseEvent) => void;
+ onCollapsedToggle?: (arg0: boolean) => void;
+ collapsible?: boolean;
+}> = ({
+ status,
+ statusContent,
+ onTranslate,
+ onClick,
+ collapsible,
+ onCollapsedToggle,
+}) => {
+ const { signedIn } = useIdentity();
+ const history = useHistory();
+ const intl = useIntl();
+ const languages = useAppSelector(
+ (state) =>
+ state.server.getIn(['translationLanguages', 'items']) as ImmutableMap<
+ string,
+ ImmutableList
+ >,
+ );
+ const clickCoordinates = useRef(null);
+ const nodeRef = useRef(null);
+
+ const handleMouseEnter = useCallback(
+ ({ currentTarget }: React.MouseEvent) => {
+ if (autoPlayGif) {
+ return;
+ }
+
+ const emojis =
+ currentTarget.querySelectorAll('.custom-emoji');
+
+ for (const emoji of emojis) {
+ const originalUrl = emoji.getAttribute('data-original');
+
+ if (originalUrl) {
+ emoji.src = originalUrl;
+ }
+ }
+ },
+ [],
+ );
+
+ const handleMouseLeave = useCallback(
+ ({ currentTarget }: React.MouseEvent) => {
+ if (autoPlayGif) {
+ return;
+ }
+
+ const emojis =
+ currentTarget.querySelectorAll('.custom-emoji');
+
+ for (const emoji of emojis) {
+ const staticUrl = emoji.getAttribute('data-static');
+
+ if (staticUrl) {
+ emoji.src = staticUrl;
+ }
+ }
+ },
+ [],
+ );
+
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
+ clickCoordinates.current = [e.clientX, e.clientY];
+ }, []);
+
+ const handleMouseUp = useCallback(
+ (e: React.MouseEvent) => {
+ if (!clickCoordinates.current) {
+ return;
+ }
+
+ const [startX, startY] = clickCoordinates.current;
+ const [deltaX, deltaY] = [
+ Math.abs(e.clientX - startX),
+ Math.abs(e.clientY - startY),
+ ];
+
+ if (!(e.target instanceof HTMLElement)) {
+ return;
+ }
+
+ let element: HTMLElement | null = e.target;
+
+ while (element) {
+ if (
+ element.localName === 'button' ||
+ element.localName === 'a' ||
+ element.localName === 'label'
+ ) {
+ return;
+ }
+
+ if (!(element.parentNode instanceof HTMLElement)) {
+ break;
+ }
+
+ element = element.parentNode;
+ }
+
+ if (
+ deltaX + deltaY < 5 &&
+ (e.button === 0 || e.button === 1) &&
+ e.detail >= 1 &&
+ onClick
+ ) {
+ onClick(e);
+ }
+
+ clickCoordinates.current = null;
+ },
+ [onClick],
+ );
+
+ const handleTranslate = useCallback(() => {
+ onTranslate?.();
+ }, [onTranslate]);
+
+ const mentions = status.get('mentions') as ImmutableList>;
+ const spoilerText = status.get('spoiler_text') as string;
+ const visibility = status.get('visibility') as string;
+ const searchIndex = status.get('search_index') as string;
+ const collapsed = status.get('collapsed') as boolean | undefined;
+
+ useLayoutEffect(() => {
+ const node = nodeRef.current;
+
+ if (!node) {
+ return;
+ }
+
+ const links = node.querySelectorAll('a');
+
+ for (const link of links) {
+ if (link.classList.contains('status-link')) {
+ continue;
+ }
+
+ link.classList.add('status-link');
+
+ const mention = mentions.find((item) => link.href === item.get('url'));
+
+ if (mention) {
+ const acct = mention.get('acct')!;
+ const id = mention.get('id')!;
+
+ link.addEventListener(
+ 'click',
+ handleMentionClick.bind(null, history, acct),
+ false,
+ );
+ link.setAttribute('title', `@${acct}`);
+ link.setAttribute('href', `/@${acct}`);
+ link.setAttribute('data-hover-card-account', id);
+ } else if (
+ link.textContent?.[0] === '#' ||
+ (link.previousSibling?.textContent?.endsWith('#'))
+ ) {
+ link.addEventListener(
+ 'click',
+ handleHashtagClick.bind(null, history, link.text),
+ false,
+ );
+ link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
+ } else {
+ link.setAttribute('title', link.href);
+ link.classList.add('unhandled-link');
+ }
+ }
+
+ if (collapsed && onCollapsedToggle) {
+ const collapsed =
+ !!collapsible &&
+ !!onClick &&
+ node.clientHeight > MAX_HEIGHT &&
+ spoilerText.length === 0;
+
+ onCollapsedToggle(collapsed);
+ }
+ }, [history, mentions, spoilerText, onCollapsedToggle, collapsible, onClick]);
+
+ const renderReadMore = onClick && status.get('collapsed');
+ const contentLocale = intl.locale.replace(/[_-].*/, '');
+ const originalLanguage = (status.get('language') as string) || 'und';
+ const targetLanguages = languages.get(originalLanguage);
+ const renderTranslate =
+ onTranslate &&
+ signedIn &&
+ ['public', 'unlisted'].includes(visibility) &&
+ searchIndex.trim().length > 0 &&
+ targetLanguages?.includes(contentLocale);
+
+ const content = { __html: statusContent ?? getStatusContent(status) };
+ const language =
+ (status.getIn(['translation', 'language']) as string) ?? originalLanguage;
+ const classNames = classnames('status__content', {
+ 'status__content--with-action': onClick && history,
+ 'status__content--collapsed': renderReadMore,
+ });
+
+ const readMoreButton = renderReadMore && (
+
+ );
+
+ const translateButton = renderTranslate && (
+
+ );
+
+ const poll = !!status.get('poll') && (
+
+ );
+
+ if (onClick) {
+ return (
+ <>
+
+
+
+ {poll}
+ {translateButton}
+
+
+ {readMoreButton}
+ >
+ );
+ } else {
+ return (
+
+
+
+ {poll}
+ {translateButton}
+
+ );
+ }
+};
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx
index 0d154db1e1..04a986d7c5 100644
--- a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx
@@ -23,7 +23,7 @@ import AttachmentList from 'mastodon/components/attachment_list';
import AvatarComposite from 'mastodon/components/avatar_composite';
import { IconButton } from 'mastodon/components/icon_button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
-import StatusContent from 'mastodon/components/status_content';
+import { StatusContent } from 'mastodon/components/status_content';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { autoPlayGif } from 'mastodon/initial_state';
import { makeGetStatus } from 'mastodon/selectors';
diff --git a/app/javascript/mastodon/features/report/components/status_check_box.jsx b/app/javascript/mastodon/features/report/components/status_check_box.jsx
index 481ee3e5ed..093a70e796 100644
--- a/app/javascript/mastodon/features/report/components/status_check_box.jsx
+++ b/app/javascript/mastodon/features/report/components/status_check_box.jsx
@@ -7,7 +7,7 @@ import { Avatar } from 'mastodon/components/avatar';
import { DisplayName } from 'mastodon/components/display_name';
import MediaAttachments from 'mastodon/components/media_attachments';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
-import StatusContent from 'mastodon/components/status_content';
+import { StatusContent } from 'mastodon/components/status_content';
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
import Option from './option';
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.tsx b/app/javascript/mastodon/features/status/components/detailed_status.tsx
index deb330b9a0..8c165bd5ee 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.tsx
+++ b/app/javascript/mastodon/features/status/components/detailed_status.tsx
@@ -25,7 +25,7 @@ import { VisibilityIcon } from 'mastodon/components/visibility_icon';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import MediaGallery from '../../../components/media_gallery';
-import StatusContent from '../../../components/status_content';
+import { StatusContent } from '../../../components/status_content';
import Audio from '../../audio';
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import Video from '../../video';
diff --git a/app/javascript/mastodon/models/status.ts b/app/javascript/mastodon/models/status.ts
index 7f9144280c..3cbcb96bc3 100644
--- a/app/javascript/mastodon/models/status.ts
+++ b/app/javascript/mastodon/models/status.ts
@@ -12,3 +12,5 @@ type CardShape = Required;
export type Card = RecordOf;
export type MediaAttachment = Immutable.Map;
+
+export type Translation = Immutable.Map;