Merge branch 'rm-emojify' into 'main'

Remove emojify function, insert custom emojis on render, small bugfixes

See merge request soapbox-pub/soapbox!3279
This commit is contained in:
Alex Gleason 2024-11-28 03:32:28 +00:00
commit 8cb2e5e9d1
74 changed files with 363 additions and 3082 deletions

View File

@ -100,7 +100,6 @@
"cssnano": "^6.0.0",
"detect-passive-events": "^2.0.0",
"emoji-mart": "^5.6.0",
"escape-html": "^1.0.3",
"eslint-plugin-formatjs": "^5.2.2",
"exifr": "^7.1.3",
"graphemesplit": "^2.4.4",

View File

@ -7,7 +7,7 @@ import { getFilters, regexFromFilters } from 'soapbox/selectors/index.ts';
import { isLoggedIn } from 'soapbox/utils/auth.ts';
import { compareId } from 'soapbox/utils/comparators.ts';
import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features.ts';
import { unescapeHTML } from 'soapbox/utils/html.ts';
import { htmlToPlaintext } from 'soapbox/utils/html.ts';
import { EXCLUDE_TYPES, NOTIFICATION_TYPES } from 'soapbox/utils/notification.ts';
import { fetchRelationships } from './accounts.ts';
@ -100,7 +100,7 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record<
if (['mention', 'status'].includes(notification.type)) {
const regex = regexFromFilters(filters);
const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
const searchIndex = notification.status.spoiler_text + '\n' + htmlToPlaintext(notification.status.content);
filtered = regex && regex.test(searchIndex);
}
@ -111,7 +111,7 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record<
if (showAlert && !filtered && isNotificationsEnabled) {
const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : '');
const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : htmlToPlaintext(notification.status ? notification.status.content : '');
navigator.serviceWorker.ready.then(serviceWorkerRegistration => {
serviceWorkerRegistration.showNotification(title, {

View File

@ -15,6 +15,7 @@ import VerificationBadge from 'soapbox/components/verification-badge.tsx';
import ActionButton from 'soapbox/features/ui/components/action-button.tsx';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { getAcct } from 'soapbox/utils/accounts.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import { displayFqn } from 'soapbox/utils/state.ts';
import Badge from './badge.tsx';
@ -232,12 +233,9 @@ const Account = ({
>
<LinkEl {...linkProps}>
<HStack space={1} alignItems='center' grow>
<Text
size='sm'
weight='semibold'
truncate
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
/>
<Text size='sm' weight='semibold' truncate>
{emojifyText(account.display_name, account.emojis)}
</Text>
{account.verified && <VerificationBadge />}
@ -308,7 +306,7 @@ const Account = ({
<Text
truncate
size='sm'
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
dangerouslySetInnerHTML={{ __html: account.note }}
className='mr-2 rtl:ml-2 rtl:mr-0 [&_br]:hidden [&_p:first-child]:inline [&_p:first-child]:truncate [&_p]:hidden'
/>
)}

View File

@ -82,7 +82,7 @@ const AnnouncementContent: React.FC<IAnnouncementContent> = ({ announcement }) =
dir={direction}
className='text-sm ltr:ml-0 rtl:mr-0'
ref={node}
dangerouslySetInnerHTML={{ __html: announcement.contentHtml }}
dangerouslySetInnerHTML={{ __html: announcement.content }}
/>
);
};

View File

@ -1,6 +1,7 @@
import HStack from 'soapbox/components/ui/hstack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import { getAcct } from '../utils/accounts.ts';
@ -10,7 +11,7 @@ import VerificationBadge from './verification-badge.tsx';
import type { Account } from 'soapbox/schemas/index.ts';
interface IDisplayName {
account: Pick<Account, 'id' | 'acct' | 'fqn' | 'verified' | 'display_name_html'>;
account: Pick<Account, 'id' | 'acct' | 'emojis' | 'fqn' | 'verified' | 'display_name'>;
withSuffix?: boolean;
}
@ -24,8 +25,9 @@ const DisplayNameInline: React.FC<IDisplayName> = ({ account, withSuffix = true
size='sm'
weight='normal'
truncate
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
/>
>
{emojifyText(account.display_name, account.emojis)}
</Text>
{verified && <VerificationBadge />}
</HStack>

View File

@ -2,16 +2,15 @@ import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
import { getAcct } from '../utils/accounts.ts';
import { getAcct } from 'soapbox/utils/accounts.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import VerificationBadge from './verification-badge.tsx';
import type { Account } from 'soapbox/schemas/index.ts';
interface IDisplayName {
account: Pick<Account, 'id' | 'acct' | 'fqn' | 'verified' | 'display_name_html'>;
account: Pick<Account, 'id' | 'acct' | 'emojis' | 'fqn' | 'verified' | 'display_name'>;
withSuffix?: boolean;
children?: React.ReactNode;
}
@ -26,8 +25,9 @@ const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = t
size='sm'
weight='semibold'
truncate
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
/>
>
{emojifyText(account.display_name, account.emojis)}
</Text>
{verified && <VerificationBadge />}
</HStack>

View File

@ -73,7 +73,7 @@ const EventPreview: React.FC<IEventPreview> = ({ status, className, hideAction,
<HStack alignItems='center' space={2}>
<Icon src={userIcon} />
<HStack space={1} alignItems='center' grow>
<span dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
<span>{account.display_name}</span>
{account.verified && <VerificationBadge />}
</HStack>
</HStack>

View File

@ -36,7 +36,9 @@ const GroupCard: React.FC<IGroupCard> = ({ group }) => {
{/* Group Info */}
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
<HStack alignItems='center' space={1.5}>
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
<Text size='lg' weight='bold'>
{group.display_name}
</Text>
{group.relationship?.pending_requests && (
<div className='size-2 rounded-full bg-secondary-500' />

View File

@ -70,7 +70,9 @@ const GroupPopover = (props: IGroupPopoverContainer) => {
{/* Group Info */}
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
<Text size='lg' weight='bold'>
{group.display_name}
</Text>
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
<GroupPrivacy group={group} />

View File

@ -1,15 +1,86 @@
import parse, { HTMLReactParserOptions, Text as DOMText, DOMNode, Element, domToReact } from 'html-react-parser';
import { forwardRef } from 'react';
import HashtagLink from 'soapbox/components/hashtag-link.tsx';
import Mention from 'soapbox/components/mention.tsx';
import { CustomEmoji } from 'soapbox/schemas/custom-emoji.ts';
import { Mention as MentionEntity } from 'soapbox/schemas/mention.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import Text, { IText } from './ui/text.tsx';
import './markup.css';
interface IMarkup extends IText {
interface IMarkup extends Omit<IText, 'children' | 'dangerouslySetInnerHTML'> {
html: { __html: string };
mentions?: MentionEntity[];
emojis?: CustomEmoji[];
}
/** Styles HTML markup returned by the API, such as in account bios and statuses. */
const Markup = forwardRef<any, IMarkup>((props, ref) => {
const Markup = forwardRef<any, IMarkup>(({ html, emojis, mentions, ...props }, ref) => {
const options: HTMLReactParserOptions = {
replace(domNode) {
if (domNode instanceof Element && ['script', 'iframe'].includes(domNode.name)) {
return null;
}
if (domNode instanceof DOMText && emojis) {
return emojifyText(domNode.data, emojis);
}
if (domNode instanceof Element && domNode.name === 'a') {
const classes = domNode.attribs.class?.split(' ');
if (classes?.includes('hashtag')) {
const child = domToReact(domNode.children as DOMNode[]);
const hashtag: string | undefined = (() => {
// Mastodon wraps the hashtag in a span, with a sibling text node containing the hashtag.
if (Array.isArray(child) && child.length) {
if (child[0]?.props?.children === '#' && typeof child[1] === 'string') {
return child[1];
}
}
// Pleroma renders a string directly inside the hashtag link.
if (typeof child === 'string') {
return child.replace(/^#/, '');
}
})();
if (hashtag) {
return <HashtagLink hashtag={hashtag} />;
}
}
if (classes?.includes('mention')) {
const mention = mentions?.find(({ url }) => domNode.attribs.href === url);
if (mention) {
return <Mention mention={mention} />;
}
}
return (
<Text ref={ref} {...props} data-markup />
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<a
{...domNode.attribs}
onClick={(e) => e.stopPropagation()}
rel='nofollow noopener'
target='_blank'
title={domNode.attribs.href}
>
{domToReact(domNode.children as DOMNode[], options)}
</a>
);
}
},
};
const content = parse(html.__html, options);
return (
<Text ref={ref} {...props} data-markup>
{content}
</Text>
);
});

View File

@ -1,139 +0,0 @@
import userEvent from '@testing-library/user-event';
import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux';
import { beforeEach, describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { mockStore, render, screen, rootState } from 'soapbox/jest/test-helpers.tsx';
import { type Poll } from 'soapbox/schemas/index.ts';
import PollFooter from './poll-footer.tsx';
let poll: Poll = {
id: '1',
options: [{
title: 'Apples',
votes_count: 0,
title_emojified: 'Apples',
}, {
title: 'Oranges',
votes_count: 0,
title_emojified: 'Oranges',
}],
emojis: [],
expired: false,
expires_at: '2020-03-24T19:33:06.000Z',
multiple: true,
voters_count: 0,
votes_count: 0,
own_votes: null,
voted: false,
};
describe('<PollFooter />', () => {
describe('with "showResults" enabled', () => {
it('renders the Refresh button', () => {
render(<PollFooter poll={poll} showResults selected={{}} />);
expect(screen.getByTestId('poll-footer')).toHaveTextContent('Refresh');
});
it('responds to the Refresh button', async() => {
__stub((mock) => {
mock.onGet('/api/v1/polls/1').reply(200, {});
});
const user = userEvent.setup();
const store = mockStore(rootState);
render(
<Provider store={store}>
<IntlProvider locale='en'>
<PollFooter poll={poll} showResults selected={{}} />
</IntlProvider>
</Provider>,
);
await user.click(screen.getByTestId('poll-refresh'));
const actions = store.getActions();
expect(actions).toEqual([
{ type: 'POLL_FETCH_REQUEST' },
{ type: 'POLLS_IMPORT', polls: [{}] },
{ type: 'POLL_FETCH_SUCCESS', poll: {} },
]);
});
it('does not render the Vote button', () => {
render(<PollFooter poll={poll} showResults selected={{}} />);
expect(screen.queryAllByTestId('button')).toHaveLength(0);
});
describe('when the Poll has not expired', () => {
beforeEach(() => {
poll = {
...poll,
expired: false,
};
});
it('renders time remaining', () => {
render(<PollFooter poll={poll} showResults selected={{}} />);
expect(screen.getByTestId('poll-expiration')).toHaveTextContent('Moments remaining');
});
});
describe('when the Poll has expired', () => {
beforeEach(() => {
poll = {
...poll,
expired: true,
};
});
it('renders closed', () => {
render(<PollFooter poll={poll} showResults selected={{}} />);
expect(screen.getByTestId('poll-expiration')).toHaveTextContent('Closed');
});
});
});
describe('with "showResults" disabled', () => {
it('does not render the Refresh button', () => {
render(<PollFooter poll={poll} showResults={false} selected={{}} />);
expect(screen.getByTestId('poll-footer')).not.toHaveTextContent('Refresh');
});
describe('when the Poll is multiple', () => {
beforeEach(() => {
poll = {
...poll,
multiple: true,
};
});
it('renders the Vote button', () => {
render(<PollFooter poll={poll} showResults={false} selected={{}} />);
expect(screen.getByTestId('button')).toHaveTextContent('Vote');
});
});
describe('when the Poll is not multiple', () => {
beforeEach(() => {
poll = {
...poll,
multiple: false,
};
});
it('does not render the Vote button', () => {
render(<PollFooter poll={poll} showResults={false} selected={{}} />);
expect(screen.queryAllByTestId('button')).toHaveLength(0);
});
});
});
});

View File

@ -7,6 +7,7 @@ import { Motion, presets, spring } from 'react-motion';
import HStack from 'soapbox/components/ui/hstack.tsx';
import Icon from 'soapbox/components/ui/icon.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import type {
Poll as PollEntity,
@ -67,12 +68,9 @@ const PollOptionText: React.FC<IPollOptionText> = ({ poll, option, index, active
<div className='grid w-full items-center'>
<div className='col-start-1 row-start-1 ml-4 mr-6 justify-self-center'>
<div className='text-primary-600 dark:text-white'>
<Text
theme='inherit'
weight='medium'
align='center'
dangerouslySetInnerHTML={{ __html: option.title_emojified }}
/>
<Text theme='inherit' weight='medium' align='center'>
{emojifyText(option.title, poll.emojis)}
</Text>
</div>
</div>
@ -135,12 +133,9 @@ const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
<PollPercentageBar percent={percent} leading={leading} />
<div className='text-primary-600 dark:text-white'>
<Text
theme='inherit'
weight='medium'
dangerouslySetInnerHTML={{ __html: option.title_emojified }}
className='relative'
/>
<Text theme='inherit' weight='medium' className='relative'>
{emojifyText(option.title, poll.emojis)}
</Text>
</div>
<HStack space={2} alignItems='center' className='relative'>

View File

@ -102,7 +102,6 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
});
if (!account) return null;
const accountBio = { __html: account.note_emojified };
const memberSinceDate = intl.formatDate(account.created_at, { month: 'long', year: 'numeric' });
const followedBy = me !== account.id && account.relationship?.followed_by === true;
@ -145,7 +144,7 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
) : null}
{account.note.length > 0 && (
<Text size='sm' dangerouslySetInnerHTML={accountBio} />
<Text size='sm' dangerouslySetInnerHTML={{ __html: account.note }} />
)}
</Stack>

View File

@ -1,24 +1,19 @@
import chevronRightIcon from '@tabler/icons/outline/chevron-right.svg';
import clsx from 'clsx';
import graphemesplit from 'graphemesplit';
import parse, { Element, type HTMLReactParserOptions, domToReact, type DOMNode, Text as DOMText } from 'html-react-parser';
import { useState, useRef, useLayoutEffect, useMemo, memo } from 'react';
import { FormattedMessage } from 'react-intl';
import Icon from 'soapbox/components/icon.tsx';
import { isOnlyEmoji as _isOnlyEmoji } from 'soapbox/utils/only-emoji.ts';
import { getTextDirection } from 'soapbox/utils/rtl.ts';
import { getTextDirection } from '../utils/rtl.ts';
import HashtagLink from './hashtag-link.tsx';
import Markup from './markup.tsx';
import Mention from './mention.tsx';
import Poll from './polls/poll.tsx';
import type { Sizes } from 'soapbox/components/ui/text.tsx';
import type { Status } from 'soapbox/types/entities.ts';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
const BIG_EMOJI_LIMIT = 10;
interface IReadMoreButton {
onClick: React.MouseEventHandler;
@ -51,11 +46,7 @@ const StatusContent: React.FC<IStatusContent> = ({
const [collapsed, setCollapsed] = useState(false);
const node = useRef<HTMLDivElement>(null);
const isOnlyEmoji = useMemo(() => {
const textContent = new DOMParser().parseFromString(status.content, 'text/html').body.textContent ?? '';
return Boolean(/^\p{Extended_Pictographic}+$/u.test(textContent) && (graphemesplit(textContent).length <= BIG_EMOJI_LIMIT));
}, [status.content]);
const isOnlyEmoji = useMemo(() => _isOnlyEmoji(status.content, status.emojis.toJS(), 10), [status.content]);
const maybeSetCollapsed = (): void => {
if (!node.current) return;
@ -83,85 +74,6 @@ const StatusContent: React.FC<IStatusContent> = ({
const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none';
const options: HTMLReactParserOptions = {
replace(domNode) {
if (domNode instanceof Element && ['script', 'iframe'].includes(domNode.name)) {
return null;
}
if (domNode instanceof DOMText) {
const parts: Array<string | JSX.Element> = [];
const textNodes = domNode.data.split(/:\w+:/);
const shortcodes = [...domNode.data.matchAll(/:(\w+):/g)];
for (let i = 0; i < textNodes.length; i++) {
parts.push(textNodes[i]);
if (shortcodes[i]) {
const [text, shortcode] = shortcodes[i];
const customEmoji = status.emojis.find((e) => e.shortcode === shortcode);
if (customEmoji) {
parts.push(<img key={i} src={customEmoji.url} alt={shortcode} className='inline-block h-[1em]' />);
} else {
parts.push(text);
}
}
}
return <>{parts}</>;
}
if (domNode instanceof Element && domNode.name === 'a') {
const classes = domNode.attribs.class?.split(' ');
if (classes?.includes('hashtag')) {
const child = domToReact(domNode.children as DOMNode[]);
const hashtag: string | undefined = (() => {
// Mastodon wraps the hashtag in a span, with a sibling text node containing the hashtag.
if (Array.isArray(child) && child.length) {
if (child[0]?.props?.children === '#' && typeof child[1] === 'string') {
return child[1];
}
}
// Pleroma renders a string directly inside the hashtag link.
if (typeof child === 'string') {
return child.replace(/^#/, '');
}
})();
if (hashtag) {
return <HashtagLink hashtag={hashtag} />;
}
}
if (classes?.includes('mention')) {
const mention = status.mentions.find(({ url }) => domNode.attribs.href === url);
if (mention) {
return <Mention mention={mention} />;
}
}
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<a
{...domNode.attribs}
onClick={(e) => e.stopPropagation()}
rel='nofollow noopener'
target='_blank'
title={domNode.attribs.href}
>
{domToReact(domNode.children as DOMNode[], options)}
</a>
);
}
},
};
const content = parse(parsedHtml, options);
const direction = getTextDirection(status.search_index);
const className = clsx(baseClassName, {
'cursor-pointer': onClick,
@ -180,9 +92,10 @@ const StatusContent: React.FC<IStatusContent> = ({
direction={direction}
lang={status.language || undefined}
size={textSize}
>
{content}
</Markup>,
emojis={status.emojis.toJS()}
mentions={status.mentions.toJS()}
html={{ __html: parsedHtml }}
/>,
];
if (collapsed) {
@ -207,9 +120,10 @@ const StatusContent: React.FC<IStatusContent> = ({
direction={direction}
lang={status.language || undefined}
size={textSize}
>
{content}
</Markup>,
emojis={status.emojis.toJS()}
mentions={status.mentions.toJS()}
html={{ __html: parsedHtml }}
/>,
];
if (status.poll && typeof status.poll === 'string') {

View File

@ -20,6 +20,7 @@ import QuotedStatus from 'soapbox/features/status/containers/quoted-status-conta
import { HotKeys } from 'soapbox/features/ui/components/hotkeys.tsx';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useSettings } from 'soapbox/hooks/useSettings.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status.ts';
import EventPreview from './event-preview.tsx';
@ -231,23 +232,17 @@ const Status: React.FC<IStatus> = (props) => {
className='hover:underline'
>
<bdi className='truncate'>
<strong
className='text-gray-800 dark:text-gray-200'
dangerouslySetInnerHTML={{
__html: status.account.display_name_html,
}}
/>
<strong className='text-gray-800 dark:text-gray-200'>
{emojifyText(status.account.display_name, status.account.emojis)}
</strong>
</bdi>
</Link>
),
group: (
<Link to={`/group/${group.slug}`} className='hover:underline'>
<strong
className='text-gray-800 dark:text-gray-200'
dangerouslySetInnerHTML={{
__html: group.display_name_html,
}}
/>
<strong className='text-gray-800 dark:text-gray-200'>
{group.display_name}
</strong>
</Link>
),
}}
@ -268,12 +263,9 @@ const Status: React.FC<IStatus> = (props) => {
name: (
<Link to={`/@${status.account.acct}`} className='hover:underline'>
<bdi className='truncate'>
<strong
className='text-gray-800 dark:text-gray-200'
dangerouslySetInnerHTML={{
__html: status.account.display_name_html,
}}
/>
<strong className='text-gray-800 dark:text-gray-200'>
{emojifyText(status.account.display_name, status.account.emojis)}
</strong>
</bdi>
</Link>
),
@ -306,7 +298,7 @@ const Status: React.FC<IStatus> = (props) => {
<Link to={`/group/${group.slug}`} className='hover:underline'>
<bdi className='truncate'>
<strong className='text-gray-800 dark:text-gray-200'>
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
<span>{group.display_name}</span>
</strong>
</bdi>
</Link>

View File

@ -129,7 +129,7 @@ const SensitiveContentOverlay = forwardRef<HTMLDivElement, ISensitiveContentOver
<div className='py-4 italic'>
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
<Text className='line-clamp-6' theme='white' size='md' weight='medium'>
&ldquo;<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} />&rdquo;
&ldquo;<span>{status.spoiler_text}</span>&rdquo;
</Text>
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
</div>

View File

@ -11,7 +11,7 @@ const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
const px = `${size}px`;
return (
<div className='inline-flex items-center justify-center font-emoji leading-[0]' style={{ width: px, height: px, fontSize: px }}>
<div className='inline-flex select-none items-center justify-center font-emoji leading-[0]' style={{ width: px, height: px, fontSize: px }}>
{emoji}
</div>
);

View File

@ -27,7 +27,7 @@ const MovedNote: React.FC<IMovedNote> = ({ from, to }) => (
id='notification.move'
defaultMessage='{name} moved to {targetName}'
values={{
name: <span dangerouslySetInnerHTML={{ __html: from.display_name_html }} />,
name: from.display_name,
targetName: to.acct,
}}
/>

View File

@ -48,7 +48,7 @@ const Announcement: React.FC<IAnnouncement> = ({ announcement }) => {
return (
<div key={announcement.id} className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
<Stack space={2}>
<Text dangerouslySetInnerHTML={{ __html: announcement.contentHtml }} />
<Text dangerouslySetInnerHTML={{ __html: announcement.content }} />
{(announcement.starts_at || announcement.ends_at || announcement.all_day) && (
<HStack space={2} wrap>
{announcement.starts_at && (

View File

@ -1,7 +1,6 @@
import clsx from 'clsx';
import Text from 'soapbox/components/ui/text.tsx';
import emojify from 'soapbox/features/emoji/index.ts';
import { EmojiReaction } from 'soapbox/types/entities.ts';
interface IChatMessageReaction {
@ -35,7 +34,7 @@ const ChatMessageReaction = (props: IChatMessageReaction) => {
})
}
>
<span dangerouslySetInnerHTML={{ __html: emojify(emojiReaction.name) }} />
<span>{emojiReaction.name}</span>
<Text tag='span' weight='medium' size='sm'>{emojiReaction.count}</Text>
</button>
);

View File

@ -6,8 +6,6 @@ import moodSmileIcon from '@tabler/icons/outline/mood-smile.svg';
import trashIcon from '@tabler/icons/outline/trash.svg';
import { useMutation } from '@tanstack/react-query';
import clsx from 'clsx';
import graphemesplit from 'graphemesplit';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import escape from 'lodash/escape';
import { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@ -19,14 +17,14 @@ import HStack from 'soapbox/components/ui/hstack.tsx';
import Icon from 'soapbox/components/ui/icon.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import emojify from 'soapbox/features/emoji/index.ts';
import { MediaGallery } from 'soapbox/features/ui/util/async-components.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
import { ChatKeys, IChat, useChatActions } from 'soapbox/queries/chats.ts';
import { queryClient } from 'soapbox/queries/client.ts';
import { stripHTML } from 'soapbox/utils/html.ts';
import { htmlToPlaintext } from 'soapbox/utils/html.ts';
import { isOnlyEmoji as _isOnlyEmoji } from 'soapbox/utils/only-emoji.ts';
import ChatMessageReactionWrapper from './chat-message-reaction-wrapper/chat-message-reaction-wrapper.tsx';
import ChatMessageReaction from './chat-message-reaction.tsx';
@ -42,23 +40,13 @@ const messages = defineMessages({
report: { id: 'chats.actions.report', defaultMessage: 'Report' },
});
const BIG_EMOJI_LIMIT = 3;
const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap<string, any>, emoji: ImmutableMap<string, any>) => {
return map.set(`:${emoji.get('shortcode')}:`, emoji);
}, ImmutableMap());
const parsePendingContent = (content: string) => {
return escape(content).replace(/(?:\r\n|\r|\n)/g, '<br>');
};
const parseContent = (chatMessage: ChatMessageEntity) => {
const content = chatMessage.content || '';
const pending = chatMessage.pending;
const deleting = chatMessage.deleting;
const formatted = (pending && !deleting) ? parsePendingContent(content) : content;
const emojiMap = makeEmojiMap(chatMessage);
return emojify(formatted, emojiMap.toJS());
const { content, pending, deleting } = chatMessage;
return (pending && !deleting) ? parsePendingContent(content) : content;
};
interface IChatMessage {
@ -98,10 +86,7 @@ const ChatMessage = (props: IChatMessage) => {
&& lastReadMessageTimestamp
&& lastReadMessageTimestamp >= new Date(chatMessage.created_at);
const isOnlyEmoji = useMemo(() => {
const textContent = new DOMParser().parseFromString(content, 'text/html').body.textContent ?? '';
return Boolean(/^\p{Extended_Pictographic}+$/u.test(textContent) && (graphemesplit(textContent).length <= BIG_EMOJI_LIMIT));
}, [content]);
const isOnlyEmoji = useMemo(() => _isOnlyEmoji(content, props.chatMessage.emojis.toJS(), 3), [content]);
const emojiReactionRows = useMemo(() => {
if (!chatMessage.emoji_reactions) {
@ -136,7 +121,7 @@ const ChatMessage = (props: IChatMessage) => {
const handleCopyText = (chatMessage: ChatMessageEntity) => {
if (navigator.clipboard) {
const text = stripHTML(chatMessage.content);
const text = htmlToPlaintext(chatMessage.content);
navigator.clipboard.writeText(text);
}
};

View File

@ -8,6 +8,7 @@ import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import VerificationBadge from 'soapbox/components/verification-badge.tsx';
import useAccountSearch from 'soapbox/queries/search.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import type { Account } from 'soapbox/types/entities.ts';
@ -41,7 +42,10 @@ const Results = ({ accountSearchResult, onSelect }: IResults) => {
<Stack alignItems='start'>
<div className='flex grow items-center space-x-1'>
<Text weight='bold' size='sm' truncate>{account.display_name}</Text>
<Text weight='bold' size='sm' truncate>
{emojifyText(account.display_name, account.emojis)}
</Text>
{account.verified && <VerificationBadge />}
</div>
<Text size='sm' weight='medium' theme='muted' direction='ltr' truncate>@{account.acct}</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}

View File

@ -29,10 +29,11 @@ const ReplyGroupIndicator = (props: IReplyGroupIndicator) => {
id='compose.reply_group_indicator.message'
defaultMessage='Posting to {groupLink}'
values={{
groupLink: <Link
to={`/group/${group.slug}`}
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
/>,
groupLink: (
<Link to={`/group/${group.slug}`}>
{group.display_name}
</Link>
),
}}
/>
</Text>

View File

@ -39,7 +39,7 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActi
<Stack space={2} className={clsx('max-h-72 overflow-y-auto rounded-lg bg-gray-100 p-4 black:bg-gray-900 dark:bg-gray-800', className)}>
<AccountContainer
{...actions}
id={status.getIn(['account', 'id']) as string}
id={status.account.id}
timestamp={status.created_at}
showProfileHoverCard={false}
withLinkToProfile={false}
@ -49,8 +49,10 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActi
<Markup
className='break-words'
size='sm'
dangerouslySetInnerHTML={{ __html: status.content }}
direction={getTextDirection(status.search_index)}
emojis={status.emojis.toJS()}
mentions={status.mentions.toJS()}
html={{ __html: status.content }}
/>
{status.media_attachments.size > 0 && (

View File

@ -57,8 +57,9 @@ const AccountCard: React.FC<IAccountCard> = ({ id }) => {
truncate
align='left'
className='[&_br]:hidden [&_p:first-child]:inline [&_p:first-child]:truncate [&_p]:hidden'
dangerouslySetInnerHTML={{ __html: account.note_emojified || '&nbsp;' }}
/>
>
{account.note}
</Text>
</Stack>
<div className='grid grid-cols-3 gap-1 py-4'>

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,3 @@
import split from 'graphemesplit';
import unicodeMapping from './mapping.ts';
import type { Emoji as EmojiMart, CustomEmoji as EmojiMartCustom } from 'soapbox/features/emoji/data.ts';
import type { CustomEmoji as MastodonCustomEmoji } from 'soapbox/schemas/custom-emoji.ts';
@ -44,112 +40,6 @@ export function isNativeEmoji(emoji: Emoji): emoji is NativeEmoji {
return (emoji as NativeEmoji).native !== undefined;
}
const isAlphaNumeric = (c: string) => {
const code = c.charCodeAt(0);
if (!(code > 47 && code < 58) && // numeric (0-9)
!(code > 64 && code < 91) && // upper alpha (A-Z)
!(code > 96 && code < 123)) { // lower alpha (a-z)
return false;
} else {
return true;
}
};
const validEmojiChar = (c: string) => {
return isAlphaNumeric(c)
|| c === '_'
|| c === '-'
|| c === '.';
};
const convertCustom = (shortname: string, filename: string) => {
return `<img draggable="false" class="inline-block w-4 h-4" alt="${shortname}" title="${shortname}" src="${filename}" />`;
};
const convertUnicode = (c: string) => {
return c;
};
const convertEmoji = (str: string, customEmojis: any) => {
if (str.length < 3) return str;
if (str in customEmojis) {
const emoji = customEmojis[str];
const filename = emoji.static_url;
if (filename?.length > 0) {
return convertCustom(str, filename);
}
}
return str;
};
export const emojifyText = (str: string, customEmojis = {}) => {
let buf = '';
let stack = '';
let open = false;
const clearStack = () => {
buf += stack;
open = false;
stack = '';
};
for (let c of split(str)) {
// convert FE0E selector to FE0F so it can be found in unimap
if (c.codePointAt(c.length - 1) === 65038) {
c = c.slice(0, -1) + String.fromCodePoint(65039);
}
// unqualified emojis aren't in emoji-mart's mappings so we just add FEOF
const unqualified = c + String.fromCodePoint(65039);
if (c in unicodeMapping) {
if (open) { // unicode emoji inside colon
clearStack();
}
buf += convertUnicode(c);
} else if (unqualified in unicodeMapping) {
if (open) { // unicode emoji inside colon
clearStack();
}
buf += convertUnicode(unqualified);
} else if (c === ':') {
stack += ':';
// we see another : we convert it and clear the stack buffer
if (open) {
buf += convertEmoji(stack, customEmojis);
stack = '';
}
open = !open;
} else {
if (open) {
stack += c;
// if the stack is non-null and we see invalid chars it's a string not emoji
// so we push it to the return result and clear it
if (!validEmojiChar(c)) {
clearStack();
}
} else {
buf += c;
}
}
}
// never found a closing colon so it's just a raw string
if (open) {
buf += stack;
}
return buf;
};
export const parseHTML = (str: string): { text: boolean; data: string }[] => {
const tokens = [];
let buf = '';
@ -194,19 +84,6 @@ export const parseHTML = (str: string): { text: boolean; data: string }[] => {
return tokens;
};
const emojify = (str: string, customEmojis = {}) => {
return parseHTML(str)
.map(({ text, data }) => {
if (!text) return data;
if (data.length === 0 || data === ' ') return data;
return emojifyText(data, customEmojis);
})
.join('');
};
export default emojify;
export function buildCustomEmojis(customEmojis: MastodonCustomEmoji[]): EmojiMart<EmojiMartCustom>[] {
const emojis: EmojiMart<EmojiMartCustom>[] = [];

View File

@ -459,7 +459,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
name: (
<Link className='mention inline-block' to={`/@${account.acct}`}>
<HStack space={1} alignItems='center' grow>
<span dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
<span>{account.display_name}</span>
{account.verified && <VerificationBadge />}
</HStack>
</Link>

View File

@ -8,6 +8,7 @@ import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import VerificationBadge from 'soapbox/components/verification-badge.tsx';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import ActionButton from '../ui/components/action-button.tsx';
import { HotKeys } from '../ui/components/hotkeys.tsx';
@ -42,12 +43,13 @@ const SuggestionItem: React.FC<ISuggestionItem> = ({ accountId }) => {
<HStack alignItems='center' justifyContent='center' space={1}>
<Text
weight='semibold'
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
truncate
align='center'
size='sm'
className='max-w-[95%]'
/>
>
{emojifyText(account.display_name, account.emojis)}
</Text>
{account.verified && <VerificationBadge />}
</HStack>

View File

@ -139,12 +139,9 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
</div>
<Stack alignItems='center' space={3} className='mx-auto mt-10 w-5/6 py-4'>
<Text
size='xl'
weight='bold'
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
data-testid='group-name'
/>
<Text size='xl' weight='bold' data-testid='group-name'>
{group.display_name}
</Text>
{!isDeleted && (
<>
@ -158,7 +155,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
<Text
theme='muted'
align='center'
dangerouslySetInnerHTML={{ __html: group.note_emojified }}
dangerouslySetInnerHTML={{ __html: group.note }}
className='[&_a]:text-primary-600 [&_a]:hover:underline [&_a]:dark:text-accent-blue'
/>
</Stack>

View File

@ -17,6 +17,7 @@ import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useInstance } from 'soapbox/hooks/useInstance.ts';
import toast from 'soapbox/toast.tsx';
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts.ts';
import { htmlToPlaintext } from 'soapbox/utils/html.ts';
import AvatarPicker from '../edit-profile/components/avatar-picker.tsx';
import HeaderPicker from '../edit-profile/components/header-picker.tsx';
@ -54,7 +55,7 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { groupId } }) => {
const header = useImageField({ maxPixels: 1920 * 1080, preview: nonDefaultHeader(group?.header) });
const displayName = useTextField(group?.display_name);
const note = useTextField(group?.note_plain);
const note = useTextField(htmlToPlaintext(group?.note || ''));
const maxName = Number(instance.configuration.groups.max_characters_name);
const maxNote = Number(instance.configuration.groups.max_characters_description);

View File

@ -90,7 +90,7 @@ const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
<List>
<ListItem label={intl.formatMessage(messages.editGroup)} to={`/group/${group.slug}/manage/edit`}>
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
{group.display_name}
</ListItem>
</List>
</>

View File

@ -51,12 +51,9 @@ const GroupGridItem = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDiv
/>
<Stack space={1}>
<Text
weight='bold'
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
theme='inherit'
truncate
/>
<Text weight='bold' theme='inherit' truncate>
{group.display_name}
</Text>
<HStack alignItems='center' space={1}>
<GroupPrivacy group={group} />

View File

@ -34,11 +34,9 @@ const GroupListItem = (props: IGroupListItem) => {
/>
<Stack className='overflow-hidden'>
<Text
weight='bold'
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
truncate
/>
<Text weight='bold' truncate>
{group.display_name}
</Text>
<HStack className='text-gray-700 dark:text-gray-600' space={1} alignItems='center'>
<Icon

View File

@ -28,7 +28,7 @@ const GroupLinkPreview: React.FC<IGroupLinkPreview> = ({ card }) => {
/>
<Stack space={4} className='p-4'>
<CardTitle title={<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />} />
<CardTitle title={group.display_name} />
<Button theme='primary' to={`/group/${group.slug}`} block>
<FormattedMessage id='group.popover.action' defaultMessage='View Group' />

View File

@ -19,8 +19,8 @@ const SiteBanner: React.FC = () => {
<Markup
size='lg'
dangerouslySetInnerHTML={{ __html: description }}
direction={getTextDirection(description)}
html={{ __html: description }}
/>
</Stack>
);

View File

@ -39,12 +39,9 @@ const GroupListItem = ({ group, onUnmute }: IGroupListItem) => {
size={42}
/>
<Text
weight='semibold'
size='sm'
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
truncate
/>
<Text weight='semibold' size='sm' truncate>
{group.display_name}
</Text>
</HStack>
<Button theme='primary' type='button' onClick={handleUnmute} size='sm'>

View File

@ -37,6 +37,7 @@ import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useInstance } from 'soapbox/hooks/useInstance.ts';
import { makeGetNotification } from 'soapbox/selectors/index.ts';
import toast from 'soapbox/toast.tsx';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import { NotificationType, validType } from 'soapbox/utils/notification.ts';
import type { ScrollPosition } from 'soapbox/components/status.tsx';
@ -56,8 +57,9 @@ const buildLink = (account: AccountEntity): JSX.Element => (
className='font-bold text-gray-800 hover:underline dark:text-gray-200'
title={account.acct}
to={`/@${account.acct}`}
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
/>
>
{emojifyText(account.display_name, account.emojis)}
</Link>
</bdi>
);

View File

@ -76,7 +76,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
<Link to={`/group/${(status.group as Group).slug}`} className='hover:underline'>
<bdi className='truncate'>
<strong className='text-gray-800 dark:text-gray-200'>
<span dangerouslySetInnerHTML={{ __html: (status.group as Group).display_name_html }} />
<span>{status.group.display_name}</span>
</strong>
</bdi>
</Link>

View File

@ -4,7 +4,6 @@ import { Link } from 'react-router-dom';
import { logOut } from 'soapbox/actions/auth.ts';
import Text from 'soapbox/components/ui/text.tsx';
import emojify from 'soapbox/features/emoji/index.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
@ -70,10 +69,9 @@ const LinkFooter: React.FC = (): JSX.Element => {
<Text theme='muted' size='sm'>
{soapboxConfig.linkFooterMessage ? (
<span
className='inline-block align-middle'
dangerouslySetInnerHTML={{ __html: emojify(soapboxConfig.linkFooterMessage) }}
/>
<span className='inline-block align-middle'>
{soapboxConfig.linkFooterMessage}
</span>
) : (
<FormattedMessage
id='getting_started.open_source_notice'

View File

@ -43,26 +43,23 @@ const CompareHistoryModal: React.FC<ICompareHistoryModal> = ({ onClose, statusId
body = (
<div className='divide-y divide-solid divide-gray-200 dark:divide-gray-800'>
{versions?.map((version) => {
const content = { __html: version.content };
const spoilerContent = { __html: version.spoilerHtml };
const poll = typeof version.poll !== 'string' && version.poll;
return (
<div className='flex flex-col py-2 first:pt-0 last:pb-0'>
{version.spoiler_text?.length > 0 && (
<>
<span dangerouslySetInnerHTML={spoilerContent} />
<span>{version.spoiler_text}</span>
<hr />
</>
)}
<div className='whitespace-normal p-0 pt-2.5 text-sm text-gray-700 dark:text-gray-500' dangerouslySetInnerHTML={content} />
<div className='whitespace-normal p-0 pt-2.5 text-sm text-gray-700 dark:text-gray-500' dangerouslySetInnerHTML={{ __html: version.content }} />
{poll && (
<div>
<Stack>
{version.poll.options.map((option: any) => (
{version.poll.options.map((option) => (
<HStack alignItems='center' className='p-1 text-gray-900 dark:text-gray-300'>
<span
className={clsx('mr-2.5 inline-block size-4 flex-none rounded-full border border-solid border-primary-600', {
@ -72,7 +69,7 @@ const CompareHistoryModal: React.FC<ICompareHistoryModal> = ({ onClose, statusId
role={poll.multiple ? 'checkbox' : 'radio'}
/>
<span dangerouslySetInnerHTML={{ __html: option.title_emojified }} />
<span>{option.title}</span>
</HStack>
))}
</Stack>

View File

@ -7,6 +7,7 @@ import Spinner from 'soapbox/components/ui/spinner.tsx';
import AccountContainer from 'soapbox/containers/account-container.tsx';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { makeGetAccount } from 'soapbox/selectors/index.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
const getAccount = makeGetAccount();
@ -28,7 +29,13 @@ const FamiliarFollowersModal = ({ accountId, onClose }: IFamiliarFollowersModal)
if (!account || !familiarFollowerIds) {
body = <Spinner />;
} else {
const emptyMessage = <FormattedMessage id='account.familiar_followers.empty' defaultMessage='No one you know follows {name}.' values={{ name: <span dangerouslySetInnerHTML={{ __html: account.display_name_html }} /> }} />;
const emptyMessage = (
<FormattedMessage
id='account.familiar_followers.empty'
defaultMessage='No one you know follows {name}.'
values={{ name: emojifyText(account.display_name, account.emojis) }}
/>
);
body = (
<ScrollableList
@ -47,7 +54,13 @@ const FamiliarFollowersModal = ({ accountId, onClose }: IFamiliarFollowersModal)
return (
<Modal
title={<FormattedMessage id='column.familiar_followers' defaultMessage='People you know following {name}' values={{ name: <span dangerouslySetInnerHTML={{ __html: account?.display_name_html || '' }} /> }} />}
title={(
<FormattedMessage
id='column.familiar_followers'
defaultMessage='People you know following {name}'
values={{ name: account ? emojifyText(account.display_name, account.emojis) : '' }}
/>
)}
onClose={onClickClose}
>
{body}

View File

@ -63,7 +63,7 @@ const ConfirmationStep: React.FC<IConfirmationStep> = ({ group }) => {
<Text
size='md'
className='mx-auto max-w-sm [&_a]:text-primary-600 [&_a]:hover:underline [&_a]:dark:text-accent-blue'
dangerouslySetInnerHTML={{ __html: group.note_emojified }}
dangerouslySetInnerHTML={{ __html: group.note }}
/>
</Stack>
</Stack>

View File

@ -15,6 +15,7 @@ import Modal from 'soapbox/components/ui/modal.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { ZapSplitData } from 'soapbox/schemas/zap-split.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import type { Account as AccountEntity } from 'soapbox/types/entities.ts';
@ -48,7 +49,13 @@ const ZapInvoiceModal: React.FC<IZapInvoice> = ({ account, invoice, splitData, o
};
const renderTitle = () => {
return <FormattedMessage id='zap.send_to' defaultMessage='Send zaps to {target}' values={{ target: account.display_name }} />;
return (
<FormattedMessage
id='zap.send_to'
defaultMessage='Send zaps to {target}'
values={{ target: emojifyText(account.display_name, account.emojis) }}
/>
);
};
const handleNext = () => {

View File

@ -8,6 +8,7 @@ import Button from 'soapbox/components/ui/button.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import { ZapSplitData } from 'soapbox/schemas/zap-split.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
const messages = defineMessages({
zap_open_wallet: { id: 'zap.open_wallet', defaultMessage: 'Open Wallet' },
@ -31,7 +32,11 @@ const ZapSplit = ({ zapData, zapAmount, invoice, onNext, isLastStep, onFinish }:
const renderTitleQr = () => {
return (
<div className='max-w-[280px] truncate'>
<FormattedMessage id='zap.send_to' defaultMessage='Send zaps to {target}' values={{ target: account.display_name }} />
<FormattedMessage
id='zap.send_to'
defaultMessage='Send zaps to {target}'
values={{ target: emojifyText(account.display_name, account.emojis) }}
/>
</div>
);
};

View File

@ -37,7 +37,7 @@ const PinnedAccountsPanel: React.FC<IPinnedAccountsPanel> = ({ account, limit })
id='pinned_accounts.title'
defaultMessage='{name}s choices'
values={{
name: <span dangerouslySetInnerHTML={{ __html: account.display_name_html }} />,
name: account.display_name,
}}
/>}
>

View File

@ -14,6 +14,7 @@ import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
import { makeGetAccount } from 'soapbox/selectors/index.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import type { Account } from 'soapbox/schemas/index.ts';
@ -50,12 +51,9 @@ const ProfileFamiliarFollowers: React.FC<IProfileFamiliarFollowers> = ({ account
<HoverRefWrapper accountId={account.id} key={account.id} inline>
<Link className='inline-block text-primary-600 hover:underline dark:text-accent-blue' to={`/@${account.acct}`}>
<HStack space={1} alignItems='center' grow>
<Text
size='sm'
theme='primary'
truncate
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
/>
<Text size='sm' theme='primary' truncate>
{emojifyText(account.display_name, account.emojis)}
</Text>
{account.verified && <VerificationBadge />}
</HStack>

View File

@ -7,6 +7,7 @@ import Markup from 'soapbox/components/markup.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import Icon from 'soapbox/components/ui/icon.tsx';
import { CryptoAddress, LightningAddress } from 'soapbox/features/ui/util/async-components.ts';
import { htmlToPlaintext } from 'soapbox/utils/html.ts';
import type { Account } from 'soapbox/schemas/index.ts';
@ -34,27 +35,28 @@ interface IProfileField {
/** Renders a single profile field. */
const ProfileField: React.FC<IProfileField> = ({ field }) => {
const intl = useIntl();
const valuePlain = htmlToPlaintext(field.value);
if (isTicker(field.name)) {
return (
<CryptoAddress
ticker={getTicker(field.name).toLowerCase()}
address={field.value_plain}
address={valuePlain}
/>
);
} else if (isZapEmoji(field.name)) {
return <LightningAddress address={field.value_plain} />;
return <LightningAddress address={valuePlain} />;
}
return (
<dl>
<dt title={field.name}>
<Markup weight='bold' tag='span' dangerouslySetInnerHTML={{ __html: field.name_emojified }} />
{field.name}
</dt>
<dd
className={clsx({ 'text-success-500': field.verified_at })}
title={field.value_plain}
title={valuePlain}
>
<HStack space={2} alignItems='center'>
{field.verified_at && (
@ -63,7 +65,11 @@ const ProfileField: React.FC<IProfileField> = ({ field }) => {
</span>
)}
<Markup className='overflow-hidden break-words' tag='span' dangerouslySetInnerHTML={{ __html: field.value_emojified }} />
<Markup
className='overflow-hidden break-words'
tag='span'
html={{ __html: field.value }}
/>
</HStack>
</dd>
</dl>

View File

@ -15,6 +15,7 @@ import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import { capitalize } from 'soapbox/utils/strings.ts';
import ProfileFamiliarFollowers from './profile-familiar-followers.tsx';
@ -142,7 +143,6 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
}
const deactivated = account.pleroma?.deactivated ?? false;
const displayNameHtml = deactivated ? { __html: intl.formatMessage(messages.deactivated) } : { __html: account.display_name_html };
const memberSinceDate = intl.formatDate(account.created_at, { month: 'long', year: 'numeric' });
const badges = getBadges();
@ -151,7 +151,9 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
<Stack space={2}>
<Stack>
<HStack space={1} alignItems='center'>
<Text size='lg' weight='bold' dangerouslySetInnerHTML={displayNameHtml} truncate />
<Text size='lg' weight='bold' truncate>
{deactivated ? intl.formatMessage(messages.deactivated) : emojifyText(account.display_name, account.emojis)}
</Text>
{account.bot && <Badge slug='bot' title={intl.formatMessage(messages.bot)} />}
@ -181,7 +183,7 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
<ProfileStats account={account} />
{account.note.length > 0 && (
<Markup size='sm' dangerouslySetInnerHTML={{ __html: account.note_emojified }} truncate />
<Markup size='sm' html={{ __html: account.note }} emojis={account.emojis} truncate />
)}
<div className='flex flex-col items-start gap-2 md:flex-row md:flex-wrap md:items-center'>

View File

@ -10,6 +10,7 @@ import Text from 'soapbox/components/ui/text.tsx';
import VerificationBadge from 'soapbox/components/verification-badge.tsx';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { getAcct } from 'soapbox/utils/accounts.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import { shortNumberFormat } from 'soapbox/utils/numbers.tsx';
import { displayFqn } from 'soapbox/utils/state.ts';
@ -26,7 +27,6 @@ const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain })
const fqn = useAppSelector((state) => displayFqn(state));
if (!account) return null;
const displayNameHtml = { __html: account.display_name_html };
const acct = !account.acct.includes('@') && domain ? `${account.acct}@${domain}` : account.acct;
const header = account.header;
const verified = account.verified;
@ -59,7 +59,9 @@ const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain })
<Stack>
<Link to={`/@${account.acct}`}>
<HStack space={1} alignItems='center'>
<Text size='lg' weight='bold' dangerouslySetInnerHTML={displayNameHtml} truncate />
<Text size='lg' weight='bold' truncate>
{emojifyText(account.display_name, account.emojis)}
</Text>
{verified && <VerificationBadge />}

View File

@ -22,6 +22,7 @@ import Stack from 'soapbox/components/ui/stack.tsx';
import SvgIcon from 'soapbox/components/ui/svg-icon.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import ZapButton from './zap-button/zap-button.tsx';
@ -113,7 +114,11 @@ const ZapPayRequestForm = ({ account, status, onClose }: IZapPayRequestForm) =>
/>
<Text weight='semibold'>
<FormattedMessage id='zap.send_to' defaultMessage='Send zaps to {target}' values={{ target: account.display_name }} />
<FormattedMessage
id='zap.send_to'
defaultMessage='Send zaps to {target}'
values={{ target: emojifyText(account.display_name, account.emojis) }}
/>
</Text>
<Avatar src={account.avatar} size={50} />
<DisplayNameInline account={account} />
@ -141,12 +146,15 @@ const ZapPayRequestForm = ({ account, status, onClose }: IZapPayRequestForm) =>
{hasZapSplit && <p className='absolute right-0 font-bold sm:-right-6 sm:text-xl'>sats</p>}
</div>
{hasZapSplit && <span className='flex justify-center text-xs'>
{hasZapSplit && (
<span className='flex justify-center text-xs'>
<FormattedMessage
id='zap.split_message.receiver'
defaultMessage='{receiver} will receive {amountReceiver} sats*' values={{ receiver: account.display_name, amountReceiver: zapSplitData.receiveAmount }}
defaultMessage='{receiver} will receive {amountReceiver} sats*'
values={{ receiver: emojifyText(account.display_name, account.emojis), amountReceiver: zapSplitData.receiveAmount }}
/>
</span>}
</span>
)}
</Stack>
@ -162,7 +170,8 @@ const ZapPayRequestForm = ({ account, status, onClose }: IZapPayRequestForm) =>
<span className='text-[10px] sm:text-xs'>
<FormattedMessage
id='zap.split_message.deducted'
defaultMessage='{amountDeducted} sats will deducted*' values={{ instance: account.display_name, amountDeducted: zapSplitData.splitAmount }}
defaultMessage='{amountDeducted} sats will deducted*'
values={{ instance: emojifyText(account.display_name, account.emojis), amountDeducted: zapSplitData.splitAmount }}
/>
</span>

View File

@ -1,200 +0,0 @@
import { Record as ImmutableRecord, fromJS } from 'immutable';
import { describe, expect, it } from 'vitest';
import AVATAR_MISSING from 'soapbox/assets/images/avatar-missing.png';
import HEADER_MISSING from 'soapbox/assets/images/header-missing.png';
import { normalizeAccount } from './account.ts';
describe('normalizeAccount()', () => {
it('adds base fields', () => {
const account = {};
const result = normalizeAccount(account);
expect(ImmutableRecord.isRecord(result)).toBe(true);
expect(result.acct).toEqual('');
expect(result.note).toEqual('');
expect(result.avatar).toEqual(AVATAR_MISSING);
expect(result.header_static).toEqual(HEADER_MISSING);
});
it('normalizes a mention', () => {
const mention = {
acct: 'NEETzsche@iddqd.social',
id: '9v5bw7hEGBPc9nrpzc',
url: 'https://iddqd.social/users/NEETzsche',
username: 'NEETzsche',
};
const result = normalizeAccount(mention);
expect(result.emojis).toEqual(fromJS([]));
expect(result.display_name).toEqual('NEETzsche');
expect(result.avatar).toEqual(AVATAR_MISSING);
expect(result.avatar_static).toEqual(AVATAR_MISSING);
expect(result.verified).toBe(false);
});
it('normalizes Fedibird birthday', async () => {
const account = await import('soapbox/__fixtures__/fedibird-account.json');
const result = normalizeAccount(account);
expect(result.birthday).toEqual('1993-07-03');
});
it('normalizes Pleroma birthday', async () => {
const account = await import('soapbox/__fixtures__/pleroma-account.json');
const result = normalizeAccount(account);
expect(result.birthday).toEqual('1993-07-03');
});
it('normalizes undefined birthday to empty string', async () => {
const account = await import('soapbox/__fixtures__/mastodon-account.json');
const result = normalizeAccount(account);
expect(result.birthday).toEqual('');
});
it('normalizes Pleroma legacy fields', async () => {
const account = await import('soapbox/__fixtures__/pleroma-2.2.2-account.json');
const result = normalizeAccount(account);
expect(result.getIn(['pleroma', 'is_active'])).toBe(true);
expect(result.getIn(['pleroma', 'is_confirmed'])).toBe(true);
expect(result.getIn(['pleroma', 'is_approved'])).toBe(true);
expect(result.hasIn(['pleroma', 'confirmation_pending'])).toBe(false);
});
it('prefers new Pleroma fields', async () => {
const account = await import('soapbox/__fixtures__/pleroma-account.json');
const result = normalizeAccount(account);
expect(result.getIn(['pleroma', 'is_active'])).toBe(true);
expect(result.getIn(['pleroma', 'is_confirmed'])).toBe(true);
expect(result.getIn(['pleroma', 'is_approved'])).toBe(true);
});
it('normalizes a verified Pleroma user', async () => {
const account = await import('soapbox/__fixtures__/mk.json');
const result = normalizeAccount(account);
expect(result.verified).toBe(true);
});
it('normalizes an unverified Pleroma user', async () => {
const account = await import('soapbox/__fixtures__/pleroma-account.json');
const result = normalizeAccount(account);
expect(result.verified).toBe(false);
});
it('normalizes a verified Truth Social user', async () => {
const account = await import('soapbox/__fixtures__/realDonaldTrump.json');
const result = normalizeAccount(account);
expect(result.verified).toBe(true);
});
it('normalizes Fedibird location', async () => {
const account = await import('soapbox/__fixtures__/fedibird-account.json');
const result = normalizeAccount(account);
expect(result.location).toBe('Texas, USA');
});
it('normalizes Truth Social location', async () => {
const account = await import('soapbox/__fixtures__/truthsocial-account.json');
const result = normalizeAccount(account);
expect(result.location).toBe('Texas');
});
it('normalizes Truth Social website', async () => {
const account = await import('soapbox/__fixtures__/truthsocial-account.json');
const result = normalizeAccount(account);
expect(result.website).toBe('https://soapbox.pub');
});
it('sets display_name from username', () => {
const account = { username: 'alex' };
const result = normalizeAccount(account);
expect(result.display_name).toBe('alex');
});
it('sets display_name from acct', () => {
const account = { acct: 'alex@gleasonator.com' };
const result = normalizeAccount(account);
expect(result.display_name).toBe('alex');
});
it('overrides a whitespace display_name', () => {
const account = { username: 'alex', display_name: ' ' };
const result = normalizeAccount(account);
expect(result.display_name).toBe('alex');
});
it('emojifies display name as `display_name_html`', async () => {
const account = await import('soapbox/__fixtures__/account-with-emojis.json');
const result = normalizeAccount(account);
expect(result.display_name_html).toContain('emojione');
});
it('emojifies note as `note_emojified`', async () => {
const account = await import('soapbox/__fixtures__/account-with-emojis.json');
const result = normalizeAccount(account);
expect(result.note_emojified).toContain('emojione');
});
it('unescapes HTML note as `note_plain`', async () => {
const account = await import('soapbox/__fixtures__/account-with-emojis.json');
const result = normalizeAccount(account);
const expected = 'I create Fediverse software that empowers people online. :soapbox:\n\nI\'m vegan btw\n\nNote: If you have a question for me, please tag me publicly. This gives the opportunity for others to chime in, and bystanders to learn.';
expect(result.note_plain).toBe(expected);
});
it('emojifies custom profile field', async () => {
const account = await import('soapbox/__fixtures__/account-with-emojis.json');
const result = normalizeAccount(account);
const field = result.fields.get(1);
expect(field?.name_emojified).toContain('emojione');
expect(field?.value_emojified).toContain('emojione');
expect(field?.value_plain).toBe('https://soapbox.pub :soapbox:');
});
it('adds default avatar and banner to GoToSocial account', async () => {
const account = await import('soapbox/__fixtures__/gotosocial-account.json');
const result = normalizeAccount(account);
expect(result.avatar).toEqual(AVATAR_MISSING);
expect(result.avatar_static).toEqual(AVATAR_MISSING);
expect(result.header).toEqual(HEADER_MISSING);
expect(result.header_static).toEqual(HEADER_MISSING);
});
it('adds fqn to Mastodon account', async () => {
const account = await import('soapbox/__fixtures__/mastodon-account.json');
const result = normalizeAccount(account);
expect(result.fqn).toEqual('benis911@mastodon.social');
});
it('normalizes Pleroma staff', async () => {
const account = await import('soapbox/__fixtures__/pleroma-account.json');
const result = normalizeAccount(account);
expect(result.admin).toBe(true);
expect(result.staff).toBe(true);
expect(result.moderator).toBe(false);
});
it('normalizes Pleroma favicon', async () => {
const account = await import('soapbox/__fixtures__/pleroma-account.json');
const result = normalizeAccount(account);
expect(result.favicon).toEqual('https://gleasonator.com/favicon.png');
});
it('adds account domain', async () => {
const account = await import('soapbox/__fixtures__/pleroma-account.json');
const result = normalizeAccount(account);
expect(result.domain).toEqual('gleasonator.com');
});
});

View File

@ -3,7 +3,6 @@
* Converts API accounts into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/account/}
*/
import escapeTextContentForBrowser from 'escape-html';
import {
Map as ImmutableMap,
List as ImmutableList,
@ -13,10 +12,8 @@ import {
import avatarMissing from 'soapbox/assets/images/avatar-missing.png';
import headerMissing from 'soapbox/assets/images/header-missing.png';
import emojify from 'soapbox/features/emoji/index.ts';
import { normalizeEmoji } from 'soapbox/normalizers/emoji.ts';
import { unescapeHTML } from 'soapbox/utils/html.ts';
import { mergeDefined, makeEmojiMap } from 'soapbox/utils/normalizers.ts';
import { mergeDefined } from 'soapbox/utils/normalizers.ts';
import type { PatronAccount } from 'soapbox/reducers/patron.ts';
import type { Emoji, Field, EmbeddedEntity, Relationship } from 'soapbox/types/entities.ts';
@ -59,11 +56,8 @@ export const AccountRecord = ImmutableRecord({
// Internal fields
admin: false,
display_name_html: '',
domain: '',
moderator: false,
note_emojified: '',
note_plain: '',
patron: null as PatronAccount | null,
relationship: null as Relationship | null,
should_refetch: false,
@ -75,11 +69,6 @@ export const FieldRecord = ImmutableRecord({
name: '',
value: '',
verified_at: null as Date | null,
// Internal fields
name_emojified: '',
value_emojified: '',
value_plain: '',
});
// https://gitlab.com/soapbox-pub/soapbox/-/issues/549
@ -188,31 +177,6 @@ const fixDisplayName = (account: ImmutableMap<string, any>) => {
return account.set('display_name', displayName.trim().length === 0 ? account.get('username') : displayName);
};
/** Emojification, etc */
const addInternalFields = (account: ImmutableMap<string, any>) => {
const emojiMap = makeEmojiMap(account.get('emojis'));
return account.withMutations((account: ImmutableMap<string, any>) => {
// Emojify account properties
account.merge({
display_name_html: emojify(escapeTextContentForBrowser(account.get('display_name')), emojiMap),
note_emojified: emojify(account.get('note', ''), emojiMap),
note_plain: unescapeHTML(account.get('note', '')),
});
// Emojify fields
account.update('fields', ImmutableList(), fields => {
return fields.map((field: ImmutableMap<string, any>) => {
return field.merge({
name_emojified: emojify(escapeTextContentForBrowser(field.get('name')), emojiMap),
value_emojified: emojify(field.get('value'), emojiMap),
value_plain: unescapeHTML(field.get('value')),
});
});
});
});
};
const getDomainFromURL = (account: ImmutableMap<string, any>): string => {
try {
const url = account.get('url');
@ -308,7 +272,6 @@ export const normalizeAccount = (account: Record<string, any>) => {
fixDisplayName(account);
fixBirthday(account);
fixNote(account);
addInternalFields(account);
}),
);
};

View File

@ -2,7 +2,6 @@
* Group normalizer:
* Converts API groups into our internal format.
*/
import escapeTextContentForBrowser from 'escape-html';
import {
Map as ImmutableMap,
List as ImmutableList,
@ -12,10 +11,7 @@ import {
import avatarMissing from 'soapbox/assets/images/avatar-missing.png';
import headerMissing from 'soapbox/assets/images/header-missing.png';
import emojify from 'soapbox/features/emoji/index.ts';
import { normalizeEmoji } from 'soapbox/normalizers/emoji.ts';
import { unescapeHTML } from 'soapbox/utils/html.ts';
import { makeEmojiMap } from 'soapbox/utils/normalizers.ts';
import type { Emoji, GroupRelationship } from 'soapbox/types/entities.ts';
@ -45,9 +41,6 @@ export const GroupRecord = ImmutableRecord({
url: '',
// Internal fields
display_name_html: '',
note_emojified: '',
note_plain: '',
relationship: null as GroupRelationship | null,
});
@ -85,31 +78,6 @@ const fixDisplayName = (group: ImmutableMap<string, any>) => {
return group.set('display_name', displayName.trim().length === 0 ? group.get('username') : displayName);
};
/** Emojification, etc */
const addInternalFields = (group: ImmutableMap<string, any>) => {
const emojiMap = makeEmojiMap(group.get('emojis'));
return group.withMutations((group: ImmutableMap<string, any>) => {
// Emojify group properties
group.merge({
display_name_html: emojify(escapeTextContentForBrowser(group.get('display_name')), emojiMap),
note_emojified: emojify(group.get('note', ''), emojiMap),
note_plain: unescapeHTML(group.get('note', '')),
});
// Emojify fields
group.update('fields', ImmutableList(), fields => {
return fields.map((field: ImmutableMap<string, any>) => {
return field.merge({
name_emojified: emojify(escapeTextContentForBrowser(field.get('name')), emojiMap),
value_emojified: emojify(field.get('value'), emojiMap),
value_plain: unescapeHTML(field.get('value')),
});
});
});
});
};
const getDomainFromURL = (group: ImmutableMap<string, any>): string => {
try {
const url = group.get('url');
@ -159,7 +127,6 @@ export const normalizeGroup = (group: Record<string, any>) => {
normalizeLocked(group);
fixDisplayName(group);
fixNote(group);
addInternalFields(group);
}),
);
};

View File

@ -1,7 +1,6 @@
/**
* Status edit normalizer
*/
import escapeTextContentForBrowser from 'escape-html';
import {
Map as ImmutableMap,
List as ImmutableList,
@ -10,12 +9,10 @@ import {
} from 'immutable';
import DOMPurify from 'isomorphic-dompurify';
import emojify from 'soapbox/features/emoji/index.ts';
import { normalizeAttachment } from 'soapbox/normalizers/attachment.ts';
import { normalizeEmoji } from 'soapbox/normalizers/emoji.ts';
import { pollSchema } from 'soapbox/schemas/index.ts';
import { stripCompatibilityFeatures } from 'soapbox/utils/html.ts';
import { makeEmojiMap } from 'soapbox/utils/normalizers.ts';
import type { Account, Attachment, Emoji, EmbeddedEntity, Poll } from 'soapbox/types/entities.ts';
@ -29,10 +26,6 @@ export const StatusEditRecord = ImmutableRecord({
poll: null as EmbeddedEntity<Poll>,
sensitive: false,
spoiler_text: '',
// Internal fields
contentHtml: '',
spoilerHtml: '',
});
const normalizeAttachments = (statusEdit: ImmutableMap<string, any>) => {
@ -59,13 +52,8 @@ const normalizeStatusPoll = (statusEdit: ImmutableMap<string, any>) => {
};
const normalizeContent = (statusEdit: ImmutableMap<string, any>) => {
const emojiMap = makeEmojiMap(statusEdit.get('emojis'));
const contentHtml = DOMPurify.sanitize(stripCompatibilityFeatures(emojify(statusEdit.get('content'), emojiMap)), { ADD_ATTR: ['target'] });
const spoilerHtml = DOMPurify.sanitize(emojify(escapeTextContentForBrowser(statusEdit.get('spoiler_text')), emojiMap), { ADD_ATTR: ['target'] });
return statusEdit
.set('contentHtml', contentHtml)
.set('spoilerHtml', spoilerHtml);
const content = DOMPurify.sanitize(stripCompatibilityFeatures(statusEdit.get('content')), { ADD_ATTR: ['target'] });
return statusEdit.set('content', content);
};
export const normalizeStatusEdit = (statusEdit: Record<string, any>) => {

View File

@ -1,202 +0,0 @@
import { Record as ImmutableRecord, fromJS } from 'immutable';
import { describe, expect, it } from 'vitest';
import { normalizeStatus } from './status.ts';
import type { Poll, Card } from 'soapbox/types/entities.ts';
describe('normalizeStatus()', () => {
it('adds base fields', () => {
const status = {};
const result = normalizeStatus(status);
expect(ImmutableRecord.isRecord(result)).toBe(true);
expect(result.emojis).toEqual(fromJS([]));
expect(result.favourites_count).toBe(0);
expect(result.mentions).toEqual(fromJS([]));
expect(result.reblog).toBe(null);
expect(result.uri).toBe('');
expect(result.visibility).toBe('public');
});
it('fixes the order of mentions', async () => {
const status = await import('soapbox/__fixtures__/status-unordered-mentions.json');
const expected = ['NEETzsche', 'alex', 'Lumeinshin', 'sneeden'];
const result = normalizeStatus(status)
.get('mentions')
.map(mention => mention.get('username'))
.toJS();
expect(result).toEqual(expected);
});
it('adds mention to self in self-reply on Mastodon', async () => {
const status = await import('soapbox/__fixtures__/mastodon-reply-to-self.json');
const expected = {
id: '106801667066418367',
username: 'benis911',
acct: 'benis911',
url: 'https://mastodon.social/@benis911',
};
const result = normalizeStatus(status).mentions;
expect(result.size).toBe(1);
expect(result.get(0)?.toJS()).toMatchObject(expected);
expect(result.get(0)?.id).toEqual('106801667066418367');
expect(ImmutableRecord.isRecord(result.get(0))).toBe(true);
});
it('normalizes mentions with only acct', () => {
const status = { mentions: [{ acct: 'alex@gleasonator.com' }] };
const expected = [{
id: '',
acct: 'alex@gleasonator.com',
username: 'alex',
url: '',
}];
const result = normalizeStatus(status).get('mentions');
expect(result.toJS()).toEqual(expected);
});
it('normalizes Mitra attachments', async () => {
const status = await import('soapbox/__fixtures__/mitra-status-with-attachments.json');
const expected = [{
id: '017eeb0e-e5df-30a4-77a7-a929145cb836',
type: 'image',
url: 'https://mitra.social/media/8e04e6091bbbac79641b5812508683ce72c38693661c18d16040553f2371e18d.png',
preview_url: 'https://mitra.social/media/8e04e6091bbbac79641b5812508683ce72c38693661c18d16040553f2371e18d.png',
remote_url: null,
}, {
id: '017eeb0e-e5e4-2a48-2889-afdebf368a54',
type: 'unknown',
url: 'https://mitra.social/media/8f72dc2e98572eb4ba7c3a902bca5f69c448fc4391837e5f8f0d4556280440ac',
preview_url: 'https://mitra.social/media/8f72dc2e98572eb4ba7c3a902bca5f69c448fc4391837e5f8f0d4556280440ac',
remote_url: null,
}, {
id: '017eeb0e-e5e5-79fd-6054-8b6869b1db49',
type: 'unknown',
url: 'https://mitra.social/media/55a81a090247cc4fc127e5716bcf7964f6e0df9b584f85f4696c0b994747a4d0.oga',
preview_url: 'https://mitra.social/media/55a81a090247cc4fc127e5716bcf7964f6e0df9b584f85f4696c0b994747a4d0.oga',
remote_url: null,
}, {
id: '017eeb0e-e5e6-c416-a444-21e560c47839',
type: 'unknown',
url: 'https://mitra.social/media/0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0',
preview_url: 'https://mitra.social/media/0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0',
remote_url: null,
}];
const result = normalizeStatus(status);
expect(result.media_attachments.toJS()).toMatchObject(expected);
});
it('leaves Pleroma attachments alone', async () => {
const status = await import('soapbox/__fixtures__/pleroma-status-with-attachments.json');
const result = normalizeStatus(status).media_attachments;
expect(result.size).toBe(4);
expect(result.get(1)?.meta).toEqual(fromJS({}));
expect(result.getIn([1, 'pleroma', 'mime_type'])).toBe('application/x-nes-rom');
expect(ImmutableRecord.isRecord(result.get(3))).toBe(true);
});
it('normalizes Pleroma quote post', async () => {
const status = await import('soapbox/__fixtures__/pleroma-quote-post.json');
const result = normalizeStatus(status);
expect(result.quote).toEqual(fromJS(status.pleroma.quote));
expect(result.pleroma.get('quote')).toBe(undefined);
});
it('normalizes GoToSocial status', async () => {
const status = await import('soapbox/__fixtures__/gotosocial-status.json');
const result = normalizeStatus(status);
// Adds missing fields
const missing = {
in_reply_to_account_id: null,
in_reply_to_id: null,
reblog: null,
pinned: false,
quote: null,
};
expect(result).toMatchObject(missing);
});
it('normalizes Friendica status', async () => {
const status = await import('soapbox/__fixtures__/friendica-status.json');
const result = normalizeStatus(status);
// Adds missing fields
const missing = {
pinned: false,
quote: null,
};
expect(result).toMatchObject(missing);
});
it('normalizes poll and poll options', () => {
const status = { poll: { id: '1', options: [{ title: 'Apples' }, { title: 'Oranges' }] } };
const result = normalizeStatus(status);
const poll = result.poll as Poll;
const expected = {
id: '1',
options: [
{ title: 'Apples', votes_count: 0 },
{ title: 'Oranges', votes_count: 0 },
],
emojis: [],
expired: false,
multiple: false,
voters_count: 0,
votes_count: 0,
own_votes: null,
voted: false,
};
expect(poll).toMatchObject(expected);
});
it('normalizes a Pleroma logged-out poll', async () => {
const status = await import('soapbox/__fixtures__/pleroma-status-with-poll.json');
const result = normalizeStatus(status);
const poll = result.poll as Poll;
// Adds logged-in fields
expect(poll.voted).toBe(false);
expect(poll.own_votes).toBe(null);
});
it('normalizes poll with emojis', async () => {
const status = await import('soapbox/__fixtures__/pleroma-status-with-poll-with-emojis.json');
const result = normalizeStatus(status);
const poll = result.poll as Poll;
// Emojifies poll options
expect(poll.options[1].title_emojified)
.toContain('emojione');
expect(poll.emojis[1].shortcode).toEqual('soapbox');
});
it('normalizes a card', async () => {
const status = await import('soapbox/__fixtures__/status-with-card.json');
const result = normalizeStatus(status);
const card = result.card as Card;
expect(card.type).toEqual('link');
expect(card.provider_url).toEqual('https://soapbox.pub');
});
});

View File

@ -92,7 +92,6 @@ export const StatusRecord = ImmutableRecord({
hidden: false,
search_index: '',
showFiltered: true,
spoilerHtml: '',
translation: null as ImmutableMap<string, string> | null,
});

View File

@ -89,7 +89,11 @@ const BlockedBlankslate = ({ group }: { group: Group }) => (
id='group.banned.message'
defaultMessage='You are banned from {group}'
values={{
group: <Text theme='inherit' tag='span' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />,
group: (
<Text theme='inherit' tag='span'>
{group.display_name}
</Text>
),
}}
/>
</Text>

View File

@ -60,7 +60,7 @@ import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS, MeAction } from '../actions/me.ts';
import { SETTING_CHANGE, FE_NAME, SettingsAction } from '../actions/settings.ts';
import { TIMELINE_DELETE, TimelineAction } from '../actions/timelines.ts';
import { normalizeAttachment } from '../normalizers/attachment.ts';
import { unescapeHTML } from '../utils/html.ts';
import { htmlToPlaintext } from '../utils/html.ts';
import type { Emoji } from 'soapbox/features/emoji/index.ts';
import type {
@ -442,7 +442,7 @@ export default function compose(state = initialState, action: ComposeAction | Ev
if (!action.withRedraft) {
map.set('id', action.status.id);
}
map.set('text', action.rawText || unescapeHTML(expandMentions(action.status)));
map.set('text', action.rawText || htmlToPlaintext(expandMentions(action.status)));
map.set('to', action.explicitAddressing ? getExplicitMentions(action.status.account.id, action.status) : ImmutableOrderedSet<string>());
map.set('in_reply_to', action.status.get('in_reply_to_id'));
map.set('privacy', action.status.get('visibility'));

View File

@ -1,12 +1,10 @@
import escapeTextContentForBrowser from 'escape-html';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import DOMPurify from 'isomorphic-dompurify';
import emojify from 'soapbox/features/emoji/index.ts';
import { normalizeStatus } from 'soapbox/normalizers/index.ts';
import { simulateEmojiReact, simulateUnEmojiReact } from 'soapbox/utils/emoji-reacts.ts';
import { stripCompatibilityFeatures, unescapeHTML } from 'soapbox/utils/html.ts';
import { makeEmojiMap, normalizeId } from 'soapbox/utils/normalizers.ts';
import { htmlToPlaintext, stripCompatibilityFeatures } from 'soapbox/utils/html.ts';
import { normalizeId } from 'soapbox/utils/normalizers.ts';
import {
EMOJI_REACT_REQUEST,
@ -96,34 +94,22 @@ const buildSearchContent = (status: StatusRecord): string => {
status.content,
]).concat(pollOptionTitles).concat(mentionedUsernames);
return unescapeHTML(fields.join('\n\n')) || '';
return htmlToPlaintext(fields.join('\n\n')) || '';
};
// Only calculate these values when status first encountered
// Otherwise keep the ones already in the reducer
export const calculateStatus = (
status: StatusRecord,
oldStatus?: StatusRecord,
expandSpoilers: boolean = false,
): StatusRecord => {
if (oldStatus && oldStatus.content === status.content && oldStatus.spoiler_text === status.spoiler_text) {
return status.merge({
search_index: oldStatus.search_index,
spoilerHtml: oldStatus.spoilerHtml,
hidden: oldStatus.hidden,
});
} else {
const spoilerText = status.spoiler_text;
const searchContent = buildSearchContent(status);
const emojiMap = makeEmojiMap(status.emojis);
return status.merge({
search_index: domParser.parseFromString(searchContent, 'text/html').documentElement.textContent || '',
content: DOMPurify.sanitize(stripCompatibilityFeatures(status.content), { USE_PROFILES: { html: true } }),
spoilerHtml: DOMPurify.sanitize(emojify(escapeTextContentForBrowser(spoilerText), emojiMap), { USE_PROFILES: { html: true } }),
hidden: expandSpoilers ? false : spoilerText.length > 0 || status.sensitive,
hidden: expandSpoilers ? false : status.spoiler_text.length > 0 || status.sensitive,
});
}
};
// Check whether a status is a quote by secondary characteristics
@ -158,7 +144,7 @@ const fixStatus = (state: State, status: APIEntity, expandSpoilers: boolean): Re
return normalizeStatus(status).withMutations(status => {
fixTranslation(status, oldStatus);
fixQuote(status, oldStatus);
calculateStatus(status, oldStatus, expandSpoilers);
calculateStatus(status, expandSpoilers);
minifyStatus(status);
}) as ReducerStatus;
};

View File

@ -1,16 +1,13 @@
import { NSchema as n } from '@nostrify/nostrify';
import escapeTextContentForBrowser from 'escape-html';
import DOMPurify from 'isomorphic-dompurify';
import z from 'zod';
import avatarMissing from 'soapbox/assets/images/avatar-missing.png';
import headerMissing from 'soapbox/assets/images/header-missing.png';
import emojify from 'soapbox/features/emoji/index.ts';
import { unescapeHTML } from 'soapbox/utils/html.ts';
import { customEmojiSchema } from './custom-emoji.ts';
import { Relationship } from './relationship.ts';
import { coerceObject, contentSchema, filteredArray, makeCustomEmojiMap } from './utils.ts';
import { coerceObject, contentSchema, filteredArray } from './utils.ts';
import type { Resolve } from 'soapbox/utils/types.ts';
@ -136,16 +133,7 @@ const filterBadges = (tags?: string[]) =>
tags?.filter(tag => tag.startsWith('badge:')).map(tag => roleSchema.parse({ id: tag, name: tag.replace(/^badge:/, '') }));
/** Add internal fields to the account. */
const transformAccount = <T extends TransformableAccount>({ pleroma, other_settings, fields, ...account }: T) => {
const customEmojiMap = makeCustomEmojiMap(account.emojis);
const newFields = fields.map((field) => ({
...field,
name_emojified: DOMPurify.sanitize(emojify(escapeTextContentForBrowser(field.name), customEmojiMap), { USE_PROFILES: { html: true } }),
value_emojified: emojify(field.value, customEmojiMap),
value_plain: unescapeHTML(field.value),
}));
const transformAccount = <T extends TransformableAccount>({ pleroma, other_settings, ...account }: T) => {
const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name;
const domain = account.domain ?? getDomain(account.url || account.uri);
@ -159,15 +147,13 @@ const transformAccount = <T extends TransformableAccount>({ pleroma, other_setti
avatar_static: account.avatar_static || account.avatar,
discoverable: account.discoverable || account.source?.pleroma?.discoverable || false,
display_name: displayName,
display_name_html: DOMPurify.sanitize(emojify(escapeTextContentForBrowser(displayName), customEmojiMap), { USE_PROFILES: { html: true } }),
domain,
fields: newFields,
fqn: account.fqn || (account.acct.includes('@') ? account.acct : `${account.acct}@${domain}`),
header_static: account.header_static || account.header,
moderator: pleroma?.is_moderator || false,
local: pleroma?.is_local !== undefined ? pleroma.is_local : account.acct.split('@')[1] === undefined,
location: account.location || pleroma?.location || other_settings?.location || '',
note_emojified: DOMPurify.sanitize(emojify(account.note, customEmojiMap), { USE_PROFILES: { html: true } }),
note: DOMPurify.sanitize(account.note, { USE_PROFILES: { html: true } }),
pleroma,
roles: account.roles.length ? account.roles : filterBadges(pleroma?.tags),
staff: pleroma?.is_admin || pleroma?.is_moderator || false,

View File

@ -1,28 +1,15 @@
import { z } from 'zod';
import emojify from 'soapbox/features/emoji/index.ts';
import { announcementReactionSchema } from './announcement-reaction.ts';
import { customEmojiSchema } from './custom-emoji.ts';
import { mentionSchema } from './mention.ts';
import { tagSchema } from './tag.ts';
import { dateSchema, filteredArray, makeCustomEmojiMap } from './utils.ts';
import { dateSchema, filteredArray } from './utils.ts';
import type { Resolve } from 'soapbox/utils/types.ts';
const transformAnnouncement = (announcement: Resolve<z.infer<typeof baseAnnouncementSchema>>) => {
const emojiMap = makeCustomEmojiMap(announcement.emojis);
const contentHtml = emojify(announcement.content, emojiMap);
return {
...announcement,
contentHtml,
};
};
// https://docs.joinmastodon.org/entities/announcement/
const baseAnnouncementSchema = z.object({
const announcementSchema = z.object({
id: z.string(),
content: z.string().catch(''),
starts_at: z.string().datetime().nullable().catch(null),
@ -43,15 +30,13 @@ const baseAnnouncementSchema = z.object({
updated_at: dateSchema,
});
const announcementSchema = baseAnnouncementSchema.transform(transformAnnouncement);
type Announcement = Resolve<z.infer<typeof announcementSchema>>;
const adminAnnouncementSchema = baseAnnouncementSchema.extend({
const adminAnnouncementSchema = announcementSchema.extend({
pleroma: z.object({
raw_content: z.string().catch(''),
}),
}).transform(transformAnnouncement);
});
type AdminAnnouncement = Resolve<z.infer<typeof adminAnnouncementSchema>>;

View File

@ -5,5 +5,5 @@ import { groupSchema } from './group.ts';
test('groupSchema with a TruthSocial group', async () => {
const data = await import('soapbox/__fixtures__/group-truthsocial.json');
const group = groupSchema.parse(data);
expect(group.display_name_html).toEqual('PATRIOT PATRIOTS');
expect(group.display_name).toEqual('PATRIOT PATRIOTS');
});

View File

@ -1,15 +1,13 @@
import escapeTextContentForBrowser from 'escape-html';
import DOMPurify from 'isomorphic-dompurify';
import z from 'zod';
import avatarMissing from 'soapbox/assets/images/avatar-missing.png';
import headerMissing from 'soapbox/assets/images/header-missing.png';
import emojify from 'soapbox/features/emoji/index.ts';
import { unescapeHTML } from 'soapbox/utils/html.ts';
import { customEmojiSchema } from './custom-emoji.ts';
import { groupRelationshipSchema } from './group-relationship.ts';
import { groupTagSchema } from './group-tag.ts';
import { filteredArray, makeCustomEmojiMap } from './utils.ts';
import { filteredArray } from './utils.ts';
const groupSchema = z.object({
avatar: z.string().catch(avatarMissing),
@ -42,12 +40,9 @@ const groupSchema = z.object({
group.header_static = group.header_static || group.header;
group.locked = group.locked || group.group_visibility === 'members_only'; // TruthSocial
const customEmojiMap = makeCustomEmojiMap(group.emojis);
return {
...group,
display_name_html: emojify(escapeTextContentForBrowser(group.display_name), customEmojiMap),
note_emojified: emojify(group.note, customEmojiMap),
note_plain: group.source?.note || unescapeHTML(group.note),
note: DOMPurify.sanitize(group.note, { USE_PROFILES: { html: true } }),
};
});

View File

@ -32,15 +32,4 @@ describe('normalizePoll()', () => {
expect(result.voted).toBe(false);
expect(result.own_votes).toBe(null);
});
it('normalizes poll with emojis', async () => {
const { poll } = await import('soapbox/__fixtures__/pleroma-status-with-poll-with-emojis.json');
const result = pollSchema.parse(poll);
// Emojifies poll options
expect(result.options[1]?.title_emojified)
.toContain('emojione');
expect(result.emojis[1]?.shortcode).toEqual('soapbox');
});
});

View File

@ -1,11 +1,7 @@
import escapeTextContentForBrowser from 'escape-html';
import DOMPurify from 'isomorphic-dompurify';
import { z } from 'zod';
import emojify from 'soapbox/features/emoji/index.ts';
import { customEmojiSchema } from './custom-emoji.ts';
import { filteredArray, makeCustomEmojiMap } from './utils.ts';
import { filteredArray } from './utils.ts';
const pollOptionSchema = z.object({
title: z.string().catch(''),
@ -27,22 +23,12 @@ const pollSchema = z.object({
non_anonymous: z.boolean().catch(false),
}).optional().catch(undefined),
}).transform((poll) => {
const emojiMap = makeCustomEmojiMap(poll.emojis);
const emojifiedOptions = poll.options.map((option) => ({
...option,
title_emojified: DOMPurify.sanitize(emojify(escapeTextContentForBrowser(option.title), emojiMap), { ALLOWED_TAGS: [] }),
}));
// If the user has votes, they have certainly voted.
if (poll.own_votes?.length) {
poll.voted = true;
}
return {
...poll,
options: emojifiedOptions,
};
return poll;
});
type Poll = z.infer<typeof pollSchema>;

View File

@ -1,9 +1,7 @@
import escapeTextContentForBrowser from 'escape-html';
import DOMPurify from 'isomorphic-dompurify';
import { z } from 'zod';
import emojify from 'soapbox/features/emoji/index.ts';
import { stripCompatibilityFeatures, unescapeHTML } from 'soapbox/utils/html.ts';
import { htmlToPlaintext, stripCompatibilityFeatures } from 'soapbox/utils/html.ts';
import { accountSchema } from './account.ts';
import { attachmentSchema } from './attachment.ts';
@ -15,7 +13,7 @@ import { groupSchema } from './group.ts';
import { mentionSchema } from './mention.ts';
import { pollSchema } from './poll.ts';
import { tagSchema } from './tag.ts';
import { contentSchema, dateSchema, filteredArray, makeCustomEmojiMap } from './utils.ts';
import { contentSchema, dateSchema, filteredArray } from './utils.ts';
import type { Resolve } from 'soapbox/utils/types.ts';
@ -94,7 +92,7 @@ const buildSearchIndex = (status: TransformableStatus): string => {
...mentionedUsernames,
];
const searchContent = unescapeHTML(fields.join('\n\n')) || '';
const searchContent = htmlToPlaintext(fields.join('\n\n')) || '';
return new DOMParser().parseFromString(searchContent, 'text/html').documentElement.textContent || '';
};
@ -105,15 +103,10 @@ type Translation = {
/** Add internal fields to the status. */
const transformStatus = <T extends TransformableStatus>({ pleroma, ...status }: T) => {
const emojiMap = makeCustomEmojiMap(status.emojis);
const content = DOMPurify.sanitize(stripCompatibilityFeatures(status.content), { USE_PROFILES: { html: true } });
const spoilerHtml = emojify(escapeTextContentForBrowser(status.spoiler_text), emojiMap);
return {
...status,
approval_status: 'approval' as const,
content,
content: DOMPurify.sanitize(stripCompatibilityFeatures(status.content), { USE_PROFILES: { html: true } }),
expectsCard: false,
event: pleroma?.event,
filtered: [],
@ -124,7 +117,6 @@ const transformStatus = <T extends TransformableStatus>({ pleroma, ...status }:
})() : undefined,
search_index: buildSearchIndex(status),
showFiltered: false, // TODO: this should be removed from the schema and done somewhere else
spoilerHtml,
translation: undefined as Translation | undefined,
};
};

View File

@ -1,7 +1,5 @@
import z from 'zod';
import type { CustomEmoji } from './custom-emoji.ts';
/** Ensure HTML content is a string, and drop empty `<p>` tags. */
const contentSchema = z.string().catch('').transform((value) => value === '<p></p>' ? '' : value);
@ -22,14 +20,6 @@ function filteredArray<T extends z.ZodTypeAny>(schema: T) {
/** Validates the string as an emoji. */
const emojiSchema = z.string().refine((v) => /\p{Extended_Pictographic}|[\u{1F1E6}-\u{1F1FF}]{2}/u.test(v));
/** Map a list of CustomEmoji to their shortcodes. */
function makeCustomEmojiMap(customEmojis: CustomEmoji[]) {
return customEmojis.reduce<Record<string, CustomEmoji>>((result, emoji) => {
result[`:${emoji.shortcode}:`] = emoji;
return result;
}, {});
}
function jsonSchema(reviver?: (this: any, key: string, value: any) => any) {
return z.string().transform((value, ctx) => {
try {
@ -52,4 +42,4 @@ function coerceObject<T extends z.ZodRawShape>(shape: T) {
/** Validates a hex color code. */
const hexColorSchema = z.string().regex(/^#([a-f0-9]{3}|[a-f0-9]{4}|[a-f0-9]{6}|[a-f0-9]{8})$/i);
export { filteredArray, hexColorSchema, makeCustomEmojiMap, emojiSchema, contentSchema, dateSchema, jsonSchema, mimeSchema, coerceObject };
export { filteredArray, hexColorSchema, emojiSchema, contentSchema, dateSchema, jsonSchema, mimeSchema, coerceObject };

31
src/utils/emojify.tsx Normal file
View File

@ -0,0 +1,31 @@
import Tooltip from 'soapbox/components/ui/tooltip.tsx';
import { CustomEmoji } from 'soapbox/schemas/custom-emoji.ts';
/** Given text and a list of custom emojis, return JSX with the emojis rendered as `<img>` elements. */
export function emojifyText(text: string, emojis: CustomEmoji[]): JSX.Element {
const parts: Array<string | JSX.Element> = [];
const textNodes = text.split(/:\w+:/);
const shortcodes = [...text.matchAll(/:(\w+):/g)];
for (let i = 0; i < textNodes.length; i++) {
parts.push(textNodes[i]);
if (shortcodes[i]) {
const [match, shortcode] = shortcodes[i];
const customEmoji = emojis.find((e) => e.shortcode === shortcode);
if (customEmoji) {
parts.push(
<Tooltip key={i} text={`:${shortcode}:`}>
<img src={customEmoji.url} alt={shortcode} className='inline h-[1em] align-text-bottom' />
</Tooltip>,
);
} else {
parts.push(match);
}
}
}
return <>{parts}</>;
}

View File

@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest';
import * as html from './html.ts';
describe('html', () => {
describe('unsecapeHTML', () => {
it('returns unescaped HTML', () => {
const output = html.unescapeHTML('<p>lorem</p><p>ipsum</p><br>&lt;br&gt;');
describe('htmlToPlaintext', () => {
it('converts html to plaintext, preserving linebreaks', () => {
const output = html.htmlToPlaintext('<p>lorem</p><p>ipsum</p><br>&lt;br&gt;');
expect(output).toEqual('lorem\n\nipsum\n<br>');
});
});

View File

@ -1,13 +1,5 @@
/** Convert HTML to a plaintext representation, preserving whitespace. */
// NB: This function can still return unsafe HTML
export const unescapeHTML = (html: string = ''): string => {
const wrapper = document.createElement('div');
wrapper.innerHTML = html.replace(/<br\s*\/?>/g, '\n').replace(/<\/p><[^>]*>/g, '\n\n').replace(/<[^>]*>/g, '');
return wrapper.textContent || '';
};
/** Remove compatibility markup for features Soapbox supports. */
export const stripCompatibilityFeatures = (html: string): string => {
export function stripCompatibilityFeatures(html: string): string {
const node = document.createElement('div');
node.innerHTML = html;
@ -26,12 +18,12 @@ export const stripCompatibilityFeatures = (html: string): string => {
});
return node.innerHTML;
};
}
/** Convert HTML to plaintext. */
// https://stackoverflow.com/a/822486
export const stripHTML = (html: string) => {
export function htmlToPlaintext(html: string): string {
const div = document.createElement('div');
div.innerHTML = html;
return div.textContent || div.innerText || '';
};
}

View File

@ -3,11 +3,6 @@ import z from 'zod';
/** Use new value only if old value is undefined */
export const mergeDefined = (oldVal: any, newVal: any) => oldVal === undefined ? newVal : oldVal;
export const makeEmojiMap = (emojis: any) => emojis.reduce((obj: any, emoji: any) => {
obj[`:${emoji.shortcode}:`] = emoji;
return obj;
}, {});
/** Normalize entity ID */
export const normalizeId = (id: unknown): string | null => {
return z.string().nullable().catch(null).parse(id);

21
src/utils/only-emoji.ts Normal file
View File

@ -0,0 +1,21 @@
import graphemesplit from 'graphemesplit';
import { CustomEmoji } from 'soapbox/schemas/custom-emoji.ts';
import { htmlToPlaintext } from 'soapbox/utils/html.ts';
/** Given the HTML string, determine whether the plaintext contains only emojis (native or custom), not exceeding the max. */
export function isOnlyEmoji(html: string, emojis: CustomEmoji[], max: number): boolean {
let plain = htmlToPlaintext(html).replaceAll(/\s/g, '');
const native = graphemesplit(plain).filter((char) => /^\p{Extended_Pictographic}+$/u.test(char));
const custom = [...plain.matchAll(/:(\w+):/g)].map(([, shortcode]) => shortcode).filter((shortcode) => emojis.some((emoji) => emoji.shortcode === shortcode));
for (const emoji of native) {
plain = plain.replaceAll(emoji, '');
}
for (const shortcode of custom) {
plain = plain.replaceAll(`:${shortcode}:`, '');
}
return plain.length === 0 && native.length + custom.length <= max;
}

View File

@ -4388,11 +4388,6 @@ escalade@^3.1.1, escalade@^3.1.2:
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
escape-html@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
escape-string-regexp@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"