From 6be2b59cd364fe629235ae038d1b51d3c4dd87f9 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 7 Dec 2024 03:26:09 +0100 Subject: [PATCH] Refactor `` into TypeScript --- app/javascript/mastodon/components/status.jsx | 2 +- .../mastodon/components/status_content.jsx | 278 ------------- .../mastodon/components/status_content.tsx | 372 ++++++++++++++++++ .../components/conversation.jsx | 2 +- .../report/components/status_check_box.jsx | 2 +- .../status/components/detailed_status.tsx | 2 +- app/javascript/mastodon/models/status.ts | 2 + 7 files changed, 378 insertions(+), 282 deletions(-) delete mode 100644 app/javascript/mastodon/components/status_content.jsx create mode 100644 app/javascript/mastodon/components/status_content.tsx 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;