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",
|
"cssnano": "^6.0.0",
|
||||||
"detect-passive-events": "^2.0.0",
|
"detect-passive-events": "^2.0.0",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"eslint-plugin-formatjs": "^5.2.2",
|
"eslint-plugin-formatjs": "^5.2.2",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
"graphemesplit": "^2.4.4",
|
"graphemesplit": "^2.4.4",
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { getFilters, regexFromFilters } from 'soapbox/selectors/index.ts';
|
||||||
import { isLoggedIn } from 'soapbox/utils/auth.ts';
|
import { isLoggedIn } from 'soapbox/utils/auth.ts';
|
||||||
import { compareId } from 'soapbox/utils/comparators.ts';
|
import { compareId } from 'soapbox/utils/comparators.ts';
|
||||||
import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features.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 { EXCLUDE_TYPES, NOTIFICATION_TYPES } from 'soapbox/utils/notification.ts';
|
||||||
|
|
||||||
import { fetchRelationships } from './accounts.ts';
|
import { fetchRelationships } from './accounts.ts';
|
||||||
|
@ -100,7 +100,7 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record<
|
||||||
|
|
||||||
if (['mention', 'status'].includes(notification.type)) {
|
if (['mention', 'status'].includes(notification.type)) {
|
||||||
const regex = regexFromFilters(filters);
|
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);
|
filtered = regex && regex.test(searchIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,7 +111,7 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record<
|
||||||
|
|
||||||
if (showAlert && !filtered && isNotificationsEnabled) {
|
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 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 => {
|
navigator.serviceWorker.ready.then(serviceWorkerRegistration => {
|
||||||
serviceWorkerRegistration.showNotification(title, {
|
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 ActionButton from 'soapbox/features/ui/components/action-button.tsx';
|
||||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
||||||
import { getAcct } from 'soapbox/utils/accounts.ts';
|
import { getAcct } from 'soapbox/utils/accounts.ts';
|
||||||
|
import { emojifyText } from 'soapbox/utils/emojify.tsx';
|
||||||
import { displayFqn } from 'soapbox/utils/state.ts';
|
import { displayFqn } from 'soapbox/utils/state.ts';
|
||||||
|
|
||||||
import Badge from './badge.tsx';
|
import Badge from './badge.tsx';
|
||||||
|
@ -232,12 +233,9 @@ const Account = ({
|
||||||
>
|
>
|
||||||
<LinkEl {...linkProps}>
|
<LinkEl {...linkProps}>
|
||||||
<HStack space={1} alignItems='center' grow>
|
<HStack space={1} alignItems='center' grow>
|
||||||
<Text
|
<Text size='sm' weight='semibold' truncate>
|
||||||
size='sm'
|
{emojifyText(account.display_name, account.emojis)}
|
||||||
weight='semibold'
|
</Text>
|
||||||
truncate
|
|
||||||
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{account.verified && <VerificationBadge />}
|
{account.verified && <VerificationBadge />}
|
||||||
|
|
||||||
|
@ -308,7 +306,7 @@ const Account = ({
|
||||||
<Text
|
<Text
|
||||||
truncate
|
truncate
|
||||||
size='sm'
|
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'
|
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}
|
dir={direction}
|
||||||
className='text-sm ltr:ml-0 rtl:mr-0'
|
className='text-sm ltr:ml-0 rtl:mr-0'
|
||||||
ref={node}
|
ref={node}
|
||||||
dangerouslySetInnerHTML={{ __html: announcement.contentHtml }}
|
dangerouslySetInnerHTML={{ __html: announcement.content }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import HStack from 'soapbox/components/ui/hstack.tsx';
|
import HStack from 'soapbox/components/ui/hstack.tsx';
|
||||||
import Text from 'soapbox/components/ui/text.tsx';
|
import Text from 'soapbox/components/ui/text.tsx';
|
||||||
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
|
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
|
||||||
|
import { emojifyText } from 'soapbox/utils/emojify.tsx';
|
||||||
|
|
||||||
import { getAcct } from '../utils/accounts.ts';
|
import { getAcct } from '../utils/accounts.ts';
|
||||||
|
|
||||||
|
@ -10,7 +11,7 @@ import VerificationBadge from './verification-badge.tsx';
|
||||||
import type { Account } from 'soapbox/schemas/index.ts';
|
import type { Account } from 'soapbox/schemas/index.ts';
|
||||||
|
|
||||||
interface IDisplayName {
|
interface IDisplayName {
|
||||||
account: Pick<Account, 'id' | 'acct' | 'fqn' | 'verified' | 'display_name_html'>;
|
account: Pick<Account, 'id' | 'acct' | 'emojis' | 'fqn' | 'verified' | 'display_name'>;
|
||||||
withSuffix?: boolean;
|
withSuffix?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,8 +25,9 @@ const DisplayNameInline: React.FC<IDisplayName> = ({ account, withSuffix = true
|
||||||
size='sm'
|
size='sm'
|
||||||
weight='normal'
|
weight='normal'
|
||||||
truncate
|
truncate
|
||||||
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
|
>
|
||||||
/>
|
{emojifyText(account.display_name, account.emojis)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
{verified && <VerificationBadge />}
|
{verified && <VerificationBadge />}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
|
@ -2,16 +2,15 @@ import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper.tsx';
|
||||||
import HStack from 'soapbox/components/ui/hstack.tsx';
|
import HStack from 'soapbox/components/ui/hstack.tsx';
|
||||||
import Text from 'soapbox/components/ui/text.tsx';
|
import Text from 'soapbox/components/ui/text.tsx';
|
||||||
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
|
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
|
||||||
|
import { getAcct } from 'soapbox/utils/accounts.ts';
|
||||||
import { getAcct } from '../utils/accounts.ts';
|
import { emojifyText } from 'soapbox/utils/emojify.tsx';
|
||||||
|
|
||||||
|
|
||||||
import VerificationBadge from './verification-badge.tsx';
|
import VerificationBadge from './verification-badge.tsx';
|
||||||
|
|
||||||
import type { Account } from 'soapbox/schemas/index.ts';
|
import type { Account } from 'soapbox/schemas/index.ts';
|
||||||
|
|
||||||
interface IDisplayName {
|
interface IDisplayName {
|
||||||
account: Pick<Account, 'id' | 'acct' | 'fqn' | 'verified' | 'display_name_html'>;
|
account: Pick<Account, 'id' | 'acct' | 'emojis' | 'fqn' | 'verified' | 'display_name'>;
|
||||||
withSuffix?: boolean;
|
withSuffix?: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
@ -26,8 +25,9 @@ const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = t
|
||||||
size='sm'
|
size='sm'
|
||||||
weight='semibold'
|
weight='semibold'
|
||||||
truncate
|
truncate
|
||||||
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
|
>
|
||||||
/>
|
{emojifyText(account.display_name, account.emojis)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
{verified && <VerificationBadge />}
|
{verified && <VerificationBadge />}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
|
@ -73,7 +73,7 @@ const EventPreview: React.FC<IEventPreview> = ({ status, className, hideAction,
|
||||||
<HStack alignItems='center' space={2}>
|
<HStack alignItems='center' space={2}>
|
||||||
<Icon src={userIcon} />
|
<Icon src={userIcon} />
|
||||||
<HStack space={1} alignItems='center' grow>
|
<HStack space={1} alignItems='center' grow>
|
||||||
<span dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
|
<span>{account.display_name}</span>
|
||||||
{account.verified && <VerificationBadge />}
|
{account.verified && <VerificationBadge />}
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
|
@ -36,7 +36,9 @@ const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
||||||
{/* Group Info */}
|
{/* Group Info */}
|
||||||
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
|
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
|
||||||
<HStack alignItems='center' space={1.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 && (
|
{group.relationship?.pending_requests && (
|
||||||
<div className='size-2 rounded-full bg-secondary-500' />
|
<div className='size-2 rounded-full bg-secondary-500' />
|
||||||
|
|
|
@ -70,7 +70,9 @@ const GroupPopover = (props: IGroupPopoverContainer) => {
|
||||||
|
|
||||||
{/* Group Info */}
|
{/* Group Info */}
|
||||||
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
|
<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>
|
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
|
||||||
<GroupPrivacy group={group} />
|
<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 { 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 Text, { IText } from './ui/text.tsx';
|
||||||
import './markup.css';
|
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. */
|
/** 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 (
|
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 HStack from 'soapbox/components/ui/hstack.tsx';
|
||||||
import Icon from 'soapbox/components/ui/icon.tsx';
|
import Icon from 'soapbox/components/ui/icon.tsx';
|
||||||
import Text from 'soapbox/components/ui/text.tsx';
|
import Text from 'soapbox/components/ui/text.tsx';
|
||||||
|
import { emojifyText } from 'soapbox/utils/emojify.tsx';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Poll as PollEntity,
|
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='grid w-full items-center'>
|
||||||
<div className='col-start-1 row-start-1 ml-4 mr-6 justify-self-center'>
|
<div className='col-start-1 row-start-1 ml-4 mr-6 justify-self-center'>
|
||||||
<div className='text-primary-600 dark:text-white'>
|
<div className='text-primary-600 dark:text-white'>
|
||||||
<Text
|
<Text theme='inherit' weight='medium' align='center'>
|
||||||
theme='inherit'
|
{emojifyText(option.title, poll.emojis)}
|
||||||
weight='medium'
|
</Text>
|
||||||
align='center'
|
|
||||||
dangerouslySetInnerHTML={{ __html: option.title_emojified }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -135,12 +133,9 @@ const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
|
||||||
<PollPercentageBar percent={percent} leading={leading} />
|
<PollPercentageBar percent={percent} leading={leading} />
|
||||||
|
|
||||||
<div className='text-primary-600 dark:text-white'>
|
<div className='text-primary-600 dark:text-white'>
|
||||||
<Text
|
<Text theme='inherit' weight='medium' className='relative'>
|
||||||
theme='inherit'
|
{emojifyText(option.title, poll.emojis)}
|
||||||
weight='medium'
|
</Text>
|
||||||
dangerouslySetInnerHTML={{ __html: option.title_emojified }}
|
|
||||||
className='relative'
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HStack space={2} alignItems='center' className='relative'>
|
<HStack space={2} alignItems='center' className='relative'>
|
||||||
|
|
|
@ -102,7 +102,6 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
const accountBio = { __html: account.note_emojified };
|
|
||||||
const memberSinceDate = intl.formatDate(account.created_at, { month: 'long', year: 'numeric' });
|
const memberSinceDate = intl.formatDate(account.created_at, { month: 'long', year: 'numeric' });
|
||||||
const followedBy = me !== account.id && account.relationship?.followed_by === true;
|
const followedBy = me !== account.id && account.relationship?.followed_by === true;
|
||||||
|
|
||||||
|
@ -145,7 +144,7 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{account.note.length > 0 && (
|
{account.note.length > 0 && (
|
||||||
<Text size='sm' dangerouslySetInnerHTML={accountBio} />
|
<Text size='sm' dangerouslySetInnerHTML={{ __html: account.note }} />
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|
|
@ -1,24 +1,19 @@
|
||||||
import chevronRightIcon from '@tabler/icons/outline/chevron-right.svg';
|
import chevronRightIcon from '@tabler/icons/outline/chevron-right.svg';
|
||||||
import clsx from 'clsx';
|
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 { useState, useRef, useLayoutEffect, useMemo, memo } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import Icon from 'soapbox/components/icon.tsx';
|
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 Markup from './markup.tsx';
|
||||||
import Mention from './mention.tsx';
|
|
||||||
import Poll from './polls/poll.tsx';
|
import Poll from './polls/poll.tsx';
|
||||||
|
|
||||||
import type { Sizes } from 'soapbox/components/ui/text.tsx';
|
import type { Sizes } from 'soapbox/components/ui/text.tsx';
|
||||||
import type { Status } from 'soapbox/types/entities.ts';
|
import type { Status } from 'soapbox/types/entities.ts';
|
||||||
|
|
||||||
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
|
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
|
||||||
const BIG_EMOJI_LIMIT = 10;
|
|
||||||
|
|
||||||
interface IReadMoreButton {
|
interface IReadMoreButton {
|
||||||
onClick: React.MouseEventHandler;
|
onClick: React.MouseEventHandler;
|
||||||
|
@ -51,11 +46,7 @@ const StatusContent: React.FC<IStatusContent> = ({
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
const node = useRef<HTMLDivElement>(null);
|
const node = useRef<HTMLDivElement>(null);
|
||||||
|
const isOnlyEmoji = useMemo(() => _isOnlyEmoji(status.content, status.emojis.toJS(), 10), [status.content]);
|
||||||
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 maybeSetCollapsed = (): void => {
|
const maybeSetCollapsed = (): void => {
|
||||||
if (!node.current) return;
|
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 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 direction = getTextDirection(status.search_index);
|
||||||
const className = clsx(baseClassName, {
|
const className = clsx(baseClassName, {
|
||||||
'cursor-pointer': onClick,
|
'cursor-pointer': onClick,
|
||||||
|
@ -180,9 +92,10 @@ const StatusContent: React.FC<IStatusContent> = ({
|
||||||
direction={direction}
|
direction={direction}
|
||||||
lang={status.language || undefined}
|
lang={status.language || undefined}
|
||||||
size={textSize}
|
size={textSize}
|
||||||
>
|
emojis={status.emojis.toJS()}
|
||||||
{content}
|
mentions={status.mentions.toJS()}
|
||||||
</Markup>,
|
html={{ __html: parsedHtml }}
|
||||||
|
/>,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
|
@ -207,9 +120,10 @@ const StatusContent: React.FC<IStatusContent> = ({
|
||||||
direction={direction}
|
direction={direction}
|
||||||
lang={status.language || undefined}
|
lang={status.language || undefined}
|
||||||
size={textSize}
|
size={textSize}
|
||||||
>
|
emojis={status.emojis.toJS()}
|
||||||
{content}
|
mentions={status.mentions.toJS()}
|
||||||
</Markup>,
|
html={{ __html: parsedHtml }}
|
||||||
|
/>,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (status.poll && typeof status.poll === 'string') {
|
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 { HotKeys } from 'soapbox/features/ui/components/hotkeys.tsx';
|
||||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
||||||
import { useSettings } from 'soapbox/hooks/useSettings.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 { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status.ts';
|
||||||
|
|
||||||
import EventPreview from './event-preview.tsx';
|
import EventPreview from './event-preview.tsx';
|
||||||
|
@ -231,23 +232,17 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
className='hover:underline'
|
className='hover:underline'
|
||||||
>
|
>
|
||||||
<bdi className='truncate'>
|
<bdi className='truncate'>
|
||||||
<strong
|
<strong className='text-gray-800 dark:text-gray-200'>
|
||||||
className='text-gray-800 dark:text-gray-200'
|
{emojifyText(status.account.display_name, status.account.emojis)}
|
||||||
dangerouslySetInnerHTML={{
|
</strong>
|
||||||
__html: status.account.display_name_html,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</bdi>
|
</bdi>
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
group: (
|
group: (
|
||||||
<Link to={`/group/${group.slug}`} className='hover:underline'>
|
<Link to={`/group/${group.slug}`} className='hover:underline'>
|
||||||
<strong
|
<strong className='text-gray-800 dark:text-gray-200'>
|
||||||
className='text-gray-800 dark:text-gray-200'
|
{group.display_name}
|
||||||
dangerouslySetInnerHTML={{
|
</strong>
|
||||||
__html: group.display_name_html,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
@ -268,12 +263,9 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
name: (
|
name: (
|
||||||
<Link to={`/@${status.account.acct}`} className='hover:underline'>
|
<Link to={`/@${status.account.acct}`} className='hover:underline'>
|
||||||
<bdi className='truncate'>
|
<bdi className='truncate'>
|
||||||
<strong
|
<strong className='text-gray-800 dark:text-gray-200'>
|
||||||
className='text-gray-800 dark:text-gray-200'
|
{emojifyText(status.account.display_name, status.account.emojis)}
|
||||||
dangerouslySetInnerHTML={{
|
</strong>
|
||||||
__html: status.account.display_name_html,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</bdi>
|
</bdi>
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
|
@ -306,7 +298,7 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
<Link to={`/group/${group.slug}`} className='hover:underline'>
|
<Link to={`/group/${group.slug}`} className='hover:underline'>
|
||||||
<bdi className='truncate'>
|
<bdi className='truncate'>
|
||||||
<strong className='text-gray-800 dark:text-gray-200'>
|
<strong className='text-gray-800 dark:text-gray-200'>
|
||||||
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
<span>{group.display_name}</span>
|
||||||
</strong>
|
</strong>
|
||||||
</bdi>
|
</bdi>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -129,7 +129,7 @@ const SensitiveContentOverlay = forwardRef<HTMLDivElement, ISensitiveContentOver
|
||||||
<div className='py-4 italic'>
|
<div className='py-4 italic'>
|
||||||
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||||
<Text className='line-clamp-6' theme='white' size='md' weight='medium'>
|
<Text className='line-clamp-6' theme='white' size='md' weight='medium'>
|
||||||
“<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} />”
|
“<span>{status.spoiler_text}</span>”
|
||||||
</Text>
|
</Text>
|
||||||
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -11,7 +11,7 @@ const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
|
||||||
const px = `${size}px`;
|
const px = `${size}px`;
|
||||||
|
|
||||||
return (
|
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}
|
{emoji}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -27,7 +27,7 @@ const MovedNote: React.FC<IMovedNote> = ({ from, to }) => (
|
||||||
id='notification.move'
|
id='notification.move'
|
||||||
defaultMessage='{name} moved to {targetName}'
|
defaultMessage='{name} moved to {targetName}'
|
||||||
values={{
|
values={{
|
||||||
name: <span dangerouslySetInnerHTML={{ __html: from.display_name_html }} />,
|
name: from.display_name,
|
||||||
targetName: to.acct,
|
targetName: to.acct,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -48,7 +48,7 @@ const Announcement: React.FC<IAnnouncement> = ({ announcement }) => {
|
||||||
return (
|
return (
|
||||||
<div key={announcement.id} className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
|
<div key={announcement.id} className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
|
||||||
<Stack space={2}>
|
<Stack space={2}>
|
||||||
<Text dangerouslySetInnerHTML={{ __html: announcement.contentHtml }} />
|
<Text dangerouslySetInnerHTML={{ __html: announcement.content }} />
|
||||||
{(announcement.starts_at || announcement.ends_at || announcement.all_day) && (
|
{(announcement.starts_at || announcement.ends_at || announcement.all_day) && (
|
||||||
<HStack space={2} wrap>
|
<HStack space={2} wrap>
|
||||||
{announcement.starts_at && (
|
{announcement.starts_at && (
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import Text from 'soapbox/components/ui/text.tsx';
|
import Text from 'soapbox/components/ui/text.tsx';
|
||||||
import emojify from 'soapbox/features/emoji/index.ts';
|
|
||||||
import { EmojiReaction } from 'soapbox/types/entities.ts';
|
import { EmojiReaction } from 'soapbox/types/entities.ts';
|
||||||
|
|
||||||
interface IChatMessageReaction {
|
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>
|
<Text tag='span' weight='medium' size='sm'>{emojiReaction.count}</Text>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,8 +6,6 @@ import moodSmileIcon from '@tabler/icons/outline/mood-smile.svg';
|
||||||
import trashIcon from '@tabler/icons/outline/trash.svg';
|
import trashIcon from '@tabler/icons/outline/trash.svg';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import graphemesplit from 'graphemesplit';
|
|
||||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
|
||||||
import escape from 'lodash/escape';
|
import escape from 'lodash/escape';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
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 Icon from 'soapbox/components/ui/icon.tsx';
|
||||||
import Stack from 'soapbox/components/ui/stack.tsx';
|
import Stack from 'soapbox/components/ui/stack.tsx';
|
||||||
import Text from 'soapbox/components/ui/text.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 { MediaGallery } from 'soapbox/features/ui/util/async-components.ts';
|
||||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
||||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
||||||
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
|
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
|
||||||
import { ChatKeys, IChat, useChatActions } from 'soapbox/queries/chats.ts';
|
import { ChatKeys, IChat, useChatActions } from 'soapbox/queries/chats.ts';
|
||||||
import { queryClient } from 'soapbox/queries/client.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 ChatMessageReactionWrapper from './chat-message-reaction-wrapper/chat-message-reaction-wrapper.tsx';
|
||||||
import ChatMessageReaction from './chat-message-reaction.tsx';
|
import ChatMessageReaction from './chat-message-reaction.tsx';
|
||||||
|
@ -42,23 +40,13 @@ const messages = defineMessages({
|
||||||
report: { id: 'chats.actions.report', defaultMessage: 'Report' },
|
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) => {
|
const parsePendingContent = (content: string) => {
|
||||||
return escape(content).replace(/(?:\r\n|\r|\n)/g, '<br>');
|
return escape(content).replace(/(?:\r\n|\r|\n)/g, '<br>');
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseContent = (chatMessage: ChatMessageEntity) => {
|
const parseContent = (chatMessage: ChatMessageEntity) => {
|
||||||
const content = chatMessage.content || '';
|
const { content, pending, deleting } = chatMessage;
|
||||||
const pending = chatMessage.pending;
|
return (pending && !deleting) ? parsePendingContent(content) : content;
|
||||||
const deleting = chatMessage.deleting;
|
|
||||||
const formatted = (pending && !deleting) ? parsePendingContent(content) : content;
|
|
||||||
const emojiMap = makeEmojiMap(chatMessage);
|
|
||||||
return emojify(formatted, emojiMap.toJS());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IChatMessage {
|
interface IChatMessage {
|
||||||
|
@ -98,10 +86,7 @@ const ChatMessage = (props: IChatMessage) => {
|
||||||
&& lastReadMessageTimestamp
|
&& lastReadMessageTimestamp
|
||||||
&& lastReadMessageTimestamp >= new Date(chatMessage.created_at);
|
&& lastReadMessageTimestamp >= new Date(chatMessage.created_at);
|
||||||
|
|
||||||
const isOnlyEmoji = useMemo(() => {
|
const isOnlyEmoji = useMemo(() => _isOnlyEmoji(content, props.chatMessage.emojis.toJS(), 3), [content]);
|
||||||
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 emojiReactionRows = useMemo(() => {
|
const emojiReactionRows = useMemo(() => {
|
||||||
if (!chatMessage.emoji_reactions) {
|
if (!chatMessage.emoji_reactions) {
|
||||||
|
@ -136,7 +121,7 @@ const ChatMessage = (props: IChatMessage) => {
|
||||||
|
|
||||||
const handleCopyText = (chatMessage: ChatMessageEntity) => {
|
const handleCopyText = (chatMessage: ChatMessageEntity) => {
|
||||||
if (navigator.clipboard) {
|
if (navigator.clipboard) {
|
||||||
const text = stripHTML(chatMessage.content);
|
const text = htmlToPlaintext(chatMessage.content);
|
||||||
navigator.clipboard.writeText(text);
|
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 Text from 'soapbox/components/ui/text.tsx';
|
||||||
import VerificationBadge from 'soapbox/components/verification-badge.tsx';
|
import VerificationBadge from 'soapbox/components/verification-badge.tsx';
|
||||||
import useAccountSearch from 'soapbox/queries/search.ts';
|
import useAccountSearch from 'soapbox/queries/search.ts';
|
||||||
|
import { emojifyText } from 'soapbox/utils/emojify.tsx';
|
||||||
|
|
||||||
import type { Account } from 'soapbox/types/entities.ts';
|
import type { Account } from 'soapbox/types/entities.ts';
|
||||||
|
|
||||||
|
@ -41,7 +42,10 @@ const Results = ({ accountSearchResult, onSelect }: IResults) => {
|
||||||
|
|
||||||
<Stack alignItems='start'>
|
<Stack alignItems='start'>
|
||||||
<div className='flex grow items-center space-x-1'>
|
<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 />}
|
{account.verified && <VerificationBadge />}
|
||||||
</div>
|
</div>
|
||||||
<Text size='sm' weight='medium' theme='muted' direction='ltr' truncate>@{account.acct}</Text> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
|
<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'
|
id='compose.reply_group_indicator.message'
|
||||||
defaultMessage='Posting to {groupLink}'
|
defaultMessage='Posting to {groupLink}'
|
||||||
values={{
|
values={{
|
||||||
groupLink: <Link
|
groupLink: (
|
||||||
to={`/group/${group.slug}`}
|
<Link to={`/group/${group.slug}`}>
|
||||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
{group.display_name}
|
||||||
/>,
|
</Link>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Text>
|
</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)}>
|
<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
|
<AccountContainer
|
||||||
{...actions}
|
{...actions}
|
||||||
id={status.getIn(['account', 'id']) as string}
|
id={status.account.id}
|
||||||
timestamp={status.created_at}
|
timestamp={status.created_at}
|
||||||
showProfileHoverCard={false}
|
showProfileHoverCard={false}
|
||||||
withLinkToProfile={false}
|
withLinkToProfile={false}
|
||||||
|
@ -49,8 +49,10 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActi
|
||||||
<Markup
|
<Markup
|
||||||
className='break-words'
|
className='break-words'
|
||||||
size='sm'
|
size='sm'
|
||||||
dangerouslySetInnerHTML={{ __html: status.content }}
|
|
||||||
direction={getTextDirection(status.search_index)}
|
direction={getTextDirection(status.search_index)}
|
||||||
|
emojis={status.emojis.toJS()}
|
||||||
|
mentions={status.mentions.toJS()}
|
||||||
|
html={{ __html: status.content }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{status.media_attachments.size > 0 && (
|
{status.media_attachments.size > 0 && (
|
||||||
|
|
|
@ -57,8 +57,9 @@ const AccountCard: React.FC<IAccountCard> = ({ id }) => {
|
||||||
truncate
|
truncate
|
||||||
align='left'
|
align='left'
|
||||||
className='[&_br]:hidden [&_p:first-child]:inline [&_p:first-child]:truncate [&_p]:hidden'
|
className='[&_br]:hidden [&_p:first-child]:inline [&_p:first-child]:truncate [&_p]:hidden'
|
||||||
dangerouslySetInnerHTML={{ __html: account.note_emojified || ' ' }}
|
>
|
||||||
/>
|
{account.note}
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<div className='grid grid-cols-3 gap-1 py-4'>
|
<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 { Emoji as EmojiMart, CustomEmoji as EmojiMartCustom } from 'soapbox/features/emoji/data.ts';
|
||||||
import type { CustomEmoji as MastodonCustomEmoji } from 'soapbox/schemas/custom-emoji.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;
|
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 }[] => {
|
export const parseHTML = (str: string): { text: boolean; data: string }[] => {
|
||||||
const tokens = [];
|
const tokens = [];
|
||||||
let buf = '';
|
let buf = '';
|
||||||
|
@ -194,19 +84,6 @@ export const parseHTML = (str: string): { text: boolean; data: string }[] => {
|
||||||
return tokens;
|
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>[] {
|
export function buildCustomEmojis(customEmojis: MastodonCustomEmoji[]): EmojiMart<EmojiMartCustom>[] {
|
||||||
const emojis: EmojiMart<EmojiMartCustom>[] = [];
|
const emojis: EmojiMart<EmojiMartCustom>[] = [];
|
||||||
|
|
||||||
|
|
|
@ -459,7 +459,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
|
||||||
name: (
|
name: (
|
||||||
<Link className='mention inline-block' to={`/@${account.acct}`}>
|
<Link className='mention inline-block' to={`/@${account.acct}`}>
|
||||||
<HStack space={1} alignItems='center' grow>
|
<HStack space={1} alignItems='center' grow>
|
||||||
<span dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
|
<span>{account.display_name}</span>
|
||||||
{account.verified && <VerificationBadge />}
|
{account.verified && <VerificationBadge />}
|
||||||
</HStack>
|
</HStack>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -8,6 +8,7 @@ import Stack from 'soapbox/components/ui/stack.tsx';
|
||||||
import Text from 'soapbox/components/ui/text.tsx';
|
import Text from 'soapbox/components/ui/text.tsx';
|
||||||
import VerificationBadge from 'soapbox/components/verification-badge.tsx';
|
import VerificationBadge from 'soapbox/components/verification-badge.tsx';
|
||||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
||||||
|
import { emojifyText } from 'soapbox/utils/emojify.tsx';
|
||||||
|
|
||||||
import ActionButton from '../ui/components/action-button.tsx';
|
import ActionButton from '../ui/components/action-button.tsx';
|
||||||
import { HotKeys } from '../ui/components/hotkeys.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}>
|
<HStack alignItems='center' justifyContent='center' space={1}>
|
||||||
<Text
|
<Text
|
||||||
weight='semibold'
|
weight='semibold'
|
||||||
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
|
|
||||||
truncate
|
truncate
|
||||||
align='center'
|
align='center'
|
||||||
size='sm'
|
size='sm'
|
||||||
className='max-w-[95%]'
|
className='max-w-[95%]'
|
||||||
/>
|
>
|
||||||
|
{emojifyText(account.display_name, account.emojis)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
{account.verified && <VerificationBadge />}
|
{account.verified && <VerificationBadge />}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
|
@ -139,12 +139,9 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Stack alignItems='center' space={3} className='mx-auto mt-10 w-5/6 py-4'>
|
<Stack alignItems='center' space={3} className='mx-auto mt-10 w-5/6 py-4'>
|
||||||
<Text
|
<Text size='xl' weight='bold' data-testid='group-name'>
|
||||||
size='xl'
|
{group.display_name}
|
||||||
weight='bold'
|
</Text>
|
||||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
|
||||||
data-testid='group-name'
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!isDeleted && (
|
{!isDeleted && (
|
||||||
<>
|
<>
|
||||||
|
@ -158,7 +155,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
||||||
<Text
|
<Text
|
||||||
theme='muted'
|
theme='muted'
|
||||||
align='center'
|
align='center'
|
||||||
dangerouslySetInnerHTML={{ __html: group.note_emojified }}
|
dangerouslySetInnerHTML={{ __html: group.note }}
|
||||||
className='[&_a]:text-primary-600 [&_a]:hover:underline [&_a]:dark:text-accent-blue'
|
className='[&_a]:text-primary-600 [&_a]:hover:underline [&_a]:dark:text-accent-blue'
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
||||||
import { useInstance } from 'soapbox/hooks/useInstance.ts';
|
import { useInstance } from 'soapbox/hooks/useInstance.ts';
|
||||||
import toast from 'soapbox/toast.tsx';
|
import toast from 'soapbox/toast.tsx';
|
||||||
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts.ts';
|
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 AvatarPicker from '../edit-profile/components/avatar-picker.tsx';
|
||||||
import HeaderPicker from '../edit-profile/components/header-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 header = useImageField({ maxPixels: 1920 * 1080, preview: nonDefaultHeader(group?.header) });
|
||||||
|
|
||||||
const displayName = useTextField(group?.display_name);
|
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 maxName = Number(instance.configuration.groups.max_characters_name);
|
||||||
const maxNote = Number(instance.configuration.groups.max_characters_description);
|
const maxNote = Number(instance.configuration.groups.max_characters_description);
|
||||||
|
|
|
@ -90,7 +90,7 @@ const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
|
||||||
|
|
||||||
<List>
|
<List>
|
||||||
<ListItem label={intl.formatMessage(messages.editGroup)} to={`/group/${group.slug}/manage/edit`}>
|
<ListItem label={intl.formatMessage(messages.editGroup)} to={`/group/${group.slug}/manage/edit`}>
|
||||||
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
{group.display_name}
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</List>
|
</List>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -51,12 +51,9 @@ const GroupGridItem = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDiv
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Stack space={1}>
|
<Stack space={1}>
|
||||||
<Text
|
<Text weight='bold' theme='inherit' truncate>
|
||||||
weight='bold'
|
{group.display_name}
|
||||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
</Text>
|
||||||
theme='inherit'
|
|
||||||
truncate
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HStack alignItems='center' space={1}>
|
<HStack alignItems='center' space={1}>
|
||||||
<GroupPrivacy group={group} />
|
<GroupPrivacy group={group} />
|
||||||
|
|
|
@ -34,11 +34,9 @@ const GroupListItem = (props: IGroupListItem) => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Stack className='overflow-hidden'>
|
<Stack className='overflow-hidden'>
|
||||||
<Text
|
<Text weight='bold' truncate>
|
||||||
weight='bold'
|
{group.display_name}
|
||||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
</Text>
|
||||||
truncate
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HStack className='text-gray-700 dark:text-gray-600' space={1} alignItems='center'>
|
<HStack className='text-gray-700 dark:text-gray-600' space={1} alignItems='center'>
|
||||||
<Icon
|
<Icon
|
||||||
|
|
|
@ -28,7 +28,7 @@ const GroupLinkPreview: React.FC<IGroupLinkPreview> = ({ card }) => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Stack space={4} className='p-4'>
|
<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>
|
<Button theme='primary' to={`/group/${group.slug}`} block>
|
||||||
<FormattedMessage id='group.popover.action' defaultMessage='View Group' />
|
<FormattedMessage id='group.popover.action' defaultMessage='View Group' />
|
||||||
|
|
|
@ -19,8 +19,8 @@ const SiteBanner: React.FC = () => {
|
||||||
|
|
||||||
<Markup
|
<Markup
|
||||||
size='lg'
|
size='lg'
|
||||||
dangerouslySetInnerHTML={{ __html: description }}
|
|
||||||
direction={getTextDirection(description)}
|
direction={getTextDirection(description)}
|
||||||
|
html={{ __html: description }}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
|
@ -39,12 +39,9 @@ const GroupListItem = ({ group, onUnmute }: IGroupListItem) => {
|
||||||
size={42}
|
size={42}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text
|
<Text weight='semibold' size='sm' truncate>
|
||||||
weight='semibold'
|
{group.display_name}
|
||||||
size='sm'
|
</Text>
|
||||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
|
||||||
truncate
|
|
||||||
/>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<Button theme='primary' type='button' onClick={handleUnmute} size='sm'>
|
<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 { useInstance } from 'soapbox/hooks/useInstance.ts';
|
||||||
import { makeGetNotification } from 'soapbox/selectors/index.ts';
|
import { makeGetNotification } from 'soapbox/selectors/index.ts';
|
||||||
import toast from 'soapbox/toast.tsx';
|
import toast from 'soapbox/toast.tsx';
|
||||||
|
import { emojifyText } from 'soapbox/utils/emojify.tsx';
|
||||||
import { NotificationType, validType } from 'soapbox/utils/notification.ts';
|
import { NotificationType, validType } from 'soapbox/utils/notification.ts';
|
||||||
|
|
||||||
import type { ScrollPosition } from 'soapbox/components/status.tsx';
|
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'
|
className='font-bold text-gray-800 hover:underline dark:text-gray-200'
|
||||||
title={account.acct}
|
title={account.acct}
|
||||||
to={`/@${account.acct}`}
|
to={`/@${account.acct}`}
|
||||||
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
|
>
|
||||||
/>
|
{emojifyText(account.display_name, account.emojis)}
|
||||||
|
</Link>
|
||||||
</bdi>
|
</bdi>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -76,7 +76,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
||||||
<Link to={`/group/${(status.group as Group).slug}`} className='hover:underline'>
|
<Link to={`/group/${(status.group as Group).slug}`} className='hover:underline'>
|
||||||
<bdi className='truncate'>
|
<bdi className='truncate'>
|
||||||
<strong className='text-gray-800 dark:text-gray-200'>
|
<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>
|
</strong>
|
||||||
</bdi>
|
</bdi>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { logOut } from 'soapbox/actions/auth.ts';
|
import { logOut } from 'soapbox/actions/auth.ts';
|
||||||
import Text from 'soapbox/components/ui/text.tsx';
|
import Text from 'soapbox/components/ui/text.tsx';
|
||||||
import emojify from 'soapbox/features/emoji/index.ts';
|
|
||||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
||||||
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
|
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
|
||||||
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
|
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
|
||||||
|
@ -70,10 +69,9 @@ const LinkFooter: React.FC = (): JSX.Element => {
|
||||||
|
|
||||||
<Text theme='muted' size='sm'>
|
<Text theme='muted' size='sm'>
|
||||||
{soapboxConfig.linkFooterMessage ? (
|
{soapboxConfig.linkFooterMessage ? (
|
||||||
<span
|
<span className='inline-block align-middle'>
|
||||||
className='inline-block align-middle'
|
{soapboxConfig.linkFooterMessage}
|
||||||
dangerouslySetInnerHTML={{ __html: emojify(soapboxConfig.linkFooterMessage) }}
|
</span>
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='getting_started.open_source_notice'
|
id='getting_started.open_source_notice'
|
||||||
|
|
|
@ -43,26 +43,23 @@ const CompareHistoryModal: React.FC<ICompareHistoryModal> = ({ onClose, statusId
|
||||||
body = (
|
body = (
|
||||||
<div className='divide-y divide-solid divide-gray-200 dark:divide-gray-800'>
|
<div className='divide-y divide-solid divide-gray-200 dark:divide-gray-800'>
|
||||||
{versions?.map((version) => {
|
{versions?.map((version) => {
|
||||||
const content = { __html: version.content };
|
|
||||||
const spoilerContent = { __html: version.spoilerHtml };
|
|
||||||
|
|
||||||
const poll = typeof version.poll !== 'string' && version.poll;
|
const poll = typeof version.poll !== 'string' && version.poll;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col py-2 first:pt-0 last:pb-0'>
|
<div className='flex flex-col py-2 first:pt-0 last:pb-0'>
|
||||||
{version.spoiler_text?.length > 0 && (
|
{version.spoiler_text?.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<span dangerouslySetInnerHTML={spoilerContent} />
|
<span>{version.spoiler_text}</span>
|
||||||
<hr />
|
<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 && (
|
{poll && (
|
||||||
<div>
|
<div>
|
||||||
<Stack>
|
<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'>
|
<HStack alignItems='center' className='p-1 text-gray-900 dark:text-gray-300'>
|
||||||
<span
|
<span
|
||||||
className={clsx('mr-2.5 inline-block size-4 flex-none rounded-full border border-solid border-primary-600', {
|
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'}
|
role={poll.multiple ? 'checkbox' : 'radio'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span dangerouslySetInnerHTML={{ __html: option.title_emojified }} />
|
<span>{option.title}</span>
|
||||||
</HStack>
|
</HStack>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
@ -7,6 +7,7 @@ import Spinner from 'soapbox/components/ui/spinner.tsx';
|
||||||
import AccountContainer from 'soapbox/containers/account-container.tsx';
|
import AccountContainer from 'soapbox/containers/account-container.tsx';
|
||||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
||||||
import { makeGetAccount } from 'soapbox/selectors/index.ts';
|
import { makeGetAccount } from 'soapbox/selectors/index.ts';
|
||||||
|
import { emojifyText } from 'soapbox/utils/emojify.tsx';
|
||||||
|
|
||||||
const getAccount = makeGetAccount();
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
|
@ -28,7 +29,13 @@ const FamiliarFollowersModal = ({ accountId, onClose }: IFamiliarFollowersModal)
|
||||||
if (!account || !familiarFollowerIds) {
|
if (!account || !familiarFollowerIds) {
|
||||||
body = <Spinner />;
|
body = <Spinner />;
|
||||||
} else {
|
} 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 = (
|
body = (
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
|
@ -47,7 +54,13 @@ const FamiliarFollowersModal = ({ accountId, onClose }: IFamiliarFollowersModal)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<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}
|
onClose={onClickClose}
|
||||||
>
|
>
|
||||||
{body}
|
{body}
|
||||||
|
|
|
@ -63,7 +63,7 @@ const ConfirmationStep: React.FC<IConfirmationStep> = ({ group }) => {
|
||||||
<Text
|
<Text
|
||||||
size='md'
|
size='md'
|
||||||
className='mx-auto max-w-sm [&_a]:text-primary-600 [&_a]:hover:underline [&_a]:dark:text-accent-blue'
|
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>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
@ -15,6 +15,7 @@ import Modal from 'soapbox/components/ui/modal.tsx';
|
||||||
import Stack from 'soapbox/components/ui/stack.tsx';
|
import Stack from 'soapbox/components/ui/stack.tsx';
|
||||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
||||||
import { ZapSplitData } from 'soapbox/schemas/zap-split.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';
|
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 = () => {
|
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 = () => {
|
const handleNext = () => {
|
||||||
|
|
|
@ -8,6 +8,7 @@ import Button from 'soapbox/components/ui/button.tsx';
|
||||||
import HStack from 'soapbox/components/ui/hstack.tsx';
|
import HStack from 'soapbox/components/ui/hstack.tsx';
|
||||||
import Stack from 'soapbox/components/ui/stack.tsx';
|
import Stack from 'soapbox/components/ui/stack.tsx';
|
||||||
import { ZapSplitData } from 'soapbox/schemas/zap-split.ts';
|
import { ZapSplitData } from 'soapbox/schemas/zap-split.ts';
|
||||||
|
import { emojifyText } from 'soapbox/utils/emojify.tsx';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
zap_open_wallet: { id: 'zap.open_wallet', defaultMessage: 'Open Wallet' },
|
zap_open_wallet: { id: 'zap.open_wallet', defaultMessage: 'Open Wallet' },
|
||||||
|
@ -31,7 +32,11 @@ const ZapSplit = ({ zapData, zapAmount, invoice, onNext, isLastStep, onFinish }:
|
||||||
const renderTitleQr = () => {
|
const renderTitleQr = () => {
|
||||||
return (
|
return (
|
||||||
<div className='max-w-[280px] truncate'>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -37,7 +37,7 @@ const PinnedAccountsPanel: React.FC<IPinnedAccountsPanel> = ({ account, limit })
|
||||||
id='pinned_accounts.title'
|
id='pinned_accounts.title'
|
||||||
defaultMessage='{name}’s choices'
|
defaultMessage='{name}’s choices'
|
||||||
values={{
|
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 { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
||||||
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
|
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
|
||||||
import { makeGetAccount } from 'soapbox/selectors/index.ts';
|
import { makeGetAccount } from 'soapbox/selectors/index.ts';
|
||||||
|
import { emojifyText } from 'soapbox/utils/emojify.tsx';
|
||||||
|
|
||||||
import type { Account } from 'soapbox/schemas/index.ts';
|
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>
|
<HoverRefWrapper accountId={account.id} key={account.id} inline>
|
||||||
<Link className='inline-block text-primary-600 hover:underline dark:text-accent-blue' to={`/@${account.acct}`}>
|
<Link className='inline-block text-primary-600 hover:underline dark:text-accent-blue' to={`/@${account.acct}`}>
|
||||||
<HStack space={1} alignItems='center' grow>
|
<HStack space={1} alignItems='center' grow>
|
||||||
<Text
|
<Text size='sm' theme='primary' truncate>
|
||||||
size='sm'
|
{emojifyText(account.display_name, account.emojis)}
|
||||||
theme='primary'
|
</Text>
|
||||||
truncate
|
|
||||||
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{account.verified && <VerificationBadge />}
|
{account.verified && <VerificationBadge />}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
|
@ -7,6 +7,7 @@ import Markup from 'soapbox/components/markup.tsx';
|
||||||
import HStack from 'soapbox/components/ui/hstack.tsx';
|
import HStack from 'soapbox/components/ui/hstack.tsx';
|
||||||
import Icon from 'soapbox/components/ui/icon.tsx';
|
import Icon from 'soapbox/components/ui/icon.tsx';
|
||||||
import { CryptoAddress, LightningAddress } from 'soapbox/features/ui/util/async-components.ts';
|
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';
|
import type { Account } from 'soapbox/schemas/index.ts';
|
||||||
|
|
||||||
|
@ -34,27 +35,28 @@ interface IProfileField {
|
||||||
/** Renders a single profile field. */
|
/** Renders a single profile field. */
|
||||||
const ProfileField: React.FC<IProfileField> = ({ field }) => {
|
const ProfileField: React.FC<IProfileField> = ({ field }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const valuePlain = htmlToPlaintext(field.value);
|
||||||
|
|
||||||
if (isTicker(field.name)) {
|
if (isTicker(field.name)) {
|
||||||
return (
|
return (
|
||||||
<CryptoAddress
|
<CryptoAddress
|
||||||
ticker={getTicker(field.name).toLowerCase()}
|
ticker={getTicker(field.name).toLowerCase()}
|
||||||
address={field.value_plain}
|
address={valuePlain}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (isZapEmoji(field.name)) {
|
} else if (isZapEmoji(field.name)) {
|
||||||
return <LightningAddress address={field.value_plain} />;
|
return <LightningAddress address={valuePlain} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dl>
|
<dl>
|
||||||
<dt title={field.name}>
|
<dt title={field.name}>
|
||||||
<Markup weight='bold' tag='span' dangerouslySetInnerHTML={{ __html: field.name_emojified }} />
|
{field.name}
|
||||||
</dt>
|
</dt>
|
||||||
|
|
||||||
<dd
|
<dd
|
||||||
className={clsx({ 'text-success-500': field.verified_at })}
|
className={clsx({ 'text-success-500': field.verified_at })}
|
||||||
title={field.value_plain}
|
title={valuePlain}
|
||||||
>
|
>
|
||||||
<HStack space={2} alignItems='center'>
|
<HStack space={2} alignItems='center'>
|
||||||
{field.verified_at && (
|
{field.verified_at && (
|
||||||
|
@ -63,7 +65,11 @@ const ProfileField: React.FC<IProfileField> = ({ field }) => {
|
||||||
</span>
|
</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>
|
</HStack>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
|
@ -15,6 +15,7 @@ import Stack from 'soapbox/components/ui/stack.tsx';
|
||||||
import Text from 'soapbox/components/ui/text.tsx';
|
import Text from 'soapbox/components/ui/text.tsx';
|
||||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
||||||
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
|
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
|
||||||
|
import { emojifyText } from 'soapbox/utils/emojify.tsx';
|
||||||
import { capitalize } from 'soapbox/utils/strings.ts';
|
import { capitalize } from 'soapbox/utils/strings.ts';
|
||||||
|
|
||||||
import ProfileFamiliarFollowers from './profile-familiar-followers.tsx';
|
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 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 memberSinceDate = intl.formatDate(account.created_at, { month: 'long', year: 'numeric' });
|
||||||
const badges = getBadges();
|
const badges = getBadges();
|
||||||
|
|
||||||
|
@ -151,7 +151,9 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
|
||||||
<Stack space={2}>
|
<Stack space={2}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<HStack space={1} alignItems='center'>
|
<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)} />}
|
{account.bot && <Badge slug='bot' title={intl.formatMessage(messages.bot)} />}
|
||||||
|
|
||||||
|
@ -181,7 +183,7 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
|
||||||
<ProfileStats account={account} />
|
<ProfileStats account={account} />
|
||||||
|
|
||||||
{account.note.length > 0 && (
|
{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'>
|
<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 VerificationBadge from 'soapbox/components/verification-badge.tsx';
|
||||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
||||||
import { getAcct } from 'soapbox/utils/accounts.ts';
|
import { getAcct } from 'soapbox/utils/accounts.ts';
|
||||||
|
import { emojifyText } from 'soapbox/utils/emojify.tsx';
|
||||||
import { shortNumberFormat } from 'soapbox/utils/numbers.tsx';
|
import { shortNumberFormat } from 'soapbox/utils/numbers.tsx';
|
||||||
import { displayFqn } from 'soapbox/utils/state.ts';
|
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));
|
const fqn = useAppSelector((state) => displayFqn(state));
|
||||||
|
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
const displayNameHtml = { __html: account.display_name_html };
|
|
||||||
const acct = !account.acct.includes('@') && domain ? `${account.acct}@${domain}` : account.acct;
|
const acct = !account.acct.includes('@') && domain ? `${account.acct}@${domain}` : account.acct;
|
||||||
const header = account.header;
|
const header = account.header;
|
||||||
const verified = account.verified;
|
const verified = account.verified;
|
||||||
|
@ -59,7 +59,9 @@ const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain })
|
||||||
<Stack>
|
<Stack>
|
||||||
<Link to={`/@${account.acct}`}>
|
<Link to={`/@${account.acct}`}>
|
||||||
<HStack space={1} alignItems='center'>
|
<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 />}
|
{verified && <VerificationBadge />}
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,7 @@ import Stack from 'soapbox/components/ui/stack.tsx';
|
||||||
import SvgIcon from 'soapbox/components/ui/svg-icon.tsx';
|
import SvgIcon from 'soapbox/components/ui/svg-icon.tsx';
|
||||||
import Text from 'soapbox/components/ui/text.tsx';
|
import Text from 'soapbox/components/ui/text.tsx';
|
||||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
||||||
|
import { emojifyText } from 'soapbox/utils/emojify.tsx';
|
||||||
|
|
||||||
import ZapButton from './zap-button/zap-button.tsx';
|
import ZapButton from './zap-button/zap-button.tsx';
|
||||||
|
|
||||||
|
@ -113,7 +114,11 @@ const ZapPayRequestForm = ({ account, status, onClose }: IZapPayRequestForm) =>
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text weight='semibold'>
|
<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>
|
</Text>
|
||||||
<Avatar src={account.avatar} size={50} />
|
<Avatar src={account.avatar} size={50} />
|
||||||
<DisplayNameInline account={account} />
|
<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>}
|
{hasZapSplit && <p className='absolute right-0 font-bold sm:-right-6 sm:text-xl'>sats</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasZapSplit && <span className='flex justify-center text-xs'>
|
{hasZapSplit && (
|
||||||
|
<span className='flex justify-center text-xs'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='zap.split_message.receiver'
|
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>
|
</Stack>
|
||||||
|
|
||||||
|
@ -162,7 +170,8 @@ const ZapPayRequestForm = ({ account, status, onClose }: IZapPayRequestForm) =>
|
||||||
<span className='text-[10px] sm:text-xs'>
|
<span className='text-[10px] sm:text-xs'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='zap.split_message.deducted'
|
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>
|
</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.
|
* Converts API accounts into our internal format.
|
||||||
* @see {@link https://docs.joinmastodon.org/entities/account/}
|
* @see {@link https://docs.joinmastodon.org/entities/account/}
|
||||||
*/
|
*/
|
||||||
import escapeTextContentForBrowser from 'escape-html';
|
|
||||||
import {
|
import {
|
||||||
Map as ImmutableMap,
|
Map as ImmutableMap,
|
||||||
List as ImmutableList,
|
List as ImmutableList,
|
||||||
|
@ -13,10 +12,8 @@ import {
|
||||||
|
|
||||||
import avatarMissing from 'soapbox/assets/images/avatar-missing.png';
|
import avatarMissing from 'soapbox/assets/images/avatar-missing.png';
|
||||||
import headerMissing from 'soapbox/assets/images/header-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 { normalizeEmoji } from 'soapbox/normalizers/emoji.ts';
|
||||||
import { unescapeHTML } from 'soapbox/utils/html.ts';
|
import { mergeDefined } from 'soapbox/utils/normalizers.ts';
|
||||||
import { mergeDefined, makeEmojiMap } from 'soapbox/utils/normalizers.ts';
|
|
||||||
|
|
||||||
import type { PatronAccount } from 'soapbox/reducers/patron.ts';
|
import type { PatronAccount } from 'soapbox/reducers/patron.ts';
|
||||||
import type { Emoji, Field, EmbeddedEntity, Relationship } from 'soapbox/types/entities.ts';
|
import type { Emoji, Field, EmbeddedEntity, Relationship } from 'soapbox/types/entities.ts';
|
||||||
|
@ -59,11 +56,8 @@ export const AccountRecord = ImmutableRecord({
|
||||||
|
|
||||||
// Internal fields
|
// Internal fields
|
||||||
admin: false,
|
admin: false,
|
||||||
display_name_html: '',
|
|
||||||
domain: '',
|
domain: '',
|
||||||
moderator: false,
|
moderator: false,
|
||||||
note_emojified: '',
|
|
||||||
note_plain: '',
|
|
||||||
patron: null as PatronAccount | null,
|
patron: null as PatronAccount | null,
|
||||||
relationship: null as Relationship | null,
|
relationship: null as Relationship | null,
|
||||||
should_refetch: false,
|
should_refetch: false,
|
||||||
|
@ -75,11 +69,6 @@ export const FieldRecord = ImmutableRecord({
|
||||||
name: '',
|
name: '',
|
||||||
value: '',
|
value: '',
|
||||||
verified_at: null as Date | null,
|
verified_at: null as Date | null,
|
||||||
|
|
||||||
// Internal fields
|
|
||||||
name_emojified: '',
|
|
||||||
value_emojified: '',
|
|
||||||
value_plain: '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// https://gitlab.com/soapbox-pub/soapbox/-/issues/549
|
// 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);
|
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 => {
|
const getDomainFromURL = (account: ImmutableMap<string, any>): string => {
|
||||||
try {
|
try {
|
||||||
const url = account.get('url');
|
const url = account.get('url');
|
||||||
|
@ -308,7 +272,6 @@ export const normalizeAccount = (account: Record<string, any>) => {
|
||||||
fixDisplayName(account);
|
fixDisplayName(account);
|
||||||
fixBirthday(account);
|
fixBirthday(account);
|
||||||
fixNote(account);
|
fixNote(account);
|
||||||
addInternalFields(account);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
* Group normalizer:
|
* Group normalizer:
|
||||||
* Converts API groups into our internal format.
|
* Converts API groups into our internal format.
|
||||||
*/
|
*/
|
||||||
import escapeTextContentForBrowser from 'escape-html';
|
|
||||||
import {
|
import {
|
||||||
Map as ImmutableMap,
|
Map as ImmutableMap,
|
||||||
List as ImmutableList,
|
List as ImmutableList,
|
||||||
|
@ -12,10 +11,7 @@ import {
|
||||||
|
|
||||||
import avatarMissing from 'soapbox/assets/images/avatar-missing.png';
|
import avatarMissing from 'soapbox/assets/images/avatar-missing.png';
|
||||||
import headerMissing from 'soapbox/assets/images/header-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 { 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';
|
import type { Emoji, GroupRelationship } from 'soapbox/types/entities.ts';
|
||||||
|
|
||||||
|
@ -45,9 +41,6 @@ export const GroupRecord = ImmutableRecord({
|
||||||
url: '',
|
url: '',
|
||||||
|
|
||||||
// Internal fields
|
// Internal fields
|
||||||
display_name_html: '',
|
|
||||||
note_emojified: '',
|
|
||||||
note_plain: '',
|
|
||||||
relationship: null as GroupRelationship | null,
|
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);
|
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 => {
|
const getDomainFromURL = (group: ImmutableMap<string, any>): string => {
|
||||||
try {
|
try {
|
||||||
const url = group.get('url');
|
const url = group.get('url');
|
||||||
|
@ -159,7 +127,6 @@ export const normalizeGroup = (group: Record<string, any>) => {
|
||||||
normalizeLocked(group);
|
normalizeLocked(group);
|
||||||
fixDisplayName(group);
|
fixDisplayName(group);
|
||||||
fixNote(group);
|
fixNote(group);
|
||||||
addInternalFields(group);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* Status edit normalizer
|
* Status edit normalizer
|
||||||
*/
|
*/
|
||||||
import escapeTextContentForBrowser from 'escape-html';
|
|
||||||
import {
|
import {
|
||||||
Map as ImmutableMap,
|
Map as ImmutableMap,
|
||||||
List as ImmutableList,
|
List as ImmutableList,
|
||||||
|
@ -10,12 +9,10 @@ import {
|
||||||
} from 'immutable';
|
} from 'immutable';
|
||||||
import DOMPurify from 'isomorphic-dompurify';
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
|
|
||||||
import emojify from 'soapbox/features/emoji/index.ts';
|
|
||||||
import { normalizeAttachment } from 'soapbox/normalizers/attachment.ts';
|
import { normalizeAttachment } from 'soapbox/normalizers/attachment.ts';
|
||||||
import { normalizeEmoji } from 'soapbox/normalizers/emoji.ts';
|
import { normalizeEmoji } from 'soapbox/normalizers/emoji.ts';
|
||||||
import { pollSchema } from 'soapbox/schemas/index.ts';
|
import { pollSchema } from 'soapbox/schemas/index.ts';
|
||||||
import { stripCompatibilityFeatures } from 'soapbox/utils/html.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';
|
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>,
|
poll: null as EmbeddedEntity<Poll>,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
spoiler_text: '',
|
spoiler_text: '',
|
||||||
|
|
||||||
// Internal fields
|
|
||||||
contentHtml: '',
|
|
||||||
spoilerHtml: '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizeAttachments = (statusEdit: ImmutableMap<string, any>) => {
|
const normalizeAttachments = (statusEdit: ImmutableMap<string, any>) => {
|
||||||
|
@ -59,13 +52,8 @@ const normalizeStatusPoll = (statusEdit: ImmutableMap<string, any>) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeContent = (statusEdit: ImmutableMap<string, any>) => {
|
const normalizeContent = (statusEdit: ImmutableMap<string, any>) => {
|
||||||
const emojiMap = makeEmojiMap(statusEdit.get('emojis'));
|
const content = DOMPurify.sanitize(stripCompatibilityFeatures(statusEdit.get('content')), { ADD_ATTR: ['target'] });
|
||||||
const contentHtml = DOMPurify.sanitize(stripCompatibilityFeatures(emojify(statusEdit.get('content'), emojiMap)), { ADD_ATTR: ['target'] });
|
return statusEdit.set('content', content);
|
||||||
const spoilerHtml = DOMPurify.sanitize(emojify(escapeTextContentForBrowser(statusEdit.get('spoiler_text')), emojiMap), { ADD_ATTR: ['target'] });
|
|
||||||
|
|
||||||
return statusEdit
|
|
||||||
.set('contentHtml', contentHtml)
|
|
||||||
.set('spoilerHtml', spoilerHtml);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const normalizeStatusEdit = (statusEdit: Record<string, any>) => {
|
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,
|
hidden: false,
|
||||||
search_index: '',
|
search_index: '',
|
||||||
showFiltered: true,
|
showFiltered: true,
|
||||||
spoilerHtml: '',
|
|
||||||
translation: null as ImmutableMap<string, string> | null,
|
translation: null as ImmutableMap<string, string> | null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -89,7 +89,11 @@ const BlockedBlankslate = ({ group }: { group: Group }) => (
|
||||||
id='group.banned.message'
|
id='group.banned.message'
|
||||||
defaultMessage='You are banned from {group}'
|
defaultMessage='You are banned from {group}'
|
||||||
values={{
|
values={{
|
||||||
group: <Text theme='inherit' tag='span' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />,
|
group: (
|
||||||
|
<Text theme='inherit' tag='span'>
|
||||||
|
{group.display_name}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</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 { SETTING_CHANGE, FE_NAME, SettingsAction } from '../actions/settings.ts';
|
||||||
import { TIMELINE_DELETE, TimelineAction } from '../actions/timelines.ts';
|
import { TIMELINE_DELETE, TimelineAction } from '../actions/timelines.ts';
|
||||||
import { normalizeAttachment } from '../normalizers/attachment.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 { Emoji } from 'soapbox/features/emoji/index.ts';
|
||||||
import type {
|
import type {
|
||||||
|
@ -442,7 +442,7 @@ export default function compose(state = initialState, action: ComposeAction | Ev
|
||||||
if (!action.withRedraft) {
|
if (!action.withRedraft) {
|
||||||
map.set('id', action.status.id);
|
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('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('in_reply_to', action.status.get('in_reply_to_id'));
|
||||||
map.set('privacy', action.status.get('visibility'));
|
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 { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||||
import DOMPurify from 'isomorphic-dompurify';
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
|
|
||||||
import emojify from 'soapbox/features/emoji/index.ts';
|
|
||||||
import { normalizeStatus } from 'soapbox/normalizers/index.ts';
|
import { normalizeStatus } from 'soapbox/normalizers/index.ts';
|
||||||
import { simulateEmojiReact, simulateUnEmojiReact } from 'soapbox/utils/emoji-reacts.ts';
|
import { simulateEmojiReact, simulateUnEmojiReact } from 'soapbox/utils/emoji-reacts.ts';
|
||||||
import { stripCompatibilityFeatures, unescapeHTML } from 'soapbox/utils/html.ts';
|
import { htmlToPlaintext, stripCompatibilityFeatures } from 'soapbox/utils/html.ts';
|
||||||
import { makeEmojiMap, normalizeId } from 'soapbox/utils/normalizers.ts';
|
import { normalizeId } from 'soapbox/utils/normalizers.ts';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EMOJI_REACT_REQUEST,
|
EMOJI_REACT_REQUEST,
|
||||||
|
@ -96,34 +94,22 @@ const buildSearchContent = (status: StatusRecord): string => {
|
||||||
status.content,
|
status.content,
|
||||||
]).concat(pollOptionTitles).concat(mentionedUsernames);
|
]).concat(pollOptionTitles).concat(mentionedUsernames);
|
||||||
|
|
||||||
return unescapeHTML(fields.join('\n\n')) || '';
|
return htmlToPlaintext(fields.join('\n\n')) || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only calculate these values when status first encountered
|
// Only calculate these values when status first encountered
|
||||||
// Otherwise keep the ones already in the reducer
|
// Otherwise keep the ones already in the reducer
|
||||||
export const calculateStatus = (
|
export const calculateStatus = (
|
||||||
status: StatusRecord,
|
status: StatusRecord,
|
||||||
oldStatus?: StatusRecord,
|
|
||||||
expandSpoilers: boolean = false,
|
expandSpoilers: boolean = false,
|
||||||
): StatusRecord => {
|
): 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 searchContent = buildSearchContent(status);
|
||||||
const emojiMap = makeEmojiMap(status.emojis);
|
|
||||||
|
|
||||||
return status.merge({
|
return status.merge({
|
||||||
search_index: domParser.parseFromString(searchContent, 'text/html').documentElement.textContent || '',
|
search_index: domParser.parseFromString(searchContent, 'text/html').documentElement.textContent || '',
|
||||||
content: DOMPurify.sanitize(stripCompatibilityFeatures(status.content), { USE_PROFILES: { html: true } }),
|
content: DOMPurify.sanitize(stripCompatibilityFeatures(status.content), { USE_PROFILES: { html: true } }),
|
||||||
spoilerHtml: DOMPurify.sanitize(emojify(escapeTextContentForBrowser(spoilerText), emojiMap), { USE_PROFILES: { html: true } }),
|
hidden: expandSpoilers ? false : status.spoiler_text.length > 0 || status.sensitive,
|
||||||
hidden: expandSpoilers ? false : spoilerText.length > 0 || status.sensitive,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check whether a status is a quote by secondary characteristics
|
// 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 => {
|
return normalizeStatus(status).withMutations(status => {
|
||||||
fixTranslation(status, oldStatus);
|
fixTranslation(status, oldStatus);
|
||||||
fixQuote(status, oldStatus);
|
fixQuote(status, oldStatus);
|
||||||
calculateStatus(status, oldStatus, expandSpoilers);
|
calculateStatus(status, expandSpoilers);
|
||||||
minifyStatus(status);
|
minifyStatus(status);
|
||||||
}) as ReducerStatus;
|
}) as ReducerStatus;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,16 +1,13 @@
|
||||||
import { NSchema as n } from '@nostrify/nostrify';
|
import { NSchema as n } from '@nostrify/nostrify';
|
||||||
import escapeTextContentForBrowser from 'escape-html';
|
|
||||||
import DOMPurify from 'isomorphic-dompurify';
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
import avatarMissing from 'soapbox/assets/images/avatar-missing.png';
|
import avatarMissing from 'soapbox/assets/images/avatar-missing.png';
|
||||||
import headerMissing from 'soapbox/assets/images/header-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 { customEmojiSchema } from './custom-emoji.ts';
|
||||||
import { Relationship } from './relationship.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';
|
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:/, '') }));
|
tags?.filter(tag => tag.startsWith('badge:')).map(tag => roleSchema.parse({ id: tag, name: tag.replace(/^badge:/, '') }));
|
||||||
|
|
||||||
/** Add internal fields to the account. */
|
/** Add internal fields to the account. */
|
||||||
const transformAccount = <T extends TransformableAccount>({ pleroma, other_settings, fields, ...account }: T) => {
|
const transformAccount = <T extends TransformableAccount>({ pleroma, other_settings, ...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 displayName = account.display_name.trim().length === 0 ? account.username : account.display_name;
|
const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name;
|
||||||
const domain = account.domain ?? getDomain(account.url || account.uri);
|
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,
|
avatar_static: account.avatar_static || account.avatar,
|
||||||
discoverable: account.discoverable || account.source?.pleroma?.discoverable || false,
|
discoverable: account.discoverable || account.source?.pleroma?.discoverable || false,
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
display_name_html: DOMPurify.sanitize(emojify(escapeTextContentForBrowser(displayName), customEmojiMap), { USE_PROFILES: { html: true } }),
|
|
||||||
domain,
|
domain,
|
||||||
fields: newFields,
|
|
||||||
fqn: account.fqn || (account.acct.includes('@') ? account.acct : `${account.acct}@${domain}`),
|
fqn: account.fqn || (account.acct.includes('@') ? account.acct : `${account.acct}@${domain}`),
|
||||||
header_static: account.header_static || account.header,
|
header_static: account.header_static || account.header,
|
||||||
moderator: pleroma?.is_moderator || false,
|
moderator: pleroma?.is_moderator || false,
|
||||||
local: pleroma?.is_local !== undefined ? pleroma.is_local : account.acct.split('@')[1] === undefined,
|
local: pleroma?.is_local !== undefined ? pleroma.is_local : account.acct.split('@')[1] === undefined,
|
||||||
location: account.location || pleroma?.location || other_settings?.location || '',
|
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,
|
pleroma,
|
||||||
roles: account.roles.length ? account.roles : filterBadges(pleroma?.tags),
|
roles: account.roles.length ? account.roles : filterBadges(pleroma?.tags),
|
||||||
staff: pleroma?.is_admin || pleroma?.is_moderator || false,
|
staff: pleroma?.is_admin || pleroma?.is_moderator || false,
|
||||||
|
|
|
@ -1,28 +1,15 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import emojify from 'soapbox/features/emoji/index.ts';
|
|
||||||
|
|
||||||
import { announcementReactionSchema } from './announcement-reaction.ts';
|
import { announcementReactionSchema } from './announcement-reaction.ts';
|
||||||
import { customEmojiSchema } from './custom-emoji.ts';
|
import { customEmojiSchema } from './custom-emoji.ts';
|
||||||
import { mentionSchema } from './mention.ts';
|
import { mentionSchema } from './mention.ts';
|
||||||
import { tagSchema } from './tag.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';
|
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/
|
// https://docs.joinmastodon.org/entities/announcement/
|
||||||
const baseAnnouncementSchema = z.object({
|
const announcementSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
content: z.string().catch(''),
|
content: z.string().catch(''),
|
||||||
starts_at: z.string().datetime().nullable().catch(null),
|
starts_at: z.string().datetime().nullable().catch(null),
|
||||||
|
@ -43,15 +30,13 @@ const baseAnnouncementSchema = z.object({
|
||||||
updated_at: dateSchema,
|
updated_at: dateSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const announcementSchema = baseAnnouncementSchema.transform(transformAnnouncement);
|
|
||||||
|
|
||||||
type Announcement = Resolve<z.infer<typeof announcementSchema>>;
|
type Announcement = Resolve<z.infer<typeof announcementSchema>>;
|
||||||
|
|
||||||
const adminAnnouncementSchema = baseAnnouncementSchema.extend({
|
const adminAnnouncementSchema = announcementSchema.extend({
|
||||||
pleroma: z.object({
|
pleroma: z.object({
|
||||||
raw_content: z.string().catch(''),
|
raw_content: z.string().catch(''),
|
||||||
}),
|
}),
|
||||||
}).transform(transformAnnouncement);
|
});
|
||||||
|
|
||||||
type AdminAnnouncement = Resolve<z.infer<typeof adminAnnouncementSchema>>;
|
type AdminAnnouncement = Resolve<z.infer<typeof adminAnnouncementSchema>>;
|
||||||
|
|
||||||
|
|
|
@ -5,5 +5,5 @@ import { groupSchema } from './group.ts';
|
||||||
test('groupSchema with a TruthSocial group', async () => {
|
test('groupSchema with a TruthSocial group', async () => {
|
||||||
const data = await import('soapbox/__fixtures__/group-truthsocial.json');
|
const data = await import('soapbox/__fixtures__/group-truthsocial.json');
|
||||||
const group = groupSchema.parse(data);
|
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 z from 'zod';
|
||||||
|
|
||||||
import avatarMissing from 'soapbox/assets/images/avatar-missing.png';
|
import avatarMissing from 'soapbox/assets/images/avatar-missing.png';
|
||||||
import headerMissing from 'soapbox/assets/images/header-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 { customEmojiSchema } from './custom-emoji.ts';
|
||||||
import { groupRelationshipSchema } from './group-relationship.ts';
|
import { groupRelationshipSchema } from './group-relationship.ts';
|
||||||
import { groupTagSchema } from './group-tag.ts';
|
import { groupTagSchema } from './group-tag.ts';
|
||||||
import { filteredArray, makeCustomEmojiMap } from './utils.ts';
|
import { filteredArray } from './utils.ts';
|
||||||
|
|
||||||
const groupSchema = z.object({
|
const groupSchema = z.object({
|
||||||
avatar: z.string().catch(avatarMissing),
|
avatar: z.string().catch(avatarMissing),
|
||||||
|
@ -42,12 +40,9 @@ const groupSchema = z.object({
|
||||||
group.header_static = group.header_static || group.header;
|
group.header_static = group.header_static || group.header;
|
||||||
group.locked = group.locked || group.group_visibility === 'members_only'; // TruthSocial
|
group.locked = group.locked || group.group_visibility === 'members_only'; // TruthSocial
|
||||||
|
|
||||||
const customEmojiMap = makeCustomEmojiMap(group.emojis);
|
|
||||||
return {
|
return {
|
||||||
...group,
|
...group,
|
||||||
display_name_html: emojify(escapeTextContentForBrowser(group.display_name), customEmojiMap),
|
note: DOMPurify.sanitize(group.note, { USE_PROFILES: { html: true } }),
|
||||||
note_emojified: emojify(group.note, customEmojiMap),
|
|
||||||
note_plain: group.source?.note || unescapeHTML(group.note),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -32,15 +32,4 @@ describe('normalizePoll()', () => {
|
||||||
expect(result.voted).toBe(false);
|
expect(result.voted).toBe(false);
|
||||||
expect(result.own_votes).toBe(null);
|
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 { z } from 'zod';
|
||||||
|
|
||||||
import emojify from 'soapbox/features/emoji/index.ts';
|
|
||||||
|
|
||||||
import { customEmojiSchema } from './custom-emoji.ts';
|
import { customEmojiSchema } from './custom-emoji.ts';
|
||||||
import { filteredArray, makeCustomEmojiMap } from './utils.ts';
|
import { filteredArray } from './utils.ts';
|
||||||
|
|
||||||
const pollOptionSchema = z.object({
|
const pollOptionSchema = z.object({
|
||||||
title: z.string().catch(''),
|
title: z.string().catch(''),
|
||||||
|
@ -27,22 +23,12 @@ const pollSchema = z.object({
|
||||||
non_anonymous: z.boolean().catch(false),
|
non_anonymous: z.boolean().catch(false),
|
||||||
}).optional().catch(undefined),
|
}).optional().catch(undefined),
|
||||||
}).transform((poll) => {
|
}).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 the user has votes, they have certainly voted.
|
||||||
if (poll.own_votes?.length) {
|
if (poll.own_votes?.length) {
|
||||||
poll.voted = true;
|
poll.voted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return poll;
|
||||||
...poll,
|
|
||||||
options: emojifiedOptions,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type Poll = z.infer<typeof pollSchema>;
|
type Poll = z.infer<typeof pollSchema>;
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
import escapeTextContentForBrowser from 'escape-html';
|
|
||||||
import DOMPurify from 'isomorphic-dompurify';
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import emojify from 'soapbox/features/emoji/index.ts';
|
import { htmlToPlaintext, stripCompatibilityFeatures } from 'soapbox/utils/html.ts';
|
||||||
import { stripCompatibilityFeatures, unescapeHTML } from 'soapbox/utils/html.ts';
|
|
||||||
|
|
||||||
import { accountSchema } from './account.ts';
|
import { accountSchema } from './account.ts';
|
||||||
import { attachmentSchema } from './attachment.ts';
|
import { attachmentSchema } from './attachment.ts';
|
||||||
|
@ -15,7 +13,7 @@ import { groupSchema } from './group.ts';
|
||||||
import { mentionSchema } from './mention.ts';
|
import { mentionSchema } from './mention.ts';
|
||||||
import { pollSchema } from './poll.ts';
|
import { pollSchema } from './poll.ts';
|
||||||
import { tagSchema } from './tag.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';
|
import type { Resolve } from 'soapbox/utils/types.ts';
|
||||||
|
|
||||||
|
@ -94,7 +92,7 @@ const buildSearchIndex = (status: TransformableStatus): string => {
|
||||||
...mentionedUsernames,
|
...mentionedUsernames,
|
||||||
];
|
];
|
||||||
|
|
||||||
const searchContent = unescapeHTML(fields.join('\n\n')) || '';
|
const searchContent = htmlToPlaintext(fields.join('\n\n')) || '';
|
||||||
return new DOMParser().parseFromString(searchContent, 'text/html').documentElement.textContent || '';
|
return new DOMParser().parseFromString(searchContent, 'text/html').documentElement.textContent || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -105,15 +103,10 @@ type Translation = {
|
||||||
|
|
||||||
/** Add internal fields to the status. */
|
/** Add internal fields to the status. */
|
||||||
const transformStatus = <T extends TransformableStatus>({ pleroma, ...status }: T) => {
|
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 {
|
return {
|
||||||
...status,
|
...status,
|
||||||
approval_status: 'approval' as const,
|
approval_status: 'approval' as const,
|
||||||
content,
|
content: DOMPurify.sanitize(stripCompatibilityFeatures(status.content), { USE_PROFILES: { html: true } }),
|
||||||
expectsCard: false,
|
expectsCard: false,
|
||||||
event: pleroma?.event,
|
event: pleroma?.event,
|
||||||
filtered: [],
|
filtered: [],
|
||||||
|
@ -124,7 +117,6 @@ const transformStatus = <T extends TransformableStatus>({ pleroma, ...status }:
|
||||||
})() : undefined,
|
})() : undefined,
|
||||||
search_index: buildSearchIndex(status),
|
search_index: buildSearchIndex(status),
|
||||||
showFiltered: false, // TODO: this should be removed from the schema and done somewhere else
|
showFiltered: false, // TODO: this should be removed from the schema and done somewhere else
|
||||||
spoilerHtml,
|
|
||||||
translation: undefined as Translation | undefined,
|
translation: undefined as Translation | undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
import type { CustomEmoji } from './custom-emoji.ts';
|
|
||||||
|
|
||||||
/** Ensure HTML content is a string, and drop empty `<p>` tags. */
|
/** Ensure HTML content is a string, and drop empty `<p>` tags. */
|
||||||
const contentSchema = z.string().catch('').transform((value) => value === '<p></p>' ? '' : value);
|
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. */
|
/** Validates the string as an emoji. */
|
||||||
const emojiSchema = z.string().refine((v) => /\p{Extended_Pictographic}|[\u{1F1E6}-\u{1F1FF}]{2}/u.test(v));
|
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) {
|
function jsonSchema(reviver?: (this: any, key: string, value: any) => any) {
|
||||||
return z.string().transform((value, ctx) => {
|
return z.string().transform((value, ctx) => {
|
||||||
try {
|
try {
|
||||||
|
@ -52,4 +42,4 @@ function coerceObject<T extends z.ZodRawShape>(shape: T) {
|
||||||
/** Validates a hex color code. */
|
/** 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);
|
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';
|
import * as html from './html.ts';
|
||||||
|
|
||||||
describe('html', () => {
|
describe('html', () => {
|
||||||
describe('unsecapeHTML', () => {
|
describe('htmlToPlaintext', () => {
|
||||||
it('returns unescaped HTML', () => {
|
it('converts html to plaintext, preserving linebreaks', () => {
|
||||||
const output = html.unescapeHTML('<p>lorem</p><p>ipsum</p><br><br>');
|
const output = html.htmlToPlaintext('<p>lorem</p><p>ipsum</p><br><br>');
|
||||||
expect(output).toEqual('lorem\n\nipsum\n<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. */
|
/** Remove compatibility markup for features Soapbox supports. */
|
||||||
export const stripCompatibilityFeatures = (html: string): string => {
|
export function stripCompatibilityFeatures(html: string): string {
|
||||||
const node = document.createElement('div');
|
const node = document.createElement('div');
|
||||||
node.innerHTML = html;
|
node.innerHTML = html;
|
||||||
|
|
||||||
|
@ -26,12 +18,12 @@ export const stripCompatibilityFeatures = (html: string): string => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return node.innerHTML;
|
return node.innerHTML;
|
||||||
};
|
}
|
||||||
|
|
||||||
/** Convert HTML to plaintext. */
|
/** Convert HTML to plaintext. */
|
||||||
// https://stackoverflow.com/a/822486
|
// https://stackoverflow.com/a/822486
|
||||||
export const stripHTML = (html: string) => {
|
export function htmlToPlaintext(html: string): string {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.innerHTML = html;
|
div.innerHTML = html;
|
||||||
return div.textContent || div.innerText || '';
|
return div.textContent || div.innerText || '';
|
||||||
};
|
}
|
||||||
|
|
|
@ -3,11 +3,6 @@ import z from 'zod';
|
||||||
/** Use new value only if old value is undefined */
|
/** Use new value only if old value is undefined */
|
||||||
export const mergeDefined = (oldVal: any, newVal: any) => oldVal === undefined ? newVal : oldVal;
|
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 */
|
/** Normalize entity ID */
|
||||||
export const normalizeId = (id: unknown): string | null => {
|
export const normalizeId = (id: unknown): string | null => {
|
||||||
return z.string().nullable().catch(null).parse(id);
|
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"
|
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
|
||||||
integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
|
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:
|
escape-string-regexp@^1.0.5:
|
||||||
version "1.0.5"
|
version "1.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
|
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
|
||||||
|
|
Loading…
Reference in New Issue