Merge branch 'develop' into hooks-migration

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-10-22 10:49:51 +02:00
commit 39b595940b
37 changed files with 494 additions and 461 deletions

View file

@ -24,14 +24,10 @@
</body>
<script>
window.BIG_BUFFET_CONFIG = {
baseUrl: 'https://pleroma.woodynet.net/',
baseUrl: 'https://pl.fediverse.pl/',
mountPoint: 'bigbuffet',
styles: ['default', 'generated'],
icons: {},
homePage: {
type: 'hashtag',
hashtag: 'ahashtag',
},
locale: 'en',
};
</script>

View file

@ -64,6 +64,7 @@
"emoji-mart": "^5.5.2",
"escape-html": "^1.0.3",
"graphemesplit": "^2.4.4",
"html-react-parser": "^5.1.18",
"immer": "^10.1.1",
"immutable": "^4.3.7",
"intersection-observer": "^0.12.2",

View file

@ -14,6 +14,8 @@ import ActionButton from 'bigbuffet/features/ui/components/action-button';
import { UserPanel } from 'bigbuffet/features/ui/util/async-components';
import { useAccountHoverCardStore } from 'bigbuffet/stores/account-hover-card';
import { ParsedContent } from './parsed-content';
import type { Account } from 'bigbuffet/normalizers/account';
const showAccountHoverCard = debounce((openAccountHoverCard, ref, accountId) => {
@ -93,7 +95,6 @@ export const AccountHoverCard: React.FC<IAccountHoverCard> = ({ visible = true }
};
if (!account) return null;
const accountBio = { __html: account.note_emojified };
// const memberSinceDate = intl.formatDate(account.created_at, { month: 'long', year: 'numeric' });
return (
@ -139,7 +140,9 @@ export const AccountHoverCard: React.FC<IAccountHoverCard> = ({ visible = true }
) : null} */}
{account.note.length > 0 && (
<p className='account-hover-card__note-date' dangerouslySetInnerHTML={accountBio} />
<p className='account-hover-card__note-date'>
<ParsedContent html={account.note} emojis={account.emojis} />
</p>
)}
</div>
</CardBody>

View file

@ -7,6 +7,7 @@ import RelativeTimestamp from 'bigbuffet/components/relative-timestamp';
import Avatar from 'bigbuffet/components/ui/avatar';
import VerificationBadge from 'bigbuffet/components/verification-badge';
import bigBuffetConfig from 'bigbuffet/config';
import Emojify from 'bigbuffet/features/emoji';
import ActionButton from 'bigbuffet/features/ui/components/action-button';
import { getAcct } from 'bigbuffet/utils/accounts';
@ -83,7 +84,9 @@ const Account = ({
const displayName = (
<div className='account-card__account__name__display-name'>
<p dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
<p>
<Emojify text={account.display_name} emojis={account.emojis} />
</p>
{account.verified && <VerificationBadge />}
</div>
@ -95,7 +98,11 @@ const Account = ({
<div className='account-card__account'>
<ProfilePopper
condition={showAccountHoverCard}
wrapper={(children) => <HoverAccountWrapper className='relative' accountId={account.id} inline>{children}</HoverAccountWrapper>}
wrapper={(children) => (
<HoverAccountWrapper className='relative' accountId={account.id} element='span'>
{children}
</HoverAccountWrapper>
)}
>
{withLinkToProfile ? (account.acct.includes('@') ? (
<a
@ -123,7 +130,7 @@ const Account = ({
<div className='account-card__account__name'>
<ProfilePopper
condition={showAccountHoverCard}
wrapper={(children) => <HoverAccountWrapper accountId={account.id} inline>{children}</HoverAccountWrapper>}
wrapper={(children) => <HoverAccountWrapper accountId={account.id} element='span'>{children}</HoverAccountWrapper>}
>
{withLinkToProfile ? (account.acct.includes('@') ? (
<a

View file

@ -2,6 +2,7 @@ import React from 'react';
import HoverAccountWrapper from 'bigbuffet/components/hover-account-wrapper';
import bigBuffetConfig from 'bigbuffet/config';
import Emojify from 'bigbuffet/features/emoji';
import { getAcct } from '../utils/accounts';
@ -10,7 +11,7 @@ import VerificationBadge from './verification-badge';
import type { Account } from 'bigbuffet/normalizers/account';
interface IDisplayName {
account: Pick<Account, 'id' | 'acct' | 'fqn' | 'verified' | 'display_name_html'>;
account: Pick<Account, 'id' | 'acct' | 'fqn' | 'verified' | 'display_name' | 'emojis'>;
withSuffix?: boolean;
children?: React.ReactNode;
}
@ -21,7 +22,9 @@ const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = t
const displayName = (
<div className='display-name__inner'>
<p dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
<p>
<Emojify text={account.display_name} emojis={account.emojis} />
</p>
{verified && <VerificationBadge />}
</div>
@ -31,7 +34,7 @@ const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = t
return (
<span className='display-name' data-testid='display-name'>
<HoverAccountWrapper accountId={account.id} inline>
<HoverAccountWrapper accountId={account.id} element='span'>
{displayName}
</HoverAccountWrapper>
{withSuffix && suffix}

View file

@ -4,6 +4,7 @@ import { defineMessages, useIntl } from 'react-intl';
import Icon from 'bigbuffet/components/icon';
import VerificationBadge from 'bigbuffet/components/verification-badge';
import Emojify from 'bigbuffet/features/emoji';
import EventActionButton from 'bigbuffet/features/event/components/event-action-button';
import EventDate from 'bigbuffet/features/event/components/event-date';
@ -56,7 +57,9 @@ const EventPreview: React.FC<IEventPreview> = ({ status, className, hideAction,
<div className='event-preview__detail'>
<Icon icon='user' />
<div className='display-name__inner'>
<span dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
<span>
<Emojify text={account.display_name} emojis={account.emojis} />
</span>
{account.verified && <VerificationBadge />}
</div>
</div>

View file

@ -0,0 +1,15 @@
import React from 'react';
import Link from './link';
interface IHashtagLink {
hashtag: string;
}
const HashtagLink: React.FC<IHashtagLink> = ({ hashtag }) => (
<Link to={`/tags/${hashtag}`} onClick={(e) => e.stopPropagation()}>
#{hashtag}
</Link>
);
export { HashtagLink as default };

View file

@ -17,17 +17,16 @@ const showAccountHoverCard = debounce((
interface IHoverAccountWrapper {
accountId: string;
inline?: boolean;
element?: 'div' | 'span' | 'bdi';
className?: string;
children: React.ReactNode;
}
/** Makes a profile hover card appear when the wrapped element is hovered. */
export const HoverAccountWrapper: React.FC<IHoverAccountWrapper> = ({ accountId, children, inline = false, className }) => {
export const HoverAccountWrapper: React.FC<IHoverAccountWrapper> = ({ accountId, children, element: Elem = 'div', className }) => {
const { openAccountHoverCard, closeAccountHoverCard } = useAccountHoverCardStore();
const ref = useRef<HTMLDivElement>(null);
const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div';
const handleMouseEnter = () => {
if (!isMobile(window.innerWidth) && ref.current) {

View file

@ -0,0 +1,118 @@
import parse, { Element, type HTMLReactParserOptions, domToReact, type DOMNode } from 'html-react-parser';
import DOMPurify from 'isomorphic-dompurify';
import React, { useMemo } from 'react';
import { Link } from 'react-router-dom';
import Emojify from 'bigbuffet/features/emoji';
import { makeEmojiMap } from 'bigbuffet/utils/normalizers';
import HashtagLink from './hashtag-link';
import HoverAccountWrapper from './hover-account-wrapper';
import StatusMention from './status-mention';
import type { CustomEmoji, Mention } from 'pl-api';
const nodesToText = (nodes: Array<DOMNode>): string =>
nodes.map(node => node.type === 'text' ? node.data : node.type === 'tag' ? nodesToText(node.children as Array<DOMNode>) : '').join('');
interface IParsedContent {
/** HTML content to display. */
html: string;
/** Array of mentioned accounts. */
mentions?: Array<Mention>;
/** Whether it's a status which has a quote. */
hasQuote?: boolean;
/** Related custom emojis. */
emojis?: Array<CustomEmoji>;
}
const ParsedContent: React.FC<IParsedContent> = (({ html, mentions, hasQuote, emojis }) => useMemo(() => {
if (html.length === 0) {
return null;
}
const emojiMap = emojis ? makeEmojiMap(emojis) : undefined;
const selectors: Array<string> = [];
// Quote posting
if (hasQuote) selectors.push('quote-inline');
const options: HTMLReactParserOptions = {
replace(domNode) {
if (!(domNode instanceof Element)) {
return;
}
if (['script', 'iframe'].includes(domNode.name)) {
return <></>;
}
if (domNode.attribs.class?.split(' ').some(className => selectors.includes(className))) {
return <></>;
}
if (domNode.name === 'a') {
const classes = domNode.attribs.class?.split(' ');
const fallback = (
// 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>
);
if (classes?.includes('mention')) {
if (mentions) {
const mention = mentions.find(({ url }) => domNode.attribs.href === url);
if (mention) {
return (
<HoverAccountWrapper accountId={mention.id} element='span'>
<Link
to={`/@${mention.acct}`}
className='mention status-link'
dir='ltr'
onClick={(e) => e.stopPropagation()}
>
@{mention.username}
</Link>
</HoverAccountWrapper>
);
}
} else if (domNode.attribs['data-user']) {
return (
<StatusMention accountId={domNode.attribs['data-user']} fallback={fallback} />
);
}
}
if (classes?.includes('hashtag')) {
const hashtag = nodesToText(domNode.children as Array<DOMNode>);
if (hashtag) {
return <HashtagLink hashtag={hashtag.replace(/^#/, '')} />;
}
}
return fallback;
}
},
transform(reactNode) {
if (typeof reactNode === 'string') {
return <Emojify text={reactNode} emojis={emojiMap} />;
}
return reactNode as JSX.Element;
},
};
return parse(DOMPurify.sanitize(html, { ADD_ATTR: ['target'], USE_PROFILES: { html: true } }), options);
}, [html, emojis]));
export { ParsedContent };

View file

@ -5,7 +5,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import RelativeTimestamp from '../relative-timestamp';
import Tooltip from '../ui/tooltip';
import type { Poll } from 'bigbuffet/normalizers/poll';
import type { Poll } from 'pl-api';
const messages = defineMessages({
closed: { id: 'poll.closed', defaultMessage: 'Closed' },

View file

@ -1,7 +1,9 @@
import React from 'react';
import { Motion, presets, spring } from 'react-motion';
import type { Poll } from 'bigbuffet/normalizers/poll';
import { ParsedContent } from '../parsed-content';
import type { Poll } from 'pl-api';
const PollPercentageBar: React.FC<{ percent: number }> = ({ percent }): JSX.Element => (
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { ...presets.gentle, precision: 0.1 }) }}>
@ -38,7 +40,9 @@ const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
<PollPercentageBar percent={percent} />
<div className='poll-option__label'>
<p dangerouslySetInnerHTML={{ __html: option.title_emojified }} />
<p>
<ParsedContent html={option.title} emojis={poll.emojis} />
</p>
</div>
<div className='poll-option__percentage'>

View file

@ -74,6 +74,7 @@ const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList &
}, ref) => {
const { autoloadMore } = bigBuffetConfig;
const listRef = React.useRef<HTMLDivElement>(null);
const parentRef = React.useRef<HTMLDivElement>(null);
/** Normalized children. */
@ -92,6 +93,7 @@ const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList &
count: data.length + (hasMore ? 1 : 0),
overscan: 3,
estimateSize: () => estimatedSize,
scrollMargin: listRef.current ? listRef.current.getBoundingClientRect().top + window.scrollY : 0,
});
useEffect(() => {
@ -156,6 +158,7 @@ const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList &
style={style}
>
<div
ref={listRef}
className={listClassName}
style={{
height: !showLoading && data.length ? virtualizer.getTotalSize() : undefined,
@ -174,7 +177,7 @@ const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList &
style={{
position: 'absolute',
width: '100%',
transform: `translateY(${item.start}px)`,
transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`,
}}
>
{renderItem(item.index)}

View file

@ -4,8 +4,10 @@ import { FormattedMessage } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import Icon from 'bigbuffet/components/icon';
import Emojify from 'bigbuffet/features/emoji';
import { onlyEmoji as isOnlyEmoji } from 'bigbuffet/utils/rich-content';
import { ParsedContent } from './parsed-content';
import Poll from './polls/poll';
import type { MinifiedStatus } from 'bigbuffet/reducers/statuses';
@ -138,7 +140,6 @@ const StatusContent: React.FC<IStatusContent> = ({ status, collapsable = false }
const withSpoiler = status.spoiler_text.length > 0;
const content = { __html: status.contentHtml };
const className = clsx('status-content', {
'status-content--with-spoiler': withSpoiler,
'status-content--collapsed': !!visibleElementsHeight,
@ -150,7 +151,7 @@ const StatusContent: React.FC<IStatusContent> = ({ status, collapsable = false }
if (status.spoiler_text) {
output.push(
<p key='status-title' className='status-title'>
<span dangerouslySetInnerHTML={{ __html: status.spoiler_text }} />
<Emojify text={status.spoiler_text} emojis={status.emojis} />
</p>,
);
}
@ -163,13 +164,19 @@ const StatusContent: React.FC<IStatusContent> = ({ status, collapsable = false }
className={clsx(className, {
'big-emoji': onlyEmoji,
})}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
data-markup
style={{
maxHeight: visibleElementsHeight,
}}
/>
>
<ParsedContent
html={status.content}
mentions={status.mentions}
hasQuote={!!status.quote_id}
emojis={status.emojis}
/>
</p>
);
if (status.translation) {
@ -181,9 +188,15 @@ const StatusContent: React.FC<IStatusContent> = ({ status, collapsable = false }
tabIndex={0}
key='status-content-translated'
className={className}
dangerouslySetInnerHTML={{ __html: status.translation.content! }}
data-markup
/>
>
<ParsedContent
html={status.translation.content}
mentions={status.mentions}
hasQuote={!!status.quote_id}
emojis={status.emojis}
/>
</p>
</div>,
);
} else {

View file

@ -0,0 +1,35 @@
import { useAccount } from 'pl-hooks';
import React from 'react';
import { Link } from 'react-router-dom';
import HoverAccountWrapper from './hover-account-wrapper';
interface IStatusMention {
accountId: string;
fallback?: JSX.Element;
}
const StatusMention: React.FC<IStatusMention> = ({ accountId, fallback }) => {
const { data: account } = useAccount(accountId);
if (!account) return (
<HoverAccountWrapper accountId={accountId} element='span'>
{fallback}
</HoverAccountWrapper>
);
return (
<HoverAccountWrapper accountId={accountId} element='span'>
<Link
to={`/@${account.acct}`}
className='mention'
dir='ltr'
onClick={(e) => e.stopPropagation()}
>
@{account.acct}
</Link>
</HoverAccountWrapper>
);
};
export { StatusMention as default };

View file

@ -51,7 +51,7 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
if (hoverable) {
return (
<HoverAccountWrapper key={account.id} accountId={account.id} inline>
<HoverAccountWrapper key={account.id} accountId={account.id} element='span'>
{link}
</HoverAccountWrapper>
);

View file

@ -8,6 +8,7 @@ import { toggleStatusHidden } from 'bigbuffet/actions/statuses';
import Icon from 'bigbuffet/components/icon';
import TranslateButton from 'bigbuffet/components/translate-button';
import AccountContainer from 'bigbuffet/containers/account-container';
import Emojify from 'bigbuffet/features/emoji';
import StatusInteractionBar from 'bigbuffet/features/status/components/status-interaction-bar';
import QuotedStatus from 'bigbuffet/features/status/containers/quoted-status-container';
import { HotKeys } from 'bigbuffet/features/ui/components/hotkeys';
@ -146,8 +147,6 @@ const Status: React.FC<IStatus> = (props) => {
let rebloggedByText, reblogElement, reblogElementMobile;
if (status.reblog && typeof status.reblog === 'object') {
const displayNameHtml = { __html: String(status.account.display_name_html) };
reblogElement = (
<NavLink
to={`/@${status.account.acct}`}
@ -161,9 +160,13 @@ const Status: React.FC<IStatus> = (props) => {
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: <bdi>
<strong dangerouslySetInnerHTML={displayNameHtml} />
</bdi>,
name: (
<bdi>
<strong>
<Emojify text={status.account.display_name} emojis={status.account.emojis} />
</strong>
</bdi>
),
}}
/>
</div>
@ -183,7 +186,13 @@ const Status: React.FC<IStatus> = (props) => {
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: <bdi><strong dangerouslySetInnerHTML={displayNameHtml} /></bdi>,
name: (
<bdi>
<strong>
<Emojify text={status.account.display_name} emojis={status.account.emojis} />
</strong>
</bdi>
),
}}
/>
</span>

View file

@ -3,6 +3,7 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { toggleStatusHidden } from 'bigbuffet/actions/statuses';
import Emojify from 'bigbuffet/features/emoji';
import { useAppDispatch } from 'bigbuffet/hooks/useAppDispatch';
import Button from '../ui/button';
@ -17,7 +18,7 @@ const messages = defineMessages({
});
interface ISensitiveContentOverlay {
status: Pick<Status, 'id' | 'sensitive' | 'hidden' | 'media_attachments' | 'currentLanguage' | 'spoilerHtml' | 'spoilerMapHtml' | 'spoiler_text'>;
status: Pick<Status, 'id' | 'sensitive' | 'hidden' | 'media_attachments' | 'currentLanguage' | 'spoiler_text' | 'emojis'>;
}
const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveContentOverlay>(({ status }, ref) => {
@ -65,7 +66,9 @@ const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveConte
{status.spoiler_text && (
<div className='sensitive-content-overlay__spoiler'>
<p>
&ldquo;<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} />&rdquo;
&ldquo;<span>
<Emojify text={status.spoiler_text} emojis={status.emojis} />
</span>&rdquo;
</p>
</div>
)}

View file

@ -12,7 +12,7 @@ import Icon from './icon';
import type { Status } from 'bigbuffet/normalizers/status';
interface ITranslateButton {
status: Pick<Status, 'id' | 'account' | 'contentHtml' | 'contentMapHtml' | 'language' | 'translating' | 'translation' | 'visibility'>;
status: Pick<Status, 'id' | 'account' | 'content' | 'language' | 'translating' | 'translation' | 'visibility'>;
}
const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
@ -28,7 +28,7 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
allow_unauthenticated: allowUnauthenticated,
} = instance.pleroma.metadata.translation;
const renderTranslate = allowUnauthenticated && (allowRemote || status.account.local) && ['public', 'unlisted'].includes(status.visibility) && status.contentHtml.length > 0 && status.language !== null && intl.locale !== status.language;
const renderTranslate = allowUnauthenticated && (allowRemote || status.account.local) && ['public', 'unlisted'].includes(status.visibility) && status.content.trim().length && status.language !== null && intl.locale !== status.language;
const supportsLanguages = (translationLanguages[status.language!]?.includes(intl.locale));

View file

@ -3,11 +3,12 @@ import { FormattedMessage } from 'react-intl';
import Account from 'bigbuffet/components/account';
import Icon from 'bigbuffet/components/icon';
import Emojify from 'bigbuffet/features/emoji';
import type { Account as AccountEntity } from 'bigbuffet/normalizers/account';
interface IMovedNote {
from: Pick<AccountEntity, 'display_name_html'>;
from: Pick<AccountEntity, 'display_name' | 'emojis'>;
to: AccountEntity;
}
@ -21,7 +22,11 @@ const MovedNote: React.FC<IMovedNote> = ({ from, to }) => (
id='notification.move'
defaultMessage='{name} moved to {targetName}'
values={{
name: <span dangerouslySetInnerHTML={{ __html: from.display_name_html }} />,
name: (
<span>
<Emojify text={from.display_name} emojis={from.emojis} />
</span>
),
targetName: to.acct,
}}
/>

View file

@ -1,15 +1,11 @@
export interface NativeEmoji {
interface NativeEmoji {
unified: string;
native: string;
x: number;
y: number;
}
export interface CustomEmoji {
src: string;
}
export interface Emoji<T> {
interface Emoji<T> {
id: string;
name: string;
keywords: string[];
@ -17,27 +13,10 @@ export interface Emoji<T> {
version?: number;
}
export interface EmojiCategory {
id: string;
emojis: string[];
}
export interface EmojiMap {
interface EmojiMap {
[s: string]: Emoji<NativeEmoji>;
}
export interface EmojiAlias {
[s: string]: string;
}
export interface EmojiSheet {
cols: number;
rows: number;
}
export interface EmojiData {
categories: EmojiCategory[];
emojis: EmojiMap;
aliases: EmojiAlias;
sheet: EmojiSheet;
}

View file

@ -1,196 +0,0 @@
import split from 'graphemesplit';
import * as BuildConfig from 'bigbuffet/build-config';
import unicodeMapping from './mapping';
/*
* TODO: Consolate emoji object types
*
* There are five different emoji objects currently
* - emoji-mart's "onPickEmoji" handler
* - emoji-mart's custom emoji types
* - an Emoji type that is either NativeEmoji or CustomEmoji
* - a type inside redux's `store.custom_emoji` immutablejs
*
* there needs to be one type for the picker handler callback
* and one type for the emoji-mart data
* and one type that is used everywhere that the above two are converted into
*/
export interface CustomEmoji {
id: string;
colons: string;
custom: true;
imageUrl: string;
}
export interface NativeEmoji {
id: string;
colons: string;
custom?: false;
unified: string;
native: string;
}
export type Emoji = CustomEmoji | NativeEmoji;
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) => isAlphaNumeric(c)
|| c === '_'
|| c === '-'
|| c === '.';
const convertCustom = (shortname: string, filename: string) =>
`<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`;
const convertUnicode = (c: string) => {
const { unified, shortcode } = unicodeMapping[c];
return `<img draggable="false" class="emojione" alt="${c}" title=":${shortcode}:" src="/${BuildConfig.ASSETS_DIR}/emoji/${unified}.svg" />`;
};
const convertEmoji = (str: string, customEmojis: any) => {
if (str.length < 3) return str;
if (str in customEmojis) {
const emoji = customEmojis[str];
const filename = emoji.static_url;
if (filename?.length > 0) {
return convertCustom(str, filename);
}
}
return str;
};
export const emojifyText = (str: string, customEmojis = {}) => {
let buf = '';
let stack = '';
let open = false;
const clearStack = () => {
buf += stack;
open = false;
stack = '';
};
for (let c of split(str)) {
// convert FE0E selector to FE0F so it can be found in unimap
if (c.codePointAt(c.length - 1) === 65038) {
c = c.slice(0, -1) + String.fromCodePoint(65039);
}
// unqualified emojis aren't in emoji-mart's mappings so we just add FEOF
const unqualified = c + String.fromCodePoint(65039);
if (c in unicodeMapping) {
if (open) { // unicode emoji inside colon
clearStack();
}
buf += convertUnicode(c);
} else if (unqualified in unicodeMapping) {
if (open) { // unicode emoji inside colon
clearStack();
}
buf += convertUnicode(unqualified);
} else if (c === ':') {
stack += ':';
// we see another : we convert it and clear the stack buffer
if (open) {
buf += convertEmoji(stack, customEmojis);
stack = '';
}
open = !open;
} else {
if (open) {
stack += c;
// if the stack is non-null and we see invalid chars it's a string not emoji
// so we push it to the return result and clear it
if (!validEmojiChar(c)) {
clearStack();
}
} else {
buf += c;
}
}
}
// never found a closing colon so it's just a raw string
if (open) {
buf += stack;
}
return buf;
};
export const parseHTML = (str: string): { text: boolean; data: string }[] => {
const tokens = [];
let buf = '';
let stack = '';
let open = false;
for (const c of str) {
if (c === '<') {
if (open) {
tokens.push({ text: true, data: stack });
stack = '<';
} else {
tokens.push({ text: true, data: buf });
stack = '<';
open = true;
}
} else if (c === '>') {
if (open) {
open = false;
tokens.push({ text: false, data: stack + '>' });
stack = '';
buf = '';
} else {
buf += '>';
}
} else {
if (open) {
stack += c;
} else {
buf += c;
}
}
}
if (open) {
tokens.push({ text: true, data: buf + stack });
} else if (buf !== '') {
tokens.push({ text: true, data: buf });
}
return tokens;
};
const emojify = (str: string, customEmojis = {}) => parseHTML(str)
.map(({ text, data }) => {
if (!text) return data;
if (data.length === 0 || data === ' ') return data;
return emojifyText(data, customEmojis);
})
.join('');
export default emojify;

View file

@ -0,0 +1,119 @@
import split from 'graphemesplit';
import React from 'react';
import * as BuildConfig from 'bigbuffet/build-config';
import { makeEmojiMap } from 'bigbuffet/utils/normalizers';
import unicodeMapping from './mapping';
import type { CustomEmoji } from 'pl-api';
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) => isAlphaNumeric(c)
|| c === '_'
|| c === '-'
|| c === '.';
interface IMaybeEmoji {
text: string;
emojis: Record<string, CustomEmoji>;
}
const MaybeEmoji: React.FC<IMaybeEmoji> = ({ text, emojis }) => {
if (text.length < 3) return text;
if (text in emojis) {
const emoji = emojis[text];
const filename = emoji.static_url;
if (filename?.length > 0) {
return <img draggable={false} className='emojione' alt={text} title={text} src={filename} />;
}
}
return text;
};
interface IEmojify {
text: string;
emojis?: Array<CustomEmoji> | Record<string, CustomEmoji>;
}
const Emojify: React.FC<IEmojify> = ({ text, emojis = {} }) => React.useMemo(() => {
if (Array.isArray(emojis)) emojis = makeEmojiMap(emojis);
const nodes = [];
let stack = '';
let open = false;
const clearStack = () => {
if (stack.length) nodes.push(stack);
open = false;
stack = '';
};
for (let c of split(text)) {
// 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) {
clearStack();
const { unified, shortcode } = unicodeMapping[c];
nodes.push(
<img draggable={false} className='emojione' alt={c} title={`:${shortcode}:`} src={`/${BuildConfig.ASSETS_DIR}/emoji/${unified}.svg`} />,
);
} else if (unqualified in unicodeMapping) {
clearStack();
const { unified, shortcode } = unicodeMapping[unqualified];
nodes.push(
<img draggable={false} className='emojione' alt={unqualified} title={`:${shortcode}:`} src={`/${BuildConfig.ASSETS_DIR}/emoji/${unified}.svg`} />,
);
} else if (c === ':') {
if (!open) {
clearStack();
}
stack += ':';
// we see another : we convert it and clear the stack buffer
if (open) {
nodes.push(<MaybeEmoji text={stack} emojis={emojis} />);
stack = '';
}
open = !open;
} else {
stack += c;
if (open && !validEmojiChar(c)) {
clearStack();
}
}
}
if (stack.length) nodes.push(stack);
return nodes;
}, [text, emojis]);
export { Emojify as default };

View file

@ -8,6 +8,7 @@ import StillImage from 'bigbuffet/components/still-image';
import IconButton from 'bigbuffet/components/ui/icon-button';
import Menu, { MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'bigbuffet/components/ui/menu';
import VerificationBadge from 'bigbuffet/components/verification-badge';
import Emojify from 'bigbuffet/features/emoji';
import { useFeatures } from 'bigbuffet/hooks/useFeatures';
import { useModalsStore } from 'bigbuffet/stores/modals';
import copy from 'bigbuffet/utils/copy';
@ -167,7 +168,9 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
name: (
<Link className='mention' to={`/@${account.acct}`}>
<div className='display-name__inner'>
<span dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
<span>
<Emojify text={account.display_name} emojis={account.emojis} />
</span>
{account.verified && <VerificationBadge />}
</div>
</Link>
@ -184,8 +187,8 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
id='event.participants'
defaultMessage='{count} {rawCount, plural, one {person} other {people}} going'
values={{
rawCount: event.participants_count || 0,
count: shortNumberFormat(event.participants_count || 0),
rawCount: event.participants_count,
count: shortNumberFormat(event.participants_count),
}}
/>
</span>

View file

@ -122,7 +122,7 @@ const EventInformation: React.FC<IEventInformation> = ({ params: { statusId: sta
return (
<div className='event-information'>
{!!status.contentHtml.trim() && (
{!!status.content.trim().length && (
<div className='event-information__description'>
<p className='event-information__heading'>
<FormattedMessage id='event.description' defaultMessage='Description' />

View file

@ -4,8 +4,10 @@ import { FormattedDate, FormattedMessage } from 'react-intl';
import { fetchHistory } from 'bigbuffet/actions/history';
import AttachmentThumbs from 'bigbuffet/components/attachment-thumbs';
import { ParsedContent } from 'bigbuffet/components/parsed-content';
import Modal from 'bigbuffet/components/ui/modal';
import Spinner from 'bigbuffet/components/ui/spinner';
import Emojify from 'bigbuffet/features/emoji';
import { useAppDispatch } from 'bigbuffet/hooks/useAppDispatch';
import { useAppSelector } from 'bigbuffet/hooks/useAppSelector';
@ -29,7 +31,7 @@ const CompareHistoryModal: React.FC<BaseModalProps & CompareHistoryModalProps> =
dispatch(fetchHistory(statusId));
}, [statusId]);
let body;
let body: JSX.Element;
if (loading) {
body = <Spinner />;
@ -37,26 +39,27 @@ const CompareHistoryModal: React.FC<BaseModalProps & CompareHistoryModalProps> =
body = (
<div className='compare-history'>
{versions?.map((version) => {
const content = { __html: version.contentHtml };
const spoilerContent = { __html: version.spoilerHtml };
const poll = typeof version.poll !== 'string' && version.poll;
return (
<div className='compare-history__version'>
{version.spoiler_text?.length > 0 && (
<>
<span dangerouslySetInnerHTML={spoilerContent} />
<span>
<Emojify text={version.spoiler_text} emojis={version.emojis} />
</span>
<hr />
</>
)}
<div className='status__content' dangerouslySetInnerHTML={content} />
<p className='status__content'>
<ParsedContent html={version.content} emojis={version.emojis} />
</p>
{poll && (
<div className='poll'>
<div>
{poll.options.map((option: any) => (
{poll.options.map((option) => (
<div className='poll-option'>
<span
className={clsx('poll-option__check')}
@ -64,7 +67,9 @@ const CompareHistoryModal: React.FC<BaseModalProps & CompareHistoryModalProps> =
role='radio'
/>
<span dangerouslySetInnerHTML={{ __html: option.title_emojified }} />
<span>
<ParsedContent html={option.title} emojis={version.emojis} />
</span>
</div>
))}
</div>

View file

@ -3,6 +3,9 @@ import React from 'react';
import { defineMessages, useIntl, FormatDateOptions } from 'react-intl';
import Icon from 'bigbuffet/components/icon';
import { ParsedContent } from 'bigbuffet/components/parsed-content';
import Emojify from 'bigbuffet/features/emoji';
import { unescapeHTML } from 'bigbuffet/utils/html';
import type { Account } from 'bigbuffet/normalizers/account';
@ -24,10 +27,11 @@ const dateFormatOptions: FormatDateOptions = {
interface IProfileField {
field: Account['fields'][number];
emojis?: Account['emojis'];
}
/** Renders a single profile field. */
const ProfileField: React.FC<IProfileField> = ({ field }) => {
const ProfileField: React.FC<IProfileField> = ({ field, emojis }) => {
const intl = useIntl();
// if (isTicker(field.name)) {
@ -42,10 +46,12 @@ const ProfileField: React.FC<IProfileField> = ({ field }) => {
return (
<dl className={clsx('profile-info__field', { 'profile-info__field--verified': field.verified_at })}>
<dt title={field.name}>
<span dangerouslySetInnerHTML={{ __html: field.name_emojified }} data-markup />
<span data-markup>
<Emojify text={field.name} emojis={emojis} />
</span>
</dt>
<dd title={field.value_plain}>
<dd title={unescapeHTML(field.value)}>
<div>
{field.verified_at && (
<span className='profile-info__field__verified' title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(field.verified_at, dateFormatOptions) })}>
@ -53,7 +59,9 @@ const ProfileField: React.FC<IProfileField> = ({ field }) => {
</span>
)}
<span dangerouslySetInnerHTML={{ __html: field.value_emojified }} data-markup />
<span data-markup>
<ParsedContent html={field.value} emojis={emojis} />
</span>
</div>
</dd>
</dl>

View file

@ -3,7 +3,9 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import Badge from 'bigbuffet/components/badge';
import Icon from 'bigbuffet/components/icon';
import { ParsedContent } from 'bigbuffet/components/parsed-content';
import bigBuffetConfig from 'bigbuffet/config';
import Emojify from 'bigbuffet/features/emoji';
import { badgeToTag, getBadges as getAccountBadges } from 'bigbuffet/utils/badges';
import { capitalize } from 'bigbuffet/utils/strings';
@ -100,8 +102,6 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
);
}
const deactivated = account.deactivated ?? false;
const displayNameHtml = deactivated ? { __html: intl.formatMessage(messages.deactivated) } : { __html: account.display_name_html };
const badges = getBadges();
return (
@ -109,7 +109,11 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
<div className='profile-info'>
<div className='profile-info__name'>
<div className='profile-info__display-name'>
<p dangerouslySetInnerHTML={displayNameHtml} />
<p>
{account.deactivated
? <FormattedMessage id='account.deactivated' defaultMessage='Deactivated' />
: <Emojify text={account.display_name} emojis={account.emojis} />}
</p>
{account.bot && <Badge slug='bot' title={intl.formatMessage(messages.bot)} />}
@ -135,7 +139,9 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
</div>
{account.note.length > 0 && (
<p className='profile-info__note' dangerouslySetInnerHTML={{ __html: account.note_emojified }} data-markup />
<p className='profile-info__note' data-markup>
<ParsedContent html={account.note} emojis={account.emojis} />
</p>
)}
<div className='profile-info__details'>
@ -156,7 +162,7 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
{account.fields.length > 0 && (
<div className='profile-info__fields'>
{account.fields.map((field, i) => (
<ProfileField field={field} key={i} />
<ProfileField field={field} key={i} emojis={account.emojis} />
))}
</div>
)}

View file

@ -6,6 +6,7 @@ import StillImage from 'bigbuffet/components/still-image';
import Avatar from 'bigbuffet/components/ui/avatar';
import VerificationBadge from 'bigbuffet/components/verification-badge';
import bigBuffetConfig from 'bigbuffet/config';
import Emojify from 'bigbuffet/features/emoji';
import { getAcct } from 'bigbuffet/utils/accounts';
interface IUserPanel {
@ -20,7 +21,6 @@ const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain })
const { displayFqn } = bigBuffetConfig;
if (!account) return null;
const displayNameHtml = { __html: account.display_name_html };
const acct = !account.acct.includes('@') && domain ? `${account.acct}@${domain}` : account.acct;
const header = account.header;
const verified = account.verified;
@ -53,7 +53,9 @@ const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain })
<div className='user-panel__name'>
<Link to={`/@${account.acct}`}>
<div className='user-panel__display-name'>
<p dangerouslySetInnerHTML={displayNameHtml} />
<p>
<Emojify text={account.display_name} emojis={account.emojis} />
</p>
{verified && <VerificationBadge />}

View file

@ -5,8 +5,6 @@ import bigBuffetConfig from 'bigbuffet/config';
import defaultStyle from 'bigbuffet/styles/application.scss?url';
import { generateThemeCss } from 'bigbuffet/utils/theme';
console.log(defaultStyle);
const { styles } = bigBuffetConfig;
const GeneratedTheme = () => {

View file

@ -1,59 +1,18 @@
import escapeTextContentForBrowser from 'escape-html';
import emojify from 'bigbuffet/features/emoji';
import { unescapeHTML } from 'bigbuffet/utils/html';
import { makeEmojiMap } from 'bigbuffet/utils/normalizers';
import type { Account as BaseAccount } from 'pl-api';
const getDomainFromURL = (account: Pick<BaseAccount, 'url'>): string => {
try {
const url = account.url;
return new URL(url).host;
} catch {
return '';
}
};
const guessFqn = (account: Pick<BaseAccount, 'acct' | 'url'>): string => {
const acct = account.acct;
const [user, domain] = acct.split('@');
if (domain) {
return acct;
} else {
return [user, getDomainFromURL(account)].join('@');
}
};
const normalizeAccount = (account: BaseAccount) => {
const missingAvatar: string = require('bigbuffet/assets/images/avatar-missing.png');
const missingHeader: string = require('bigbuffet/assets/images/header-missing.png');
const fqn = account.fqn || guessFqn(account);
const domain = fqn.split('@')[1] || '';
const note = account.note === '<p></p>' ? '' : account.note;
const emojiMap = makeEmojiMap(account.emojis);
return {
...account,
avatar: account.avatar || account.avatar_static || missingAvatar,
avatar_static: account.avatar_static || account.avatar || missingAvatar,
header: account.header || account.header_static || missingHeader,
header_static: account.header_static || account.header || missingHeader,
fqn,
domain,
note,
display_name_html: emojify(escapeTextContentForBrowser(account.display_name), emojiMap),
note_emojified: emojify(account.note, emojiMap),
note_plain: unescapeHTML(account.note),
fields: account.fields.map(field => ({
...field,
name_emojified: emojify(escapeTextContentForBrowser(field.name), emojiMap),
value_emojified: emojify(field.value, emojiMap),
value_plain: unescapeHTML(field.value),
})),
};
};

View file

@ -1,45 +0,0 @@
import escapeTextContentForBrowser from 'escape-html';
import DOMPurify from 'isomorphic-dompurify';
import { Status as BaseStatus, StatusEdit as BaseStatusEdit, CustomEmoji } from 'pl-api';
import emojify from 'bigbuffet/features/emoji';
import { makeEmojiMap } from 'bigbuffet/utils/normalizers';
const sanitizeTitle = (text: string, emojiMap: Record<string, CustomEmoji>) =>
DOMPurify.sanitize(emojify(escapeTextContentForBrowser(text), emojiMap), { ALLOWED_TAGS: [] });
const normalizePoll = (poll: Exclude<BaseStatus['poll'], null>) => {
const emojiMap = makeEmojiMap(poll.emojis);
return {
...poll,
options: poll.options.map(option => ({
...option,
title_emojified: sanitizeTitle(option.title, emojiMap),
title_map_emojified: option.title_map
? Object.fromEntries(Object.entries(option.title_map).map(([key, title]) => [key, sanitizeTitle(title, emojiMap)]))
: null,
})),
};
};
const normalizePollEdit = (poll: Exclude<BaseStatusEdit['poll'], null>, emojis: Array<CustomEmoji>) => {
const emojiMap = makeEmojiMap(emojis);
return {
...poll,
options: poll.options.map(option => ({
...option,
title_emojified: sanitizeTitle(option.title, emojiMap),
})),
};
};
type Poll = ReturnType<typeof normalizePoll>;
type PollEdit = ReturnType<typeof normalizePollEdit>;
export {
normalizePoll,
normalizePollEdit,
type Poll,
type PollEdit,
};

View file

@ -1,29 +0,0 @@
/**
* Status edit normalizer
*/
import escapeTextContentForBrowser from 'escape-html';
import DOMPurify from 'isomorphic-dompurify';
import emojify from 'bigbuffet/features/emoji';
import { makeEmojiMap } from 'bigbuffet/utils/normalizers';
import { normalizePollEdit } from './poll';
import type { StatusEdit as BaseStatusEdit } from 'pl-api';
const normalizeStatusEdit = (statusEdit: BaseStatusEdit) => {
const emojiMap = makeEmojiMap(statusEdit.emojis);
const poll = statusEdit.poll ? normalizePollEdit(statusEdit.poll, statusEdit.emojis) : null;
return {
...statusEdit,
poll,
contentHtml: DOMPurify.sanitize(emojify(statusEdit.content, emojiMap), { ADD_ATTR: ['target'] }),
spoilerHtml: DOMPurify.sanitize(emojify(escapeTextContentForBrowser(statusEdit.spoiler_text), emojiMap), { ADD_ATTR: ['target'] }),
};
};
type StatusEdit = ReturnType<typeof normalizeStatusEdit>
export { type StatusEdit, normalizeStatusEdit };

View file

@ -3,17 +3,12 @@
* Converts API statuses into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/status/}
*/
import escapeTextContentForBrowser from 'escape-html';
import DOMPurify from 'isomorphic-dompurify';
import { type Account as BaseAccount, type Status as BaseStatus, type MediaAttachment, mentionSchema, type Translation } from 'pl-api';
import * as v from 'valibot';
import emojify from 'bigbuffet/features/emoji';
import { unescapeHTML } from 'bigbuffet/utils/html';
import { makeEmojiMap } from 'bigbuffet/utils/normalizers';
import { normalizeAccount } from './account';
import { normalizePoll } from './poll';
const domParser = new DOMParser();
@ -22,10 +17,6 @@ type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'group' |
type CalculatedValues = {
search_index: string;
contentHtml: string;
spoilerHtml: string;
contentMapHtml?: Record<string, string>;
spoilerMapHtml?: Record<string, string>;
hidden?: boolean;
translation?: Translation | null | false;
currentLanguage?: string;
@ -61,32 +52,20 @@ const buildSearchContent = (status: Pick<BaseStatus, 'poll' | 'mentions' | 'spoi
return unescapeHTML(fields.join('\n\n')) || '';
};
const calculateContent = (text: string, emojiMap: any) => DOMPurify.sanitize(emojify(text, emojiMap), { USE_PROFILES: { html: true } });
const calculateSpoiler = (text: string, emojiMap: any) => DOMPurify.sanitize(emojify(escapeTextContentForBrowser(text), emojiMap), { USE_PROFILES: { html: true } });
const calculateStatus = (status: BaseStatus, oldStatus?: OldStatus): CalculatedValues => {
if (oldStatus && oldStatus.content === status.content && oldStatus.spoiler_text === status.spoiler_text) {
const {
search_index, contentHtml, spoilerHtml, contentMapHtml, spoilerMapHtml, hidden, translation, currentLanguage,
search_index, hidden, translation, currentLanguage,
} = oldStatus;
return {
search_index, contentHtml, spoilerHtml, contentMapHtml, spoilerMapHtml, hidden, translation, currentLanguage,
search_index, hidden, translation, currentLanguage,
};
} else {
const searchContent = buildSearchContent(status);
const emojiMap = makeEmojiMap(status.emojis);
return {
search_index: domParser.parseFromString(searchContent, 'text/html').documentElement.textContent || '',
contentHtml: calculateContent(status.content, emojiMap),
spoilerHtml: calculateSpoiler(status.spoiler_text, emojiMap),
contentMapHtml: status.content_map
? Object.fromEntries(Object.entries(status.content_map)?.map(([key, value]) => [key, calculateContent(value, emojiMap)]))
: undefined,
spoilerMapHtml: status.spoiler_text_map
? Object.fromEntries(Object.entries(status.spoiler_text_map).map(([key, value]) => [key, calculateSpoiler(value, emojiMap)]))
: undefined,
};
}
};
@ -124,8 +103,6 @@ const normalizeStatus = (status: BaseStatus & {
} | null) = null;
let media_attachments = status.media_attachments;
// Normalize poll
const poll = status.poll ? normalizePoll(status.poll) : null;
if (status.event) {
const firstAttachment = status.media_attachments[0];
@ -164,7 +141,6 @@ const normalizeStatus = (status: BaseStatus & {
content: status.content === '<p></p>' ? '' : status.content,
filtered: status.filtered?.map(result => result.filter.title),
event,
poll,
media_attachments,
...calculated,
translation: (status.translation || calculated.translation || null) as Translation | null | false,

View file

@ -1,18 +0,0 @@
import emojify from 'bigbuffet/features/emoji';
import { makeEmojiMap } from 'bigbuffet/utils/normalizers';
import type { Status, Translation as BaseTranslation } from 'pl-api';
const normalizeTranslation = (translation: BaseTranslation, status: Pick<Status, 'emojis'>) => {
const emojiMap = makeEmojiMap(status.emojis);
const content = emojify(translation.content, emojiMap);
return {
...translation,
content,
};
};
type Translation = ReturnType<typeof normalizeTranslation>;
export { normalizeTranslation, type Translation };

View file

@ -1,7 +1,8 @@
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord } from 'immutable';
import { HISTORY_FETCH_REQUEST, HISTORY_FETCH_SUCCESS, HISTORY_FETCH_FAIL, type HistoryAction } from 'bigbuffet/actions/history';
import { normalizeStatusEdit, type StatusEdit } from 'bigbuffet/normalizers/status-edit';
import type { StatusEdit } from 'pl-api';
const HistoryRecord = ImmutableRecord({
loading: false,
@ -22,7 +23,7 @@ const history = (state: State = initialState, action: HistoryAction) => {
case HISTORY_FETCH_SUCCESS:
return state.update(action.statusId, HistoryRecord(), history => history!.withMutations(map => {
map.set('loading', false);
map.set('items', ImmutableList(action.history.map((x, i: number) => ({ ...x, original: i === 0 })).reverse().map(normalizeStatusEdit)));
map.set('items', ImmutableList(action.history.map((x, i: number) => ({ ...x, original: i === 0 })).toReversed()));
}));
case HISTORY_FETCH_FAIL:
return state.update(action.statusId, HistoryRecord(), history => history!.set('loading', false));

View file

@ -1,7 +1,6 @@
import { Map as ImmutableMap } from 'immutable';
import { normalizeStatus } from 'bigbuffet/normalizers/status';
import { normalizeTranslation } from 'bigbuffet/normalizers/translation';
import {
STATUS_REVEAL,
@ -21,15 +20,12 @@ type State = ImmutableMap<string, MinifiedStatus>;
type MinifiedStatus = Omit<StatusRecord, 'reblog' | 'poll' | 'quote' | 'group'>;
/** Import translation from translation service into the store. */
const importTranslation = (state: State, statusId: string, translation: Translation) => {
const result = normalizeTranslation(translation, state.get(statusId)!);
return state.update(statusId, undefined as any, (status) => ({
const importTranslation = (state: State, statusId: string, translation: Translation) =>
state.update(statusId, undefined as any, (status) => ({
...status,
translation: result,
translation,
translating: false,
}));
};
/** Delete translation from the store. */
const deleteTranslation = (state: State, statusId: string) => state.deleteIn([statusId, 'translation']);

View file

@ -2834,6 +2834,13 @@ domelementtype@^2.3.0:
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
domhandler@5.0.3, domhandler@^5.0.2, domhandler@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
dependencies:
domelementtype "^2.3.0"
domhandler@^4.2.0:
version "4.2.2"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.2.2.tgz#e825d721d19a86b8c201a35264e226c678ee755f"
@ -2848,13 +2855,6 @@ domhandler@^4.3.1:
dependencies:
domelementtype "^2.2.0"
domhandler@^5.0.2, domhandler@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
dependencies:
domelementtype "^2.3.0"
dompurify@^3.1.7:
version "3.1.7"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.7.tgz#711a8c96479fb6ced93453732c160c3c72418a6a"
@ -2869,7 +2869,7 @@ domutils@^2.8.0:
domelementtype "^2.2.0"
domhandler "^4.2.0"
domutils@^3.0.1:
domutils@^3.0.1, domutils@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e"
integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==
@ -4174,6 +4174,14 @@ hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-
dependencies:
react-is "^16.7.0"
html-dom-parser@5.0.10:
version "5.0.10"
resolved "https://registry.yarnpkg.com/html-dom-parser/-/html-dom-parser-5.0.10.tgz#bf46b05c50f35c2fcadfc8e91566c54d3caf9bd7"
integrity sha512-GwArYL3V3V8yU/mLKoFF7HlLBv80BZ2Ey1BzfVNRpAci0cEKhFHI/Qh8o8oyt3qlAMLlK250wsxLdYX4viedvg==
dependencies:
domhandler "5.0.3"
htmlparser2 "9.1.0"
html-encoding-sniffer@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz#696df529a7cfd82446369dc5193e590a3735b448"
@ -4194,11 +4202,31 @@ html-minifier-terser@^6.1.0:
relateurl "^0.2.7"
terser "^5.10.0"
html-react-parser@^5.1.18:
version "5.1.18"
resolved "https://registry.yarnpkg.com/html-react-parser/-/html-react-parser-5.1.18.tgz#a07ff6d95fcaa6de45244386a12dddb981434915"
integrity sha512-65BwC0zzrdeW96jB2FRr5f1ovBhRMpLPJNvwkY5kA8Ay5xdL9t/RH2/uUTM7p+cl5iM88i6dDk4LXtfMnRmaJQ==
dependencies:
domhandler "5.0.3"
html-dom-parser "5.0.10"
react-property "2.0.2"
style-to-js "1.1.16"
html-tags@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce"
integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==
htmlparser2@9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-9.1.0.tgz#cdb498d8a75a51f739b61d3f718136c369bc8c23"
integrity sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils "^3.1.0"
entities "^4.5.0"
http-link-header@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/http-link-header/-/http-link-header-1.1.3.tgz#b367b7a0ad1cf14027953f31aa1df40bb433da2a"
@ -4313,6 +4341,11 @@ ini@^1.3.5:
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
inline-style-parser@0.2.4:
version "0.2.4"
resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.4.tgz#f4af5fe72e612839fcd453d989a586566d695f22"
integrity sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==
internal-slot@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c"
@ -6227,6 +6260,11 @@ react-motion@^0.5.2:
prop-types "^15.5.8"
raf "^3.1.0"
react-property@2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/react-property/-/react-property-2.0.2.tgz#d5ac9e244cef564880a610bc8d868bd6f60fdda6"
integrity sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==
react-redux@^9.0.4:
version "9.1.2"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.1.2.tgz#deba38c64c3403e9abd0c3fbeab69ffd9d8a7e4b"
@ -7055,6 +7093,20 @@ style-loader@^4.0.0:
resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-4.0.0.tgz#0ea96e468f43c69600011e0589cb05c44f3b17a5"
integrity sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==
style-to-js@1.1.16:
version "1.1.16"
resolved "https://registry.yarnpkg.com/style-to-js/-/style-to-js-1.1.16.tgz#e6bd6cd29e250bcf8fa5e6591d07ced7575dbe7a"
integrity sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==
dependencies:
style-to-object "1.0.8"
style-to-object@1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-1.0.8.tgz#67a29bca47eaa587db18118d68f9d95955e81292"
integrity sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==
dependencies:
inline-style-parser "0.2.4"
stylehacks@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-6.0.0.tgz#9fdd7c217660dae0f62e14d51c89f6c01b3cb738"