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:
commit
8cb2e5e9d1
|
@ -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",
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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'
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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' />
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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'>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'>
|
||||
“<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} />”
|
||||
“<span>{status.spoiler_text}</span>”
|
||||
</Text>
|
||||
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||
</div>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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 */}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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 || ' ' }}
|
||||
/>
|
||||
>
|
||||
{account.note}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<div className='grid grid-cols-3 gap-1 py-4'>
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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>[] = [];
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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' />
|
||||
|
|
|
@ -19,8 +19,8 @@ const SiteBanner: React.FC = () => {
|
|||
|
||||
<Markup
|
||||
size='lg'
|
||||
dangerouslySetInnerHTML={{ __html: description }}
|
||||
direction={getTextDirection(description)}
|
||||
html={{ __html: description }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
}}
|
||||
/>}
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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 />}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>) => {
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -92,7 +92,6 @@ export const StatusRecord = ImmutableRecord({
|
|||
hidden: false,
|
||||
search_index: '',
|
||||
showFiltered: true,
|
||||
spoilerHtml: '',
|
||||
translation: null as ImmutableMap<string, string> | null,
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>>;
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
|
@ -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 } }),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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 };
|
|
@ -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}</>;
|
||||
}
|
|
@ -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><br>');
|
||||
describe('htmlToPlaintext', () => {
|
||||
it('converts html to plaintext, preserving linebreaks', () => {
|
||||
const output = html.htmlToPlaintext('<p>lorem</p><p>ipsum</p><br><br>');
|
||||
expect(output).toEqual('lorem\n\nipsum\n<br>');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 || '';
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue