Merge branch 'markup-css' into 'develop'

Add <Markup> component to style markup from the API

See merge request soapbox-pub/soapbox!1922
environments/review-develop-3zknud/deployments/1487
Alex Gleason 2 years ago
commit 5f9ce5776e

@ -1,77 +1,77 @@
.status-content p { [data-markup] p {
@apply mb-4 whitespace-pre-wrap; @apply mb-4 whitespace-pre-wrap;
} }
.status-content p:last-child { [data-markup] p:last-child {
@apply mb-0; @apply mb-0;
} }
.status-content a { [data-markup] a {
@apply text-primary-600 dark:text-accent-blue hover:underline; @apply text-primary-600 dark:text-accent-blue hover:underline;
} }
.status-content strong { [data-markup] strong {
@apply font-bold; @apply font-bold;
} }
.status-content em { [data-markup] em {
@apply italic; @apply italic;
} }
.status-content ul, [data-markup] ul,
.status-content ol { [data-markup] ol {
@apply pl-10 mb-4; @apply pl-10 mb-4;
} }
.status-content ul { [data-markup] ul {
@apply list-disc list-outside; @apply list-disc list-outside;
} }
.status-content ol { [data-markup] ol {
@apply list-decimal list-outside; @apply list-decimal list-outside;
} }
.status-content blockquote { [data-markup] blockquote {
@apply py-1 pl-4 mb-4 border-l-4 border-solid border-gray-400 text-gray-500 dark:text-gray-400; @apply py-1 pl-4 mb-4 border-l-4 border-solid border-gray-400 text-gray-500 dark:text-gray-400;
} }
.status-content code { [data-markup] code {
@apply cursor-text font-mono; @apply cursor-text font-mono;
} }
.status-content p > code, [data-markup] p > code,
.status-content pre { [data-markup] pre {
@apply bg-gray-100 dark:bg-primary-800; @apply bg-gray-100 dark:bg-primary-800;
} }
/* Inline code */ /* Inline code */
.status-content p > code { [data-markup] p > code {
@apply py-0.5 px-1 rounded-sm; @apply py-0.5 px-1 rounded-sm;
} }
/* Code block */ /* Code block */
.status-content pre { [data-markup] pre {
@apply py-2 px-3 mb-4 leading-6 overflow-x-auto rounded-md break-all; @apply py-2 px-3 mb-4 leading-6 overflow-x-auto rounded-md break-all;
} }
.status-content pre:last-child { [data-markup] pre:last-child {
@apply mb-0; @apply mb-0;
} }
/* Markdown images */ /* Markdown images */
.status-content img:not(.emojione):not([width][height]) { [data-markup] img:not(.emojione):not([width][height]) {
@apply w-full h-72 object-contain rounded-lg overflow-hidden my-4 block; @apply w-full h-72 object-contain rounded-lg overflow-hidden my-4 block;
} }
/* User setting to underline links */ /* User setting to underline links */
body.underline-links .status-content a { body.underline-links [data-markup] a {
@apply underline; @apply underline;
} }
.status-content .big-emoji img.emojione { [data-markup] .big-emoji img.emojione {
@apply inline w-9 h-9 p-1; @apply inline w-9 h-9 p-1;
} }
.status-content .status-link { [data-markup] .status-link {
@apply hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue; @apply hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue;
} }

@ -0,0 +1,16 @@
import React from 'react';
import Text, { IText } from './ui/text/text';
import './markup.css';
interface IMarkup extends IText {
}
/** Styles HTML markup returned by the API, such as in account bios and statuses. */
const Markup = React.forwardRef<any, IMarkup>((props, ref) => {
return (
<Text ref={ref} {...props} data-markup />
);
});
export default Markup;

@ -10,19 +10,14 @@ import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich-content';
import { isRtl } from '../rtl'; import { isRtl } from '../rtl';
import Markup from './markup';
import Poll from './polls/poll'; import Poll from './polls/poll';
import './status-content.css';
import type { Status, Mention } from 'soapbox/types/entities'; import type { Status, Mention } from 'soapbox/types/entities';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
const BIG_EMOJI_LIMIT = 10; const BIG_EMOJI_LIMIT = 10;
type Point = [
x: number,
y: number,
]
interface IReadMoreButton { interface IReadMoreButton {
onClick: React.MouseEventHandler, onClick: React.MouseEventHandler,
} }
@ -49,7 +44,6 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [onlyEmoji, setOnlyEmoji] = useState(false); const [onlyEmoji, setOnlyEmoji] = useState(false);
const startXY = useRef<Point>();
const node = useRef<HTMLDivElement>(null); const node = useRef<HTMLDivElement>(null);
const { greentext } = useSoapboxConfig(); const { greentext } = useSoapboxConfig();
@ -131,29 +125,6 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
updateStatusLinks(); updateStatusLinks();
}); });
const handleMouseDown: React.EventHandler<React.MouseEvent> = (e) => {
startXY.current = [e.clientX, e.clientY];
};
const handleMouseUp: React.EventHandler<React.MouseEvent> = (e) => {
if (!startXY.current) return;
const target = e.target as HTMLElement;
const parentNode = target.parentNode as HTMLElement;
const [startX, startY] = startXY.current;
const [deltaX, deltaY] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
if (target.localName === 'button' || target.localName === 'a' || (parentNode && (parentNode.localName === 'button' || parentNode.localName === 'a'))) {
return;
}
if (deltaX + deltaY < 5 && e.button === 0 && !(e.ctrlKey || e.metaKey) && onClick) {
onClick();
}
startXY.current = undefined;
};
const parsedHtml = useMemo((): string => { const parsedHtml = useMemo((): string => {
const html = translatable && status.translation ? status.translation.get('content')! : status.contentHtml; const html = translatable && status.translation ? status.translation.get('content')! : status.contentHtml;
@ -173,30 +144,24 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none'; const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none';
const content = { __html: parsedHtml }; const content = { __html: parsedHtml };
const directionStyle: React.CSSProperties = { direction: 'ltr' }; const direction = isRtl(status.search_index) ? 'rtl' : 'ltr';
const className = classNames(baseClassName, 'status-content', { const className = classNames(baseClassName, {
'cursor-pointer': onClick, 'cursor-pointer': onClick,
'whitespace-normal': withSpoiler, 'whitespace-normal': withSpoiler,
'max-h-[300px]': collapsed, 'max-h-[300px]': collapsed,
'leading-normal big-emoji': onlyEmoji, 'leading-normal big-emoji': onlyEmoji,
}); });
if (isRtl(status.search_index)) {
directionStyle.direction = 'rtl';
}
if (onClick) { if (onClick) {
const output = [ const output = [
<div <Markup
ref={node} ref={node}
tabIndex={0} tabIndex={0}
key='content' key='content'
className={className} className={className}
style={directionStyle} direction={direction}
dangerouslySetInnerHTML={content} dangerouslySetInnerHTML={content}
lang={status.language || undefined} lang={status.language || undefined}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
/>, />,
]; ];
@ -212,14 +177,14 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
return <div className={classNames({ 'bg-gray-100 dark:bg-primary-800 rounded-md p-4': hasPoll })}>{output}</div>; return <div className={classNames({ 'bg-gray-100 dark:bg-primary-800 rounded-md p-4': hasPoll })}>{output}</div>;
} else { } else {
const output = [ const output = [
<div <Markup
ref={node} ref={node}
tabIndex={0} tabIndex={0}
key='content' key='content'
className={classNames(baseClassName, 'status-content', { className={classNames(baseClassName, {
'leading-normal big-emoji': onlyEmoji, 'leading-normal big-emoji': onlyEmoji,
})} })}
style={directionStyle} direction={direction}
dangerouslySetInnerHTML={content} dangerouslySetInnerHTML={content}
lang={status.language || undefined} lang={status.language || undefined}
/>, />,

@ -54,7 +54,9 @@ export type Sizes = keyof typeof sizes
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label' type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label'
type Directions = 'ltr' | 'rtl' type Directions = 'ltr' | 'rtl'
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML'> { interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML' | 'tabIndex' | 'lang'> {
/** Text content. */
children?: React.ReactNode,
/** How to align the text. */ /** How to align the text. */
align?: keyof typeof alignments, align?: keyof typeof alignments,
/** Extra class names for the outer element. */ /** Extra class names for the outer element. */
@ -84,8 +86,8 @@ interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'danger
} }
/** UI-friendly text container with dark mode support. */ /** UI-friendly text container with dark mode support. */
const Text: React.FC<IText> = React.forwardRef( const Text = React.forwardRef<any, IText>(
(props: IText, ref: React.LegacyRef<any>) => { (props, ref) => {
const { const {
align, align,
className, className,

@ -2,14 +2,13 @@ import * as React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { prepareRequest } from 'soapbox/actions/consumer-auth'; import { prepareRequest } from 'soapbox/actions/consumer-auth';
import Markup from 'soapbox/components/markup';
import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui'; import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification-badge'; import VerificationBadge from 'soapbox/components/verification-badge';
import RegistrationForm from 'soapbox/features/auth-login/components/registration-form'; import RegistrationForm from 'soapbox/features/auth-login/components/registration-form';
import { useAppDispatch, useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
import { capitalize } from 'soapbox/utils/strings'; import { capitalize } from 'soapbox/utils/strings';
import './instance-description.css';
const LandingPage = () => { const LandingPage = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const features = useFeatures(); const features = useFeatures();
@ -114,12 +113,10 @@ const LandingPage = () => {
{instance.title} {instance.title}
</h1> </h1>
<Text size='lg'> <Markup
<span size='lg'
className='instance-description' dangerouslySetInnerHTML={{ __html: instance.short_description || instance.description }}
dangerouslySetInnerHTML={{ __html: instance.short_description || instance.description }} />
/>
</Text>
</Stack> </Stack>
</div> </div>
</div> </div>

@ -1,14 +0,0 @@
/* Instance HTML from the API. */
.instance-description a {
@apply underline;
}
.instance-description b,
.instance-description strong {
@apply font-bold;
}
.instance-description i,
.instance-description em {
@apply italic;
}

@ -2,7 +2,8 @@ import classNames from 'clsx';
import React from 'react'; import React from 'react';
import { defineMessages, useIntl, FormattedMessage, FormatDateOptions } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage, FormatDateOptions } from 'react-intl';
import { Widget, Stack, HStack, Icon, Text } from 'soapbox/components/ui'; import Markup from 'soapbox/components/markup';
import { Widget, Stack, HStack, Icon } from 'soapbox/components/ui';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container'; import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import { CryptoAddress } from 'soapbox/features/ui/util/async-components'; import { CryptoAddress } from 'soapbox/features/ui/util/async-components';
@ -51,7 +52,7 @@ const ProfileField: React.FC<IProfileField> = ({ field }) => {
return ( return (
<dl> <dl>
<dt title={field.name}> <dt title={field.name}>
<Text weight='bold' tag='span' dangerouslySetInnerHTML={{ __html: field.name_emojified }} /> <Markup weight='bold' tag='span' dangerouslySetInnerHTML={{ __html: field.name_emojified }} />
</dt> </dt>
<dd <dd
@ -65,7 +66,7 @@ const ProfileField: React.FC<IProfileField> = ({ field }) => {
</span> </span>
)} )}
<Text className='break-words overflow-hidden' tag='span' dangerouslySetInnerHTML={{ __html: field.value_emojified }} /> <Markup className='break-words overflow-hidden' tag='span' dangerouslySetInnerHTML={{ __html: field.value_emojified }} />
</HStack> </HStack>
</dd> </dd>
</dl> </dl>

@ -4,6 +4,7 @@ import React from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import Badge from 'soapbox/components/badge'; import Badge from 'soapbox/components/badge';
import Markup from 'soapbox/components/markup';
import { Icon, HStack, Stack, Text } from 'soapbox/components/ui'; import { Icon, HStack, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification-badge'; import VerificationBadge from 'soapbox/components/verification-badge';
import { useSoapboxConfig } from 'soapbox/hooks'; import { useSoapboxConfig } from 'soapbox/hooks';
@ -139,13 +140,6 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
return ( return (
<div className='mt-6 min-w-0 flex-1 sm:px-2'> <div className='mt-6 min-w-0 flex-1 sm:px-2'>
<Stack space={2}> <Stack space={2}>
{/* Not sure if this is actual used. */}
{/* <div className='profile-info-panel-content__deactivated'>
<FormattedMessage
id='account.deactivated_description' defaultMessage='This account has been deactivated.'
/>
</div> */}
<Stack> <Stack>
<HStack space={1} alignItems='center'> <HStack space={1} alignItems='center'>
<Text size='lg' weight='bold' dangerouslySetInnerHTML={displayNameHtml} /> <Text size='lg' weight='bold' dangerouslySetInnerHTML={displayNameHtml} />
@ -178,8 +172,8 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
<ProfileStats account={account} /> <ProfileStats account={account} />
{account.note.length > 0 && account.note !== '<p></p>' && ( {account.note.length > 0 && (
<Text size='sm' dangerouslySetInnerHTML={content} /> <Markup size='sm' dangerouslySetInnerHTML={content} />
)} )}
<div className='flex flex-col md:flex-row items-start md:flex-wrap md:items-center gap-2'> <div className='flex flex-col md:flex-row items-start md:flex-wrap md:items-center gap-2'>

@ -269,6 +269,15 @@ const fixBirthday = (account: ImmutableMap<string, any>) => {
return account.set('birthday', birthday || ''); return account.set('birthday', birthday || '');
}; };
/** Rewrite `<p></p>` to empty string. */
const fixNote = (account: ImmutableMap<string, any>) => {
if (account.get('note') === '<p></p>') {
return account.set('note', '');
} else {
return account;
}
};
export const normalizeAccount = (account: Record<string, any>) => { export const normalizeAccount = (account: Record<string, any>) => {
return AccountRecord( return AccountRecord(
ImmutableMap(fromJS(account)).withMutations(account => { ImmutableMap(fromJS(account)).withMutations(account => {
@ -289,6 +298,7 @@ export const normalizeAccount = (account: Record<string, any>) => {
fixUsername(account); fixUsername(account);
fixDisplayName(account); fixDisplayName(account);
fixBirthday(account); fixBirthday(account);
fixNote(account);
addInternalFields(account); addInternalFields(account);
}), }),
); );

Loading…
Cancel
Save