diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index 2d83ae9926..2b6b589b37 100644 --- a/app/javascript/flavours/glitch/actions/statuses.js +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -49,11 +49,13 @@ export function fetchStatusRequest(id, skipLoading) { }; } -export function fetchStatus(id, forceFetch = false) { +export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) { return (dispatch, getState) => { const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null; - dispatch(fetchContext(id)); + if (alsoFetchContext) { + dispatch(fetchContext(id)); + } if (skipLoading) { return; diff --git a/app/javascript/flavours/glitch/components/logo.tsx b/app/javascript/flavours/glitch/components/logo.tsx index b7f8bd6695..fe9680d0e3 100644 --- a/app/javascript/flavours/glitch/components/logo.tsx +++ b/app/javascript/flavours/glitch/components/logo.tsx @@ -7,6 +7,13 @@ export const WordmarkLogo: React.FC = () => ( ); +export const IconLogo: React.FC = () => ( + + Mastodon + + +); + export const SymbolLogo: React.FC = () => ( Mastodon ); diff --git a/app/javascript/flavours/glitch/components/more_from_author.jsx b/app/javascript/flavours/glitch/components/more_from_author.jsx index 4f20ae76bf..f2377b450b 100644 --- a/app/javascript/flavours/glitch/components/more_from_author.jsx +++ b/app/javascript/flavours/glitch/components/more_from_author.jsx @@ -2,14 +2,12 @@ import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; +import { IconLogo } from 'flavours/glitch/components/logo'; import { AuthorLink } from 'flavours/glitch/features/explore/components/author_link'; export const MoreFromAuthor = ({ accountId }) => (
- - - - + }} />
); diff --git a/app/javascript/flavours/glitch/entrypoints/embed.tsx b/app/javascript/flavours/glitch/entrypoints/embed.tsx new file mode 100644 index 0000000000..12eb4a413f --- /dev/null +++ b/app/javascript/flavours/glitch/entrypoints/embed.tsx @@ -0,0 +1,74 @@ +import { createRoot } from 'react-dom/client'; + +import '@/entrypoints/public-path'; + +import { start } from 'flavours/glitch/common'; +import { Status } from 'flavours/glitch/features/standalone/status'; +import { afterInitialRender } from 'flavours/glitch/hooks/useRenderSignal'; +import { loadPolyfills } from 'flavours/glitch/polyfills'; +import ready from 'flavours/glitch/ready'; + +start(); + +function loaded() { + const mountNode = document.getElementById('mastodon-status'); + + if (mountNode) { + const attr = mountNode.getAttribute('data-props'); + + if (!attr) return; + + const props = JSON.parse(attr) as { id: string; locale: string }; + const root = createRoot(mountNode); + + root.render(); + } +} + +function main() { + ready(loaded).catch((error: unknown) => { + console.error(error); + }); +} + +loadPolyfills() + .then(main) + .catch((error: unknown) => { + console.error(error); + }); + +interface SetHeightMessage { + type: 'setHeight'; + id: string; + height: number; +} + +function isSetHeightMessage(data: unknown): data is SetHeightMessage { + if ( + data && + typeof data === 'object' && + 'type' in data && + data.type === 'setHeight' + ) + return true; + else return false; +} + +window.addEventListener('message', (e) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases + if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return; + + const data = e.data; + + // We use a timeout to allow for the React page to render before calculating the height + afterInitialRender(() => { + window.parent.postMessage( + { + type: 'setHeight', + id: data.id, + height: document.getElementsByTagName('html')[0]?.scrollHeight, + }, + '*', + ); + }); +}); diff --git a/app/javascript/flavours/glitch/entrypoints/public.tsx b/app/javascript/flavours/glitch/entrypoints/public.tsx index 44afc9d825..41c0e34396 100644 --- a/app/javascript/flavours/glitch/entrypoints/public.tsx +++ b/app/javascript/flavours/glitch/entrypoints/public.tsx @@ -37,43 +37,6 @@ const messages = defineMessages({ }, }); -interface SetHeightMessage { - type: 'setHeight'; - id: string; - height: number; -} - -function isSetHeightMessage(data: unknown): data is SetHeightMessage { - if ( - data && - typeof data === 'object' && - 'type' in data && - data.type === 'setHeight' - ) - return true; - else return false; -} - -window.addEventListener('message', (e) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases - if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return; - - const data = e.data; - - ready(() => { - window.parent.postMessage( - { - type: 'setHeight', - id: data.id, - height: document.getElementsByTagName('html')[0]?.scrollHeight, - }, - '*', - ); - }).catch((e: unknown) => { - console.error('Error in setHeightMessage postMessage', e); - }); -}); - function loaded() { const { messages: localeData } = getLocale(); diff --git a/app/javascript/flavours/glitch/features/standalone/status/index.tsx b/app/javascript/flavours/glitch/features/standalone/status/index.tsx new file mode 100644 index 0000000000..280b8fbb09 --- /dev/null +++ b/app/javascript/flavours/glitch/features/standalone/status/index.tsx @@ -0,0 +1,94 @@ +/* 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 { + fetchStatus, + toggleStatusSpoilers, +} from 'flavours/glitch/actions/statuses'; +import { hydrateStore } from 'flavours/glitch/actions/store'; +import { Router } from 'flavours/glitch/components/router'; +import { DetailedStatus } from 'flavours/glitch/features/status/components/detailed_status'; +import { useRenderSignal } from 'flavours/glitch/hooks/useRenderSignal'; +import initialState from 'flavours/glitch/initial_state'; +import { IntlProvider } from 'flavours/glitch/locales'; +import { + makeGetStatus, + makeGetPictureInPicture, +} from 'flavours/glitch/selectors'; +import { store, useAppSelector, useAppDispatch } from 'flavours/glitch/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 ( +
+ + + +
+ ); +}; + +export const Status: React.FC<{ id: string }> = ({ id }) => { + useEffect(() => { + if (initialState) { + store.dispatch(hydrateStore(initialState)); + } + }, []); + + return ( + + + + + + + + ); +}; diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx deleted file mode 100644 index 2db9fa6d3a..0000000000 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx +++ /dev/null @@ -1,336 +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 { AnimatedNumber } from 'flavours/glitch/components/animated_number'; -import AttachmentList from 'flavours/glitch/components/attachment_list'; -import EditedTimestamp from 'flavours/glitch/components/edited_timestamp'; -import { getHashtagBarForStatus } from 'flavours/glitch/components/hashtag_bar'; -import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; -import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon'; -import PollContainer from 'flavours/glitch/containers/poll_container'; -import { WithRouterPropTypes } from 'flavours/glitch/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 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, - settings: ImmutablePropTypes.map.isRequired, - onOpenMedia: PropTypes.func.isRequired, - onOpenVideo: PropTypes.func.isRequired, - onToggleHidden: PropTypes.func, - onTranslate: PropTypes.func.isRequired, - expanded: PropTypes.bool, - 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, - ...WithRouterPropTypes, - }; - - state = { - height: null, - }; - - handleAccountClick = (e) => { - if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.props.history) { - e.preventDefault(); - this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`); - } - - e.stopPropagation(); - }; - - parseClick = (e, destination) => { - if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.props.history) { - e.preventDefault(); - this.props.history.push(destination); - } - - e.stopPropagation(); - }; - - handleOpenVideo = (options) => { - this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options); - }; - - _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); - } - - handleChildUpdate = () => { - this._measureHeight(); - }; - - 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); - }; - - render () { - const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; - const outerStyle = { boxSizing: 'border-box' }; - const { compact, pictureInPicture, expanded, onToggleHidden, settings } = this.props; - - if (!status) { - return null; - } - - let applicationLink = ''; - let reblogLink = ''; - let favouriteLink = ''; - - // Depending on user settings, some media are considered as parts of the - // contents (affected by CW) while other will be displayed outside of the - // CW. - let contentMedia = []; - let contentMediaIcons = []; - let extraMedia = []; - let extraMediaIcons = []; - let media = contentMedia; - let mediaIcons = contentMediaIcons; - - if (settings.getIn(['content_warnings', 'media_outside'])) { - media = extraMedia; - mediaIcons = extraMediaIcons; - } - - if (this.props.measureHeight) { - outerStyle.height = `${this.state.height}px`; - } - - const language = status.getIn(['translation', 'language']) || status.get('language'); - - if (pictureInPicture.get('inUse')) { - media.push(); - mediaIcons.push('video-camera'); - } else if (status.get('media_attachments').size > 0) { - if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { - media.push(); - } else 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.push( - {status.getIn(['application', 'name'])}; - } - - const visibilityLink = <>·; - - if (!['unlisted', 'public'].includes(status.get('visibility'))) { - reblogLink = null; - } else if (this.props.history) { - reblogLink = ( - - - - - - - ); - } else { - reblogLink = ( - - - - - - - ); - } - - if (this.props.history) { - favouriteLink = ( - - - - - - - ); - } else { - favouriteLink = ( - - - - - - - ); - } - - const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); - contentMedia.push(hashtagBar); - - return ( -
-
- -
- -
- - - -
-
- - - - - {visibilityLink} - - {applicationLink} -
- - {status.get('edited_at') &&
} - -
- {reblogLink} - {reblogLink && <>·} - {favouriteLink} -
-
-
-
- ); - } - -} - -export default withRouter(DetailedStatus); diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx new file mode 100644 index 0000000000..a1da973b49 --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx @@ -0,0 +1,413 @@ +/* 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 { AnimatedNumber } from 'flavours/glitch/components/animated_number'; +import AttachmentList from 'flavours/glitch/components/attachment_list'; +import EditedTimestamp from 'flavours/glitch/components/edited_timestamp'; +import type { StatusLike } from 'flavours/glitch/components/hashtag_bar'; +import { getHashtagBarForStatus } from 'flavours/glitch/components/hashtag_bar'; +import { IconLogo } from 'flavours/glitch/components/logo'; +import { Permalink } from 'flavours/glitch/components/permalink'; +import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; +import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon'; +import { useAppSelector } from 'flavours/glitch/store'; + +import { Avatar } from '../../../components/avatar'; +import { DisplayName } from '../../../components/display_name'; +import MediaGallery from '../../../components/media_gallery'; +import StatusContent from '../../../components/status_content'; +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; + expanded: boolean; +}> = ({ + status, + onOpenMedia, + onOpenVideo, + onTranslate, + measureHeight, + onHeightChange, + domain, + showMedia, + withLogo, + pictureInPicture, + onToggleMediaVisibility, + onToggleHidden, + expanded, +}) => { + const properStatus = status?.get('reblog') ?? status; + const [height, setHeight] = useState(0); + const nodeRef = useRef(); + + const rewriteMentions = useAppSelector( + (state) => state.local_settings.get('rewrite_mentions', false) as boolean, + ); + const tagMisleadingLinks = useAppSelector( + (state) => + state.local_settings.get('tag_misleading_links', false) as boolean, + ); + const mediaOutsideCW = useAppSelector( + (state) => + state.local_settings.getIn( + ['content_warnings', 'media_outside'], + false, + ) as boolean, + ); + const letterboxMedia = useAppSelector( + (state) => + state.local_settings.getIn(['media', 'letterbox'], false) as boolean, + ); + const fullwidthMedia = useAppSelector( + (state) => + state.local_settings.getIn(['media', 'fullwidth'], false) as boolean, + ); + + 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 _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 handleChildUpdate = useCallback(() => { + _measureHeight(); + }, [_measureHeight]); + + const handleTranslate = useCallback(() => { + if (onTranslate) onTranslate(status); + }, [onTranslate, status]); + + if (!properStatus) { + return null; + } + + let applicationLink; + let reblogLink; + + // Depending on user settings, some media are considered as parts of the + // contents (affected by CW) while other will be displayed outside of the + // CW. + const contentMedia: React.ReactNode[] = []; + const contentMediaIcons: string[] = []; + const extraMedia: React.ReactNode[] = []; + const extraMediaIcons: string[] = []; + let media = contentMedia; + let mediaIcons: string[] = contentMediaIcons; + + if (mediaOutsideCW) { + media = extraMedia; + mediaIcons = extraMediaIcons; + } + + 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.push(); + mediaIcons.push('video-camera'); + } else if (status.get('media_attachments').size > 0) { + if ( + status + .get('media_attachments') + .some( + (item: Immutable.Map) => item.get('type') === 'unknown', + ) + ) { + media.push(); + } else 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.push( +