Merge branch 'embedded-status' into 'develop'

Native status embeds from Soapbox

See merge request soapbox-pub/soapbox-fe!1730
This commit is contained in:
Alex Gleason 2022-08-23 15:48:56 +00:00
commit 6ee6c8aa44
18 changed files with 289 additions and 105 deletions

View File

@ -1,5 +1,8 @@
import loadPolyfills from './soapbox/load_polyfills'; import loadPolyfills from './soapbox/load_polyfills';
// Load iframe event listener
require('./soapbox/iframe');
// @ts-ignore // @ts-ignore
require.context('./images/', true); require.context('./images/', true);

View File

@ -0,0 +1,63 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
interface ISafeEmbed {
/** Styles for the outer frame element. */
className?: string,
/** Space-separate list of restrictions to ALLOW for the iframe. */
sandbox?: string,
/** Unique title for the iframe. */
title: string,
/** HTML body to embed. */
html?: string,
}
/** Safely embeds arbitrary HTML content on the page (by putting it in an iframe). */
const SafeEmbed: React.FC<ISafeEmbed> = ({
className,
sandbox,
title,
html,
}) => {
const iframe = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState<number | undefined>(undefined);
const handleMessage = useCallback((e: MessageEvent) => {
if (e.data?.type === 'setHeight') {
setHeight(e.data?.height);
}
}, []);
useEffect(() => {
const iframeDocument = iframe.current?.contentWindow?.document;
if (iframeDocument && html) {
iframeDocument.open();
iframeDocument.write(html);
iframeDocument.close();
iframeDocument.body.style.margin = '0';
iframe.current?.contentWindow?.addEventListener('message', handleMessage);
const innerFrame = iframeDocument.querySelector('iframe');
if (innerFrame) {
innerFrame.width = '100%';
}
}
return () => {
iframe.current?.contentWindow?.removeEventListener('message', handleMessage);
};
}, [iframe.current, html]);
return (
<iframe
ref={iframe}
className={className}
sandbox={sandbox}
height={height}
title={title}
/>
);
};
export default SafeEmbed;

View File

@ -18,6 +18,7 @@ import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper';
import StatusActionButton from 'soapbox/components/status-action-button'; import StatusActionButton from 'soapbox/components/status-action-button';
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
import { isLocal } from 'soapbox/utils/accounts';
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji_reacts'; import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji_reacts';
import type { Menu } from 'soapbox/components/dropdown_menu'; import type { Menu } from 'soapbox/components/dropdown_menu';
@ -362,7 +363,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
icon: require('@tabler/icons/link.svg'), icon: require('@tabler/icons/link.svg'),
}); });
if (features.embeds) { if (features.embeds && isLocal(status.account as Account)) {
menu.push({ menu.push({
text: intl.formatMessage(messages.embed), text: intl.formatMessage(messages.embed),
action: handleEmbed, action: handleEmbed,

View File

@ -18,7 +18,7 @@ import StatusActionBar from './status-action-bar';
import StatusMedia from './status-media'; import StatusMedia from './status-media';
import StatusReplyMentions from './status-reply-mentions'; import StatusReplyMentions from './status-reply-mentions';
import StatusContent from './status_content'; import StatusContent from './status_content';
import { HStack, Text } from './ui'; import { Card, HStack, Text } from './ui';
import type { Map as ImmutableMap } from 'immutable'; import type { Map as ImmutableMap } from 'immutable';
import type { import type {
@ -47,7 +47,9 @@ export interface IStatus {
featured?: boolean, featured?: boolean,
hideActionBar?: boolean, hideActionBar?: boolean,
hoverable?: boolean, hoverable?: boolean,
variant?: 'default' | 'rounded',
withDismiss?: boolean, withDismiss?: boolean,
accountAction?: React.ReactElement,
} }
const Status: React.FC<IStatus> = (props) => { const Status: React.FC<IStatus> = (props) => {
@ -64,8 +66,10 @@ const Status: React.FC<IStatus> = (props) => {
unread, unread,
group, group,
hideActionBar, hideActionBar,
variant = 'rounded',
withDismiss, withDismiss,
} = props; } = props;
const intl = useIntl(); const intl = useIntl();
const history = useHistory(); const history = useHistory();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -293,6 +297,8 @@ const Status: React.FC<IStatus> = (props) => {
const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`; const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`;
const accountAction = props.accountAction || reblogElement;
return ( return (
<HotKeys handlers={handlers} data-testid='status'> <HotKeys handlers={handlers} data-testid='status'>
<div <div
@ -316,8 +322,10 @@ const Status: React.FC<IStatus> = (props) => {
</div> </div>
)} )}
<div <Card
variant={variant}
className={classNames('status__wrapper', `status-${actualStatus.visibility}`, { className={classNames('status__wrapper', `status-${actualStatus.visibility}`, {
'py-6 sm:p-5': variant === 'rounded',
'status-reply': !!status.in_reply_to_id, 'status-reply': !!status.in_reply_to_id,
muted, muted,
read: unread === false, read: unread === false,
@ -332,8 +340,8 @@ const Status: React.FC<IStatus> = (props) => {
id={String(actualStatus.getIn(['account', 'id']))} id={String(actualStatus.getIn(['account', 'id']))}
timestamp={actualStatus.created_at} timestamp={actualStatus.created_at}
timestampUrl={statusUrl} timestampUrl={statusUrl}
action={reblogElement} action={accountAction}
hideActions={!reblogElement} hideActions={!accountAction}
showEdit={!!actualStatus.edited_at} showEdit={!!actualStatus.edited_at}
showProfileHoverCard={hoverable} showProfileHoverCard={hoverable}
withLinkToProfile={hoverable} withLinkToProfile={hoverable}
@ -376,7 +384,7 @@ const Status: React.FC<IStatus> = (props) => {
</div> </div>
)} )}
</div> </div>
</div> </Card>
</div> </div>
</HotKeys> </HotKeys>
); );

View File

@ -18,7 +18,7 @@ const messages = defineMessages({
interface ICard { interface ICard {
/** The type of card. */ /** The type of card. */
variant?: 'rounded', variant?: 'default' | 'rounded',
/** Card size preset. */ /** Card size preset. */
size?: 'md' | 'lg' | 'xl', size?: 'md' | 'lg' | 'xl',
/** Extra classnames for the <div> element. */ /** Extra classnames for the <div> element. */
@ -28,12 +28,11 @@ interface ICard {
} }
/** An opaque backdrop to hold a collection of related elements. */ /** An opaque backdrop to hold a collection of related elements. */
const Card = React.forwardRef<HTMLDivElement, ICard>(({ children, variant, size = 'md', className, ...filteredProps }, ref): JSX.Element => ( const Card = React.forwardRef<HTMLDivElement, ICard>(({ children, variant = 'default', size = 'md', className, ...filteredProps }, ref): JSX.Element => (
<div <div
ref={ref} ref={ref}
{...filteredProps} {...filteredProps}
className={classNames({ className={classNames({
'space-y-4': true,
'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none overflow-hidden': variant === 'rounded', 'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none overflow-hidden': variant === 'rounded',
[sizes[size]]: variant === 'rounded', [sizes[size]]: variant === 'rounded',
}, className)} }, className)}

View File

@ -18,6 +18,7 @@ import GdprBanner from 'soapbox/components/gdpr-banner';
import Helmet from 'soapbox/components/helmet'; import Helmet from 'soapbox/components/helmet';
import LoadingScreen from 'soapbox/components/loading-screen'; import LoadingScreen from 'soapbox/components/loading-screen';
import AuthLayout from 'soapbox/features/auth_layout'; import AuthLayout from 'soapbox/features/auth_layout';
import EmbeddedStatus from 'soapbox/features/embedded-status';
import PublicLayout from 'soapbox/features/public_layout'; import PublicLayout from 'soapbox/features/public_layout';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { import {
@ -148,6 +149,12 @@ const SoapboxMount = () => {
<Route path='/verify' component={AuthLayout} /> <Route path='/verify' component={AuthLayout} />
)} )}
<Route
path='/embed/:statusId'
render={(props) => <EmbeddedStatus params={props.match.params} />}
/>
<Redirect from='/@:username/:statusId/embed' to='/embed/:statusId' />
<Route path='/reset-password' component={AuthLayout} /> <Route path='/reset-password' component={AuthLayout} />
<Route path='/edit-password' component={AuthLayout} /> <Route path='/edit-password' component={AuthLayout} />
<Route path='/invite/:token' component={AuthLayout} /> <Route path='/invite/:token' component={AuthLayout} />

View File

@ -53,7 +53,7 @@ const Ad: React.FC<IAd> = ({ card, impression }) => {
return ( return (
<div className='relative'> <div className='relative'>
<Card className='p-5' variant='rounded'> <Card className='py-6 sm:p-5' variant='rounded'>
<Stack space={4}> <Stack space={4}>
<HStack alignItems='center' space={3}> <HStack alignItems='center' space={3}>
<Avatar src={instance.thumbnail} size={42} /> <Avatar src={instance.thumbnail} size={42} />

View File

@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { fetchStatus } from 'soapbox/actions/statuses';
import MissingIndicator from 'soapbox/components/missing_indicator';
import SiteLogo from 'soapbox/components/site-logo';
import Status from 'soapbox/components/status';
import { Spinner } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { iframeId } from 'soapbox/iframe';
import { makeGetStatus } from 'soapbox/selectors';
interface IEmbeddedStatus {
params: {
statusId: string,
},
}
const getStatus = makeGetStatus();
/** Status to be presented in an iframe for embeds on external websites. */
const EmbeddedStatus: React.FC<IEmbeddedStatus> = ({ params }) => {
const dispatch = useAppDispatch();
const history = useHistory();
const status = useAppSelector(state => getStatus(state, { id: params.statusId }));
const [loading, setLoading] = useState(true);
useEffect(() => {
// Prevent navigation for UX and security.
// https://stackoverflow.com/a/71531211
history.block();
dispatch(fetchStatus(params.statusId))
.then(() => setLoading(false))
.catch(() => setLoading(false));
}, []);
useEffect(() => {
window.parent.postMessage({
type: 'setHeight',
id: iframeId,
height: document.getElementsByTagName('html')[0].scrollHeight,
}, '*');
}, [status, loading]);
const logo = (
<div className='flex align-middle justify-center ml-4'>
<SiteLogo className='max-h-[20px] max-w-[112px]' />
</div>
);
const renderInner = () => {
if (loading) {
return <Spinner />;
} else if (status) {
return <Status status={status} accountAction={logo} variant='default' />;
} else {
return <MissingIndicator nested />;
}
};
return (
<a
className='block bg-white dark:bg-primary-900'
href={status?.url || '#'}
onClick={e => e.stopPropagation()}
target='_blank'
>
<div className='p-4 sm:p-6 max-w-3xl pointer-events-none'>
{renderInner()}
</div>
</a>
);
};
export default EmbeddedStatus;

View File

@ -60,7 +60,7 @@ const Settings = () => {
return ( return (
<Column label={intl.formatMessage(messages.settings)} transparent withHeader={false}> <Column label={intl.formatMessage(messages.settings)} transparent withHeader={false}>
<Card variant='rounded'> <Card className='space-y-4' variant='rounded'>
<CardHeader> <CardHeader>
<CardTitle title={intl.formatMessage(messages.profile)} /> <CardTitle title={intl.formatMessage(messages.profile)} />
</CardHeader> </CardHeader>

View File

@ -380,9 +380,9 @@ const Thread: React.FC<IThread> = (props) => {
return ( return (
<PendingStatus <PendingStatus
className='thread__status'
key={id} key={id}
idempotencyKey={idempotencyKey} idempotencyKey={idempotencyKey}
thread
/> />
); );
}; };

View File

@ -0,0 +1,65 @@
import React, { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { closeModal } from 'soapbox/actions/modals';
import SafeEmbed from 'soapbox/components/safe-embed';
import { Modal, Stack, Text, Input, Divider } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import useEmbed from 'soapbox/queries/embed';
interface IEmbedModal {
url: string,
onError: (error: any) => void,
}
const EmbedModal: React.FC<IEmbedModal> = ({ url, onError }) => {
const dispatch = useAppDispatch();
const { data: embed, error, isError } = useEmbed(url);
useEffect(() => {
if (error && isError) {
onError(error);
}
}, [isError]);
const handleInputClick: React.MouseEventHandler<HTMLInputElement> = (e) => {
e.currentTarget.select();
};
const handleClose = () => {
dispatch(closeModal('EMBED'));
};
return (
<Modal
title={<FormattedMessage id='status.embed' defaultMessage='Embed post' />}
onClose={handleClose}
>
<Stack space={4}>
<Text theme='muted'>
<FormattedMessage id='embed.instructions' defaultMessage='Embed this post on your website by copying the code below.' />
</Text>
<Input
type='text'
readOnly
value={embed?.html || ''}
onClick={handleInputClick}
/>
</Stack>
<div className='py-9'>
<Divider />
</div>
<SafeEmbed
className='rounded-xl overflow-hidden w-full'
sandbox='allow-same-origin allow-scripts'
title='embedded-status'
html={embed?.html}
/>
</Modal>
);
};
export default EmbedModal;

View File

@ -1,83 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import api from 'soapbox/api';
import { Modal, Stack, Text, Input } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import type { RootState } from 'soapbox/store';
const fetchEmbed = (url: string) => {
return (dispatch: any, getState: () => RootState) => {
return api(getState).get('/api/oembed', { params: { url } });
};
};
interface IEmbedModal {
url: string,
onError: (error: any) => void,
}
const EmbedModal: React.FC<IEmbedModal> = ({ url, onError }) => {
const dispatch = useAppDispatch();
const iframe = useRef<HTMLIFrameElement>(null);
const [oembed, setOembed] = useState<any>(null);
useEffect(() => {
dispatch(fetchEmbed(url)).then(({ data }) => {
if (!iframe.current?.contentWindow) return;
setOembed(data);
const iframeDocument = iframe.current.contentWindow.document;
iframeDocument.open();
iframeDocument.write(data.html);
iframeDocument.close();
const innerFrame = iframeDocument.querySelector('iframe');
iframeDocument.body.style.margin = '0';
if (innerFrame) {
innerFrame.width = '100%';
}
}).catch(error => {
onError(error);
});
}, [!!iframe.current]);
const handleInputClick: React.MouseEventHandler<HTMLInputElement> = (e) => {
e.currentTarget.select();
};
return (
<Modal title={<FormattedMessage id='status.embed' defaultMessage='Embed' />}>
<Stack space={4}>
<Stack>
<Text theme='muted' size='sm'>
<FormattedMessage id='embed.instructions' defaultMessage='Embed this post on your website by copying the code below.' />
</Text>
<Input
type='text'
readOnly
value={oembed?.html || ''}
onClick={handleInputClick}
/>
</Stack>
<iframe
className='inline-flex rounded-xl overflow-hidden max-w-full'
frameBorder='0'
ref={iframe}
sandbox='allow-same-origin'
title='preview'
/>
</Stack>
</Modal>
);
};
export default EmbedModal;

View File

@ -3,7 +3,7 @@ import React from 'react';
import StatusReplyMentions from 'soapbox/components/status-reply-mentions'; import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
import StatusContent from 'soapbox/components/status_content'; import StatusContent from 'soapbox/components/status_content';
import { HStack } from 'soapbox/components/ui'; import { Card, HStack } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container'; import AccountContainer from 'soapbox/containers/account_container';
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder_card'; import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder_card';
import PlaceholderMediaGallery from 'soapbox/features/placeholder/components/placeholder_media_gallery'; import PlaceholderMediaGallery from 'soapbox/features/placeholder/components/placeholder_media_gallery';
@ -24,6 +24,7 @@ interface IPendingStatus {
className?: string, className?: string,
idempotencyKey: string, idempotencyKey: string,
muted?: boolean, muted?: boolean,
thread?: boolean,
} }
interface IPendingStatusMedia { interface IPendingStatusMedia {
@ -44,7 +45,7 @@ const PendingStatusMedia: React.FC<IPendingStatusMedia> = ({ status }) => {
} }
}; };
const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, muted }) => { const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, muted, thread = false }) => {
const status = useAppSelector((state) => { const status = useAppSelector((state) => {
const pendingStatus = state.pending_statuses.get(idempotencyKey); const pendingStatus = state.pending_statuses.get(idempotencyKey);
return pendingStatus ? buildStatus(state, pendingStatus, idempotencyKey) : null; return pendingStatus ? buildStatus(state, pendingStatus, idempotencyKey) : null;
@ -58,7 +59,10 @@ const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, mu
return ( return (
<div className={classNames('opacity-50', className)}> <div className={classNames('opacity-50', className)}>
<div className={classNames('status', { 'status-reply': !!status.in_reply_to_id, muted })} data-id={status.id}> <div className={classNames('status', { 'status-reply': !!status.in_reply_to_id, muted })} data-id={status.id}>
<div className={classNames('status__wrapper', `status-${status.visibility}`, { 'status-reply': !!status.in_reply_to_id })} tabIndex={muted ? undefined : 0}> <Card
className={classNames('py-6 sm:p-5', `status-${status.visibility}`, { 'status-reply': !!status.in_reply_to_id })}
variant={thread ? 'default' : 'rounded'}
>
<div className='mb-4'> <div className='mb-4'>
<HStack justifyContent='between' alignItems='start'> <HStack justifyContent='between' alignItems='start'>
<AccountContainer <AccountContainer
@ -88,7 +92,7 @@ const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, mu
{/* TODO */} {/* TODO */}
{/* <PlaceholderActionBar /> */} {/* <PlaceholderActionBar /> */}
</div> </Card>
</div> </div>
</div> </div>
); );

View File

@ -191,7 +191,7 @@ export function EditFederationModal() {
} }
export function EmbedModal() { export function EmbedModal() {
return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal'); return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed-modal');
} }
export function ComponentModal() { export function ComponentModal() {

14
app/soapbox/iframe.ts Normal file
View File

@ -0,0 +1,14 @@
/** ID of this iframe (given by embed.js) when embedded on a page. */
let iframeId: any;
/** Receive iframe messages. */
// https://github.com/mastodon/mastodon/pull/4853
const handleMessage = (e: MessageEvent) => {
if (e.data?.type === 'setHeight') {
iframeId = e.data?.id;
}
};
window.addEventListener('message', handleMessage);
export { iframeId };

View File

@ -986,7 +986,7 @@
"status.delete": "Delete", "status.delete": "Delete",
"status.detailed_status": "Detailed conversation view", "status.detailed_status": "Detailed conversation view",
"status.direct": "Direct message @{name}", "status.direct": "Direct message @{name}",
"status.embed": "Embed", "status.embed": "Embed post",
"status.favourite": "Like", "status.favourite": "Like",
"status.filtered": "Filtered", "status.filtered": "Filtered",
"status.load_more": "Load more", "status.load_more": "Load more",

View File

@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'soapbox/hooks';
type Embed = {
type: string,
version: string,
author_name: string,
author_url: string,
provider_name: string,
provider_url: string,
cache_age: number,
html: string,
width: number,
height: number,
}
/** Fetch OEmbed information for a status by its URL. */
// https://github.com/mastodon/mastodon/blob/main/app/controllers/api/oembed_controller.rb
// https://github.com/mastodon/mastodon/blob/main/app/serializers/oembed_serializer.rb
export default function useEmbed(url: string) {
const api = useApi();
const getEmbed = async() => {
const { data } = await api.get('/api/oembed', { params: { url } });
return data;
};
return useQuery<Embed>(['embed', url], getEmbed);
}

View File

@ -177,10 +177,6 @@
} }
} }
.status__wrapper {
@apply bg-white dark:bg-primary-900 px-4 py-6 shadow-xl dark:shadow-none sm:p-5 sm:rounded-xl;
}
[column-type=filled] .status__wrapper, [column-type=filled] .status__wrapper,
[column-type=filled] .status-placeholder { [column-type=filled] .status-placeholder {
@apply rounded-none shadow-none p-4; @apply rounded-none shadow-none p-4;