Add <Markup> component to style markup from the API
This commit is contained in:
parent
39b4ee9f09
commit
7e32f0d992
7 changed files with 66 additions and 78 deletions
|
@ -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;
|
||||||
}
|
}
|
16
app/soapbox/components/markup.tsx
Normal file
16
app/soapbox/components/markup.tsx
Normal file
|
@ -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,8 +10,8 @@ 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 StopPropagation from './stop-propagation';
|
import StopPropagation from './stop-propagation';
|
||||||
|
|
||||||
import type { Status, Mention } from 'soapbox/types/entities';
|
import type { Status, Mention } from 'soapbox/types/entities';
|
||||||
|
@ -19,11 +19,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
@ -52,7 +47,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();
|
||||||
|
@ -138,29 +132,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;
|
||||||
|
|
||||||
|
@ -180,30 +151,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}
|
|
||||||
/>,
|
/>,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -219,14 +184,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,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…
Reference in a new issue