Merge commit 'dcc26c1b2484848d5b7c3c1a56cfe38b37e83d23' into glitch-soc/merge-upstream
This commit is contained in:
commit
8785d3f6fe
@ -13,14 +13,17 @@ import axios from 'axios';
|
|||||||
import { on } from 'delegated-events';
|
import { on } from 'delegated-events';
|
||||||
import { throttle } from 'lodash';
|
import { throttle } from 'lodash';
|
||||||
|
|
||||||
|
import { determineEmojiMode } from '@/mastodon/features/emoji/mode';
|
||||||
|
import { updateHtmlWithEmoji } from '@/mastodon/features/emoji/render';
|
||||||
|
import loadKeyboardExtensions from '@/mastodon/load_keyboard_extensions';
|
||||||
|
import { loadLocale, getLocale } from '@/mastodon/locales';
|
||||||
|
import { loadPolyfills } from '@/mastodon/polyfills';
|
||||||
|
import ready from '@/mastodon/ready';
|
||||||
|
import { assetHost } from '@/mastodon/utils/config';
|
||||||
|
import { getNestedProperty } from '@/mastodon/utils/objects';
|
||||||
|
import { isDarkMode } from '@/mastodon/utils/theme';
|
||||||
import { formatTime } from '@/mastodon/utils/time';
|
import { formatTime } from '@/mastodon/utils/time';
|
||||||
|
|
||||||
import emojify from '../mastodon/features/emoji/emoji';
|
|
||||||
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
|
|
||||||
import { loadLocale, getLocale } from '../mastodon/locales';
|
|
||||||
import { loadPolyfills } from '../mastodon/polyfills';
|
|
||||||
import ready from '../mastodon/ready';
|
|
||||||
|
|
||||||
import 'cocoon-js-vanilla';
|
import 'cocoon-js-vanilla';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@ -38,7 +41,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function loaded() {
|
async function loaded() {
|
||||||
const { messages: localeData } = getLocale();
|
const { messages: localeData } = getLocale();
|
||||||
|
|
||||||
const locale = document.documentElement.lang;
|
const locale = document.documentElement.lang;
|
||||||
@ -75,9 +78,30 @@ function loaded() {
|
|||||||
return messageFormat.format(values) as string;
|
return messageFormat.format(values) as string;
|
||||||
};
|
};
|
||||||
|
|
||||||
document.querySelectorAll('.emojify').forEach((content) => {
|
let emojiStyle = 'auto';
|
||||||
content.innerHTML = emojify(content.innerHTML);
|
const initialStateText =
|
||||||
});
|
document.getElementById('initial-state')?.textContent;
|
||||||
|
if (initialStateText) {
|
||||||
|
const stateEmojiStyle = getNestedProperty(
|
||||||
|
JSON.parse(initialStateText) as unknown,
|
||||||
|
'meta',
|
||||||
|
'emoji_style',
|
||||||
|
);
|
||||||
|
if (typeof stateEmojiStyle === 'string') {
|
||||||
|
emojiStyle = stateEmojiStyle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const emojiMode = determineEmojiMode(emojiStyle);
|
||||||
|
const darkTheme = isDarkMode();
|
||||||
|
for (const element of document.querySelectorAll('.emojify')) {
|
||||||
|
await updateHtmlWithEmoji({
|
||||||
|
assetHost,
|
||||||
|
element,
|
||||||
|
locale,
|
||||||
|
mode: emojiMode,
|
||||||
|
darkTheme,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document
|
document
|
||||||
.querySelectorAll<HTMLTimeElement>('time.formatted')
|
.querySelectorAll<HTMLTimeElement>('time.formatted')
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { throttle } from 'lodash';
|
|||||||
import api from 'mastodon/api';
|
import api from 'mastodon/api';
|
||||||
import { browserHistory } from 'mastodon/components/router';
|
import { browserHistory } from 'mastodon/components/router';
|
||||||
import { countableText } from 'mastodon/features/compose/util/counter';
|
import { countableText } from 'mastodon/features/compose/util/counter';
|
||||||
import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light';
|
|
||||||
import { tagHistory } from 'mastodon/settings';
|
import { tagHistory } from 'mastodon/settings';
|
||||||
import { fetchCustomEmojiData } from '@/mastodon/features/emoji/picker';
|
import { fetchCustomEmojiData } from '@/mastodon/features/emoji/picker';
|
||||||
|
|
||||||
@ -595,7 +594,8 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, token) => {
|
|||||||
|
|
||||||
const fetchComposeSuggestionsEmojis = async (dispatch, token) => {
|
const fetchComposeSuggestionsEmojis = async (dispatch, token) => {
|
||||||
const custom = await fetchCustomEmojiData();
|
const custom = await fetchCustomEmojiData();
|
||||||
const results = emojiSearch(token.replace(':', ''), { maxResults: 5, custom });
|
const { search } = await import('@/mastodon/features/emoji/emoji_mart_search_light');
|
||||||
|
const results = search(token.replace(':', ''), { maxResults: 5, custom });
|
||||||
dispatch(readyComposeSuggestionsEmojis(token, results));
|
dispatch(readyComposeSuggestionsEmojis(token, results));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
||||||
|
|
||||||
exports[`<AutosuggestEmoji /> > renders emoji with custom url 1`] = `
|
|
||||||
<div
|
|
||||||
className="autosuggest-emoji"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt="foobar"
|
|
||||||
className="emojione"
|
|
||||||
src="http://example.com/emoji.png"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="autosuggest-emoji__name"
|
|
||||||
>
|
|
||||||
:foobar:
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`<AutosuggestEmoji /> > renders native emoji 1`] = `
|
|
||||||
<div
|
|
||||||
className="autosuggest-emoji"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt="💙"
|
|
||||||
className="emojione"
|
|
||||||
src="/emoji/1f499.svg"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="autosuggest-emoji__name"
|
|
||||||
>
|
|
||||||
:foobar:
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
import renderer from 'react-test-renderer';
|
|
||||||
|
|
||||||
import AutosuggestEmoji from '../autosuggest_emoji';
|
|
||||||
|
|
||||||
describe('<AutosuggestEmoji />', () => {
|
|
||||||
it('renders native emoji', () => {
|
|
||||||
const emoji = {
|
|
||||||
native: '💙',
|
|
||||||
colons: ':foobar:',
|
|
||||||
};
|
|
||||||
const component = renderer.create(<AutosuggestEmoji emoji={emoji} />);
|
|
||||||
const tree = component.toJSON();
|
|
||||||
|
|
||||||
expect(tree).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders emoji with custom url', () => {
|
|
||||||
const emoji = {
|
|
||||||
custom: true,
|
|
||||||
imageUrl: 'http://example.com/emoji.png',
|
|
||||||
native: 'foobar',
|
|
||||||
colons: ':foobar:',
|
|
||||||
};
|
|
||||||
const component = renderer.create(<AutosuggestEmoji emoji={emoji} />);
|
|
||||||
const tree = component.toJSON();
|
|
||||||
|
|
||||||
expect(tree).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -11,6 +11,8 @@ import { FormattedDateWrapper } from '../formatted_date';
|
|||||||
import { NumberFields, NumberFieldsItem } from '../number_fields';
|
import { NumberFields, NumberFieldsItem } from '../number_fields';
|
||||||
import { ShortNumber } from '../short_number';
|
import { ShortNumber } from '../short_number';
|
||||||
|
|
||||||
|
import classes from './styles.module.scss';
|
||||||
|
|
||||||
export const AccountNumberFields: FC<{ accountId: string }> = ({
|
export const AccountNumberFields: FC<{ accountId: string }> = ({
|
||||||
accountId,
|
accountId,
|
||||||
}) => {
|
}) => {
|
||||||
@ -33,7 +35,7 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NumberFields>
|
<NumberFields className={classes.numberFields}>
|
||||||
<NumberFieldsItem
|
<NumberFieldsItem
|
||||||
label={
|
label={
|
||||||
<FormattedMessage id='account.followers' defaultMessage='Followers' />
|
<FormattedMessage id='account.followers' defaultMessage='Followers' />
|
||||||
|
|||||||
@ -78,6 +78,8 @@
|
|||||||
|
|
||||||
.nameWrapper {
|
.nameWrapper {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
@ -263,9 +265,23 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.numberFields {
|
||||||
|
@container (width >= #{$button-breakpoint}) {
|
||||||
|
--number-fields-gap: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.bio {
|
.bio {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
|
:any-link {
|
||||||
|
color: var(--color-text-status-links);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.familiarFollowers {
|
.familiarFollowers {
|
||||||
|
|||||||
@ -90,7 +90,7 @@ export const AccountListItem: React.FC<Props> = ({
|
|||||||
<ListItemLink
|
<ListItemLink
|
||||||
to={`/@${account.acct}`}
|
to={`/@${account.acct}`}
|
||||||
data-hover-card-account={accountId}
|
data-hover-card-account={accountId}
|
||||||
subtitle={handle}
|
subtitle={<span className={classes.handle}>{handle}</span>}
|
||||||
>
|
>
|
||||||
<DisplayNameSimple
|
<DisplayNameSimple
|
||||||
account={account}
|
account={account}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
.wrapper {
|
.wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: start;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|
||||||
@ -24,6 +23,13 @@
|
|||||||
vertical-align: -4px;
|
vertical-align: -4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.handle {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
align-self: start;
|
align-self: start;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { assetHost } from 'mastodon/utils/config';
|
|
||||||
|
|
||||||
import { unicodeMapping } from '../features/emoji/emoji_unicode_mapping_light';
|
|
||||||
|
|
||||||
export default class AutosuggestEmoji extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
emoji: PropTypes.object.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { emoji } = this.props;
|
|
||||||
let url;
|
|
||||||
|
|
||||||
if (emoji.custom) {
|
|
||||||
url = emoji.imageUrl;
|
|
||||||
} else {
|
|
||||||
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
|
|
||||||
|
|
||||||
if (!mapping) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
url = `${assetHost}/emoji/${mapping.filename}.svg`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='autosuggest-emoji'>
|
|
||||||
<img
|
|
||||||
className='emojione'
|
|
||||||
src={url}
|
|
||||||
alt={emoji.native || emoji.colons}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='autosuggest-emoji__name'>{emoji.colons}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
22
app/javascript/mastodon/components/autosuggest_emoji.tsx
Normal file
22
app/javascript/mastodon/components/autosuggest_emoji.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import { useCustomEmojis } from '@/mastodon/hooks/useCustomEmojis';
|
||||||
|
|
||||||
|
import { Emoji } from './emoji';
|
||||||
|
|
||||||
|
interface LegacyEmoji {
|
||||||
|
colons: string;
|
||||||
|
custom?: boolean;
|
||||||
|
native?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AutosuggestEmoji: FC<{ emoji: LegacyEmoji }> = ({ emoji }) => {
|
||||||
|
const emojis = useCustomEmojis();
|
||||||
|
return (
|
||||||
|
<div className='autosuggest-emoji'>
|
||||||
|
<Emoji code={emoji.native ?? emoji.colons} customEmoji={emojis} />
|
||||||
|
<div className='autosuggest-emoji__name'>{emoji.colons}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -9,7 +9,7 @@ import Overlay from 'react-overlays/Overlay';
|
|||||||
|
|
||||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||||
|
|
||||||
import AutosuggestEmoji from './autosuggest_emoji';
|
import { AutosuggestEmoji } from './autosuggest_emoji';
|
||||||
import { AutosuggestHashtag } from './autosuggest_hashtag';
|
import { AutosuggestHashtag } from './autosuggest_hashtag';
|
||||||
|
|
||||||
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import Textarea from 'react-textarea-autosize';
|
|||||||
|
|
||||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||||
|
|
||||||
import AutosuggestEmoji from './autosuggest_emoji';
|
import { AutosuggestEmoji } from './autosuggest_emoji';
|
||||||
import { AutosuggestHashtag } from './autosuggest_hashtag';
|
import { AutosuggestHashtag } from './autosuggest_hashtag';
|
||||||
|
|
||||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
stringToEmojiState,
|
stringToEmojiState,
|
||||||
tokenizeText,
|
tokenizeText,
|
||||||
} from '@/mastodon/features/emoji/render';
|
} from '@/mastodon/features/emoji/render';
|
||||||
|
import type { ExtraCustomEmojiMap } from '@/mastodon/features/emoji/types';
|
||||||
|
|
||||||
import { AnimateEmojiContext, CustomEmojiContext } from './context';
|
import { AnimateEmojiContext, CustomEmojiContext } from './context';
|
||||||
|
|
||||||
@ -26,14 +27,19 @@ interface EmojiProps {
|
|||||||
code: string;
|
code: string;
|
||||||
showFallback?: boolean;
|
showFallback?: boolean;
|
||||||
showLoading?: boolean;
|
showLoading?: boolean;
|
||||||
|
customEmoji?: ExtraCustomEmojiMap | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Emoji: FC<EmojiProps> = ({
|
export const Emoji: FC<EmojiProps> = ({
|
||||||
code,
|
code,
|
||||||
showFallback = true,
|
showFallback = true,
|
||||||
showLoading = true,
|
showLoading = true,
|
||||||
|
customEmoji: customEmojiOverride,
|
||||||
}) => {
|
}) => {
|
||||||
const customEmoji = useContext(CustomEmojiContext);
|
let customEmoji = useContext(CustomEmojiContext);
|
||||||
|
if (customEmojiOverride) {
|
||||||
|
customEmoji = customEmojiOverride;
|
||||||
|
}
|
||||||
|
|
||||||
// First, set the emoji state based on the input code.
|
// First, set the emoji state based on the input code.
|
||||||
const [state, setState] = useState(() =>
|
const [state, setState] = useState(() =>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export const ListItemWrapper: React.FC<WrapperProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div {...otherProps} className={classNames(classes.wrapper, className)}>
|
<div {...otherProps} className={classNames(classes.wrapper, className)}>
|
||||||
{icon}
|
{icon}
|
||||||
<div>{children}</div>
|
<div className={classes.main}>{children}</div>
|
||||||
{sideContent && (
|
{sideContent && (
|
||||||
<span className={classes.sideContent}>{sideContent}</span>
|
<span className={classes.sideContent}>{sideContent}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -14,6 +14,11 @@
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
.list {
|
.list {
|
||||||
|
--_item-gap: var(--number-fields-gap, 24px);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
gap: 4px 24px;
|
gap: 4px var(--_item-gap);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import StatusActionBar from './status_action_bar';
|
|||||||
import StatusContent from './status_content';
|
import StatusContent from './status_content';
|
||||||
import { StatusThreadLabel } from './status_thread_label';
|
import { StatusThreadLabel } from './status_thread_label';
|
||||||
import { CollectionPreviewCard } from '../features/collections/components/collection_preview_card';
|
import { CollectionPreviewCard } from '../features/collections/components/collection_preview_card';
|
||||||
|
import { compareUrls } from '../utils/compare_urls';
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
|
|
||||||
@ -555,7 +556,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
).find((item) => compareUrls(item.get('url'), cardUrl));
|
).find((item) => compareUrls(item.get('url'), cardUrl));
|
||||||
|
|
||||||
if (taggedCollection) {
|
if (taggedCollection) {
|
||||||
media = <CollectionPreviewCard collection={taggedCollection} />;
|
media = <CollectionPreviewCard collection={taggedCollection.toJS()} />;
|
||||||
} else {
|
} else {
|
||||||
media = (
|
media = (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@ -46,8 +46,16 @@ import { WizardStepTitle } from './wizard_step_title';
|
|||||||
export const CollectionDetails: React.FC = () => {
|
export const CollectionDetails: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { id, name, description, topic, discoverable, sensitive, items } =
|
const {
|
||||||
useAppSelector((state) => state.collections.editor);
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
topic,
|
||||||
|
language,
|
||||||
|
discoverable,
|
||||||
|
sensitive,
|
||||||
|
items,
|
||||||
|
} = useAppSelector((state) => state.collections.editor);
|
||||||
|
|
||||||
const handleNameChange = useCallback(
|
const handleNameChange = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@ -110,6 +118,7 @@ export const CollectionDetails: React.FC = () => {
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
tag_name: topic || null,
|
tag_name: topic || null,
|
||||||
|
language: language || null,
|
||||||
discoverable,
|
discoverable,
|
||||||
sensitive,
|
sensitive,
|
||||||
};
|
};
|
||||||
@ -125,6 +134,9 @@ export const CollectionDetails: React.FC = () => {
|
|||||||
sensitive,
|
sensitive,
|
||||||
account_ids: items.map((item) => item.account_id),
|
account_ids: items.map((item) => item.account_id),
|
||||||
};
|
};
|
||||||
|
if (language) {
|
||||||
|
payload.language = language;
|
||||||
|
}
|
||||||
if (topic) {
|
if (topic) {
|
||||||
payload.tag_name = topic;
|
payload.tag_name = topic;
|
||||||
}
|
}
|
||||||
@ -149,6 +161,7 @@ export const CollectionDetails: React.FC = () => {
|
|||||||
description,
|
description,
|
||||||
topic,
|
topic,
|
||||||
discoverable,
|
discoverable,
|
||||||
|
language,
|
||||||
sensitive,
|
sensitive,
|
||||||
dispatch,
|
dispatch,
|
||||||
history,
|
history,
|
||||||
@ -392,13 +405,8 @@ const renderTagItem = (item: TagSearchResult) => (
|
|||||||
|
|
||||||
const LanguageField: React.FC = () => {
|
const LanguageField: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const initialLanguage = useAppSelector(
|
|
||||||
(state) => state.compose.get('default_language') as string,
|
|
||||||
);
|
|
||||||
const { language } = useAppSelector((state) => state.collections.editor);
|
const { language } = useAppSelector((state) => state.collections.editor);
|
||||||
|
|
||||||
const selectedLanguage = language ?? initialLanguage;
|
|
||||||
|
|
||||||
const handleLanguageChange = useCallback(
|
const handleLanguageChange = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
@ -419,7 +427,7 @@ const LanguageField: React.FC = () => {
|
|||||||
defaultMessage='Language'
|
defaultMessage='Language'
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
value={selectedLanguage}
|
value={language}
|
||||||
onChange={handleLanguageChange}
|
onChange={handleLanguageChange}
|
||||||
>
|
>
|
||||||
<option value=''>
|
<option value=''>
|
||||||
|
|||||||
@ -1,97 +0,0 @@
|
|||||||
import emojify from '../emoji';
|
|
||||||
|
|
||||||
describe('emoji', () => {
|
|
||||||
describe('.emojify', () => {
|
|
||||||
it('ignores unknown shortcodes', () => {
|
|
||||||
expect(emojify(':foobarbazfake:')).toEqual(':foobarbazfake:');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores shortcodes inside of tags', () => {
|
|
||||||
expect(emojify('<p data-foo=":smile:"></p>')).toEqual('<p data-foo=":smile:"></p>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('works with unclosed tags', () => {
|
|
||||||
expect(emojify('hello>')).toEqual('hello>');
|
|
||||||
expect(emojify('<hello')).toEqual('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('works with unclosed shortcodes', () => {
|
|
||||||
expect(emojify('smile:')).toEqual('smile:');
|
|
||||||
expect(emojify(':smile')).toEqual(':smile');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does unicode', () => {
|
|
||||||
expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual(
|
|
||||||
'<img draggable="false" class="emojione" alt="👩👩👦👦" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg">');
|
|
||||||
expect(emojify('👨👩👧👧')).toEqual(
|
|
||||||
'<img draggable="false" class="emojione" alt="👨👩👧👧" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg">');
|
|
||||||
expect(emojify('👩👩👦')).toEqual('<img draggable="false" class="emojione" alt="👩👩👦" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg">');
|
|
||||||
expect(emojify('\u2757')).toEqual(
|
|
||||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg">');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does multiple unicode', () => {
|
|
||||||
expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual(
|
|
||||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg">');
|
|
||||||
expect(emojify('\u2757#\uFE0F\u20E3')).toEqual(
|
|
||||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg">');
|
|
||||||
expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual(
|
|
||||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"> <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg">');
|
|
||||||
expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual(
|
|
||||||
'foo <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"> bar');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores unicode inside of tags', () => {
|
|
||||||
expect(emojify('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>')).toEqual('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does multiple emoji properly (issue 5188)', () => {
|
|
||||||
expect(emojify('👌🌈💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"><img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg">');
|
|
||||||
expect(emojify('👌 🌈 💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"> <img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"> <img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg">');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does an emoji that has no shortcode', () => {
|
|
||||||
expect(emojify('👁🗨')).toEqual('<img draggable="false" class="emojione" alt="👁🗨" title="" src="/emoji/1f441-200d-1f5e8.svg">');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does an emoji whose filename is irregular', () => {
|
|
||||||
expect(emojify('↙️')).toEqual('<img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg">');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('avoid emojifying on invisible text', () => {
|
|
||||||
expect(emojify('<a href="http://example.com/test%F0%9F%98%84"><span class="invisible">http://</span><span class="ellipsis">example.com/te</span><span class="invisible">st😄</span></a>'))
|
|
||||||
.toEqual('<a href="http://example.com/test%F0%9F%98%84"><span class="invisible">http://</span><span class="ellipsis">example.com/te</span><span class="invisible">st😄</span></a>');
|
|
||||||
expect(emojify('<span class="invisible">:luigi:</span>', { ':luigi:': { static_url: 'luigi.exe' } }))
|
|
||||||
.toEqual('<span class="invisible">:luigi:</span>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('avoid emojifying on invisible text with nested tags', () => {
|
|
||||||
expect(emojify('<span class="invisible">😄<span class="foo">bar</span>😴</span>😇'))
|
|
||||||
.toEqual('<span class="invisible">😄<span class="foo">bar</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
|
|
||||||
expect(emojify('<span class="invisible">😄<span class="invisible">😕</span>😴</span>😇'))
|
|
||||||
.toEqual('<span class="invisible">😄<span class="invisible">😕</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
|
|
||||||
expect(emojify('<span class="invisible">😄<br>😴</span>😇'))
|
|
||||||
.toEqual('<span class="invisible">😄<br>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not emojify emojis with textual presentation VS15 character', () => {
|
|
||||||
expect(emojify('✴︎')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15
|
|
||||||
.toEqual('✴︎');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does a simple emoji properly', () => {
|
|
||||||
expect(emojify('♀♂'))
|
|
||||||
.toEqual('<img draggable="false" class="emojione" alt="♀" title=":female_sign:" src="/emoji/2640.svg"><img draggable="false" class="emojione" alt="♂" title=":male_sign:" src="/emoji/2642.svg">');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does an emoji containing ZWJ properly', () => {
|
|
||||||
expect(emojify('💂♀️💂♂️'))
|
|
||||||
.toEqual('<img draggable="false" class="emojione" alt="💂♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f.svg"><img draggable="false" class="emojione" alt="💂♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f.svg">');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps ordering as expected (issue fixed by PR 20677)', () => {
|
|
||||||
expect(emojify('<p>💕 <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener" target="_blank">#<span>foo</span></a> test: foo.</p>'))
|
|
||||||
.toEqual('<p><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg"> <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener" target="_blank">#<span>foo</span></a> test: foo.</p>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,170 +0,0 @@
|
|||||||
import Trie from 'substring-trie';
|
|
||||||
|
|
||||||
import { getIsSystemTheme, isDarkMode } from '@/mastodon/utils/theme';
|
|
||||||
import { assetHost } from 'mastodon/utils/config';
|
|
||||||
|
|
||||||
import { autoPlayGif } from '../../initial_state';
|
|
||||||
|
|
||||||
import { unicodeMapping } from './emoji_unicode_mapping_light';
|
|
||||||
|
|
||||||
const trie = new Trie(Object.keys(unicodeMapping));
|
|
||||||
|
|
||||||
// Convert to file names from emojis. (For different variation selector emojis)
|
|
||||||
const emojiFilenames = (emojis) => {
|
|
||||||
return emojis.map(v => unicodeMapping[v].filename);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Emoji requiring extra borders depending on theme
|
|
||||||
const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺', '📱', '📲', '🚲', '🪮', '🐦⬛']);
|
|
||||||
const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️', '🪽', '🪿']);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} filename
|
|
||||||
* @param {"light" | "dark" } colorScheme
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
const emojiFilename = (filename, colorScheme) => {
|
|
||||||
const borderedEmoji = colorScheme === "light" ? lightEmoji : darkEmoji;
|
|
||||||
return borderedEmoji.includes(filename) ? (filename + '_border') : filename;
|
|
||||||
};
|
|
||||||
|
|
||||||
const emojifyTextNode = (node, customEmojis) => {
|
|
||||||
const VS15 = 0xFE0E;
|
|
||||||
const VS16 = 0xFE0F;
|
|
||||||
|
|
||||||
let str = node.textContent;
|
|
||||||
|
|
||||||
const fragment = new DocumentFragment();
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
let unicode_emoji;
|
|
||||||
|
|
||||||
// Skip to the next potential emoji to replace (either custom emoji or custom emoji :shortcode:
|
|
||||||
if (customEmojis === null) {
|
|
||||||
while (i < str.length && !(unicode_emoji = trie.search(str.slice(i)))) {
|
|
||||||
i += str.codePointAt(i) < 65536 ? 1 : 2;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
while (i < str.length && str[i] !== ':' && !(unicode_emoji = trie.search(str.slice(i)))) {
|
|
||||||
i += str.codePointAt(i) < 65536 ? 1 : 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We reached the end of the string, nothing to replace
|
|
||||||
if (i === str.length) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rend, replacement = null;
|
|
||||||
if (str[i] === ':') { // Potentially the start of a custom emoji :shortcode:
|
|
||||||
rend = str.indexOf(':', i + 1) + 1;
|
|
||||||
|
|
||||||
// no matching ending ':', skip
|
|
||||||
if (!rend) {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortcode = str.slice(i, rend);
|
|
||||||
const custom_emoji = customEmojis[shortcode];
|
|
||||||
|
|
||||||
// not a recognized shortcode, skip
|
|
||||||
if (!custom_emoji) {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// now got a replacee as ':shortcode:'
|
|
||||||
// if you want additional emoji handler, add statements below which set replacement and return true.
|
|
||||||
const filename = autoPlayGif ? custom_emoji.url : custom_emoji.static_url;
|
|
||||||
replacement = document.createElement('img');
|
|
||||||
replacement.setAttribute('draggable', 'false');
|
|
||||||
replacement.setAttribute('class', 'emojione custom-emoji');
|
|
||||||
replacement.setAttribute('alt', shortcode);
|
|
||||||
replacement.setAttribute('title', shortcode);
|
|
||||||
replacement.setAttribute('src', filename);
|
|
||||||
replacement.setAttribute('data-original', custom_emoji.url);
|
|
||||||
replacement.setAttribute('data-static', custom_emoji.static_url);
|
|
||||||
} else { // start of an unicode emoji
|
|
||||||
rend = i + unicode_emoji.length;
|
|
||||||
|
|
||||||
// If the matched character was followed by VS15 (for selecting text presentation), skip it.
|
|
||||||
if (str.codePointAt(rend - 1) !== VS16 && str.codePointAt(rend) === VS15) {
|
|
||||||
i = rend + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { filename, shortCode } = unicodeMapping[unicode_emoji];
|
|
||||||
const title = shortCode ? `:${shortCode}:` : '';
|
|
||||||
|
|
||||||
const isSystemTheme = getIsSystemTheme();
|
|
||||||
|
|
||||||
const theme = (isSystemTheme || !isDarkMode()) ? 'light' : 'dark';
|
|
||||||
|
|
||||||
const imageFilename = emojiFilename(filename, theme);
|
|
||||||
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.setAttribute('draggable', 'false');
|
|
||||||
img.setAttribute('class', 'emojione');
|
|
||||||
img.setAttribute('alt', unicode_emoji);
|
|
||||||
img.setAttribute('title', title);
|
|
||||||
img.setAttribute('src', `${assetHost}/emoji/${imageFilename}.svg`);
|
|
||||||
|
|
||||||
if (isSystemTheme && imageFilename !== emojiFilename(filename, 'dark')) {
|
|
||||||
replacement = document.createElement('picture');
|
|
||||||
|
|
||||||
const source = document.createElement('source');
|
|
||||||
source.setAttribute('media', '(prefers-color-scheme: dark)');
|
|
||||||
source.setAttribute('srcset', `${assetHost}/emoji/${emojiFilename(filename, 'dark')}.svg`);
|
|
||||||
replacement.appendChild(source);
|
|
||||||
replacement.appendChild(img);
|
|
||||||
} else {
|
|
||||||
replacement = img;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the processed-up-to-now string and the emoji replacement
|
|
||||||
fragment.append(document.createTextNode(str.slice(0, i)));
|
|
||||||
fragment.append(replacement);
|
|
||||||
str = str.slice(rend);
|
|
||||||
i = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
fragment.append(document.createTextNode(str));
|
|
||||||
node.parentElement.replaceChild(fragment, node);
|
|
||||||
};
|
|
||||||
|
|
||||||
const emojifyNode = (node, customEmojis) => {
|
|
||||||
for (const child of Array.from(node.childNodes)) {
|
|
||||||
switch(child.nodeType) {
|
|
||||||
case Node.TEXT_NODE:
|
|
||||||
emojifyTextNode(child, customEmojis);
|
|
||||||
break;
|
|
||||||
case Node.ELEMENT_NODE:
|
|
||||||
if (!child.classList.contains('invisible'))
|
|
||||||
emojifyNode(child, customEmojis);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy emoji processing function.
|
|
||||||
* @param {string} str
|
|
||||||
* @param {object} customEmojis
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
const emojify = (str, customEmojis = {}) => {
|
|
||||||
const wrapper = document.createElement('div');
|
|
||||||
wrapper.innerHTML = str;
|
|
||||||
|
|
||||||
if (!Object.keys(customEmojis).length)
|
|
||||||
customEmojis = null;
|
|
||||||
|
|
||||||
emojifyNode(wrapper, customEmojis);
|
|
||||||
|
|
||||||
return wrapper.innerHTML;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default emojify;
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
// A mapping of unicode strings to an object containing the filename
|
|
||||||
// (i.e. the svg filename) and a shortCode intended to be shown
|
|
||||||
// as a "title" attribute in an HTML element (aka tooltip).
|
|
||||||
|
|
||||||
import emojiCompressed from 'virtual:mastodon-emoji-compressed';
|
|
||||||
import type {
|
|
||||||
FilenameData,
|
|
||||||
ShortCodesToEmojiDataKey,
|
|
||||||
} from 'virtual:mastodon-emoji-compressed';
|
|
||||||
|
|
||||||
import { unicodeToFilename } from './unicode_utils';
|
|
||||||
|
|
||||||
type UnicodeMapping = Record<
|
|
||||||
FilenameData[number][0],
|
|
||||||
{
|
|
||||||
shortCode: ShortCodesToEmojiDataKey;
|
|
||||||
filename: FilenameData[number][number];
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
const [
|
|
||||||
shortCodesToEmojiData,
|
|
||||||
_skins,
|
|
||||||
_categories,
|
|
||||||
_short_names,
|
|
||||||
emojisWithoutShortCodes,
|
|
||||||
] = emojiCompressed;
|
|
||||||
|
|
||||||
// decompress
|
|
||||||
const unicodeMapping: UnicodeMapping = {};
|
|
||||||
|
|
||||||
function processEmojiMapData(
|
|
||||||
emojiMapData: FilenameData[number],
|
|
||||||
shortCode?: ShortCodesToEmojiDataKey,
|
|
||||||
) {
|
|
||||||
const [native, _filename] = emojiMapData;
|
|
||||||
// filename name can be derived from unicodeToFilename
|
|
||||||
const filename = emojiMapData[1] ?? unicodeToFilename(native);
|
|
||||||
unicodeMapping[native] = {
|
|
||||||
shortCode,
|
|
||||||
filename,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.keys(shortCodesToEmojiData).forEach(
|
|
||||||
(shortCode: ShortCodesToEmojiDataKey) => {
|
|
||||||
if (shortCode === undefined) return;
|
|
||||||
|
|
||||||
const emojiData = shortCodesToEmojiData[shortCode];
|
|
||||||
if (!emojiData) return;
|
|
||||||
const [filenameData, _searchData] = emojiData;
|
|
||||||
filenameData.forEach((emojiMapData) => {
|
|
||||||
processEmojiMapData(emojiMapData, shortCode);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
emojisWithoutShortCodes.forEach((emojiMapData) => {
|
|
||||||
processEmojiMapData(emojiMapData);
|
|
||||||
});
|
|
||||||
|
|
||||||
export { unicodeMapping };
|
|
||||||
@ -34,6 +34,43 @@ export function useEmojiAppState(): EmojiAppState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getEmojiAppState(): EmojiAppState {
|
||||||
|
const currentLocale = toSupportedLocale(document.documentElement.lang);
|
||||||
|
|
||||||
|
let emojiStyle = 'auto';
|
||||||
|
const initialStateText =
|
||||||
|
document.getElementById('initial-state')?.textContent;
|
||||||
|
if (initialStateText) {
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(initialStateText) as unknown;
|
||||||
|
if (
|
||||||
|
state !== null &&
|
||||||
|
typeof state === 'object' &&
|
||||||
|
'meta' in state &&
|
||||||
|
state.meta !== null &&
|
||||||
|
typeof state.meta === 'object' &&
|
||||||
|
'emoji_style' in state.meta &&
|
||||||
|
typeof state.meta.emoji_style === 'string'
|
||||||
|
) {
|
||||||
|
emojiStyle = state.meta.emoji_style;
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn(
|
||||||
|
'Failed to parse initial state for emoji, defaulting to auto. Error:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentLocale,
|
||||||
|
locales: [currentLocale],
|
||||||
|
mode: determineEmojiMode(emojiStyle),
|
||||||
|
darkTheme: isDarkMode(),
|
||||||
|
assetHost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type Feature = Uint8ClampedArray;
|
type Feature = Uint8ClampedArray;
|
||||||
|
|
||||||
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/constants.js
|
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/constants.js
|
||||||
|
|||||||
@ -152,11 +152,11 @@ const CODES_WITH_LIGHT_BORDER = EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
|
|||||||
|
|
||||||
export function unicodeHexToUrl({
|
export function unicodeHexToUrl({
|
||||||
unicodeHex,
|
unicodeHex,
|
||||||
darkTheme,
|
darkTheme = true,
|
||||||
assetHost,
|
assetHost,
|
||||||
}: {
|
}: {
|
||||||
unicodeHex: string;
|
unicodeHex: string;
|
||||||
darkTheme: boolean;
|
darkTheme?: boolean;
|
||||||
assetHost: string;
|
assetHost: string;
|
||||||
}): string {
|
}): string {
|
||||||
const normalizedHex = unicodeToTwemojiHex(unicodeHex);
|
const normalizedHex = unicodeToTwemojiHex(unicodeHex);
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
|
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
|
||||||
|
|
||||||
|
import { EMOJI_MODE_TWEMOJI } from './constants';
|
||||||
import * as db from './database';
|
import * as db from './database';
|
||||||
import * as loader from './loader';
|
import * as loader from './loader';
|
||||||
import {
|
import {
|
||||||
loadEmojiDataToState,
|
loadEmojiDataToState,
|
||||||
stringToEmojiState,
|
stringToEmojiState,
|
||||||
tokenizeText,
|
tokenizeText,
|
||||||
|
updateHtmlWithEmoji,
|
||||||
} from './render';
|
} from './render';
|
||||||
import type { EmojiStateCustom, EmojiStateUnicode } from './types';
|
import type { EmojiStateCustom, EmojiStateUnicode } from './types';
|
||||||
|
|
||||||
@ -107,6 +109,74 @@ describe('stringToEmojiState', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('updateHtmlWithEmoji', () => {
|
||||||
|
const defaultOptions = {
|
||||||
|
assetHost: '',
|
||||||
|
darkTheme: false,
|
||||||
|
mode: EMOJI_MODE_TWEMOJI,
|
||||||
|
locale: 'en',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates element text with emojis', async () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.textContent = '😊';
|
||||||
|
|
||||||
|
vi.spyOn(db, 'loadLegacyShortcodesByShortcode').mockResolvedValueOnce(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
vi.spyOn(db, 'loadEmojiByHexcode').mockResolvedValueOnce(
|
||||||
|
unicodeEmojiFactory(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateHtmlWithEmoji({
|
||||||
|
...defaultOptions,
|
||||||
|
element,
|
||||||
|
});
|
||||||
|
|
||||||
|
const img = element.querySelector('img');
|
||||||
|
expect(img).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not update element text when mode is native', async () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.textContent = '😊';
|
||||||
|
|
||||||
|
const dbShortcodeCall = vi.spyOn(db, 'loadLegacyShortcodesByShortcode');
|
||||||
|
const dbEmojiCall = vi.spyOn(db, 'loadEmojiByHexcode');
|
||||||
|
|
||||||
|
await updateHtmlWithEmoji({
|
||||||
|
...defaultOptions,
|
||||||
|
mode: 'native',
|
||||||
|
element,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dbShortcodeCall).not.toHaveBeenCalled();
|
||||||
|
expect(dbEmojiCall).not.toHaveBeenCalled();
|
||||||
|
expect(element.textContent).toBe('😊');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not try to load custom emojis', async () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.textContent = ':smile:';
|
||||||
|
|
||||||
|
const dbShortcodeCall = vi.spyOn(db, 'loadLegacyShortcodesByShortcode');
|
||||||
|
const dbEmojiCall = vi.spyOn(db, 'loadEmojiByHexcode');
|
||||||
|
|
||||||
|
await updateHtmlWithEmoji({
|
||||||
|
...defaultOptions,
|
||||||
|
element,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dbShortcodeCall).not.toHaveBeenCalled();
|
||||||
|
expect(dbEmojiCall).not.toHaveBeenCalled();
|
||||||
|
expect(element.textContent).toBe(':smile:');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('loadEmojiDataToState', () => {
|
describe('loadEmojiDataToState', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@ -137,6 +207,18 @@ describe('loadEmojiDataToState', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('converts unicode emoji code to hexcode when loading data', async () => {
|
||||||
|
const dbCall = vi
|
||||||
|
.spyOn(db, 'loadEmojiByHexcode')
|
||||||
|
.mockResolvedValue(unicodeEmojiFactory());
|
||||||
|
const unicodeState = {
|
||||||
|
type: 'unicode',
|
||||||
|
code: '😊',
|
||||||
|
} as const satisfies EmojiStateUnicode;
|
||||||
|
await loadEmojiDataToState(unicodeState, 'en');
|
||||||
|
expect(dbCall).toHaveBeenCalledWith('1F60A', 'en');
|
||||||
|
});
|
||||||
|
|
||||||
test('returns null for custom emoji without data', async () => {
|
test('returns null for custom emoji without data', async () => {
|
||||||
const customState = {
|
const customState = {
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
|
|||||||
@ -4,7 +4,9 @@ import {
|
|||||||
EMOJI_TYPE_UNICODE,
|
EMOJI_TYPE_UNICODE,
|
||||||
EMOJI_TYPE_CUSTOM,
|
EMOJI_TYPE_CUSTOM,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
|
import { emojiToInversionClassName, unicodeHexToUrl } from './normalize';
|
||||||
import type {
|
import type {
|
||||||
|
EmojiAppState,
|
||||||
EmojiLoadedState,
|
EmojiLoadedState,
|
||||||
EmojiMode,
|
EmojiMode,
|
||||||
EmojiState,
|
EmojiState,
|
||||||
@ -47,14 +49,14 @@ export function tokenizeText(text: string): TokenizedText {
|
|||||||
if (code.startsWith(':') && code.endsWith(':')) {
|
if (code.startsWith(':') && code.endsWith(':')) {
|
||||||
// Custom emoji
|
// Custom emoji
|
||||||
tokens.push({
|
tokens.push({
|
||||||
type: EMOJI_TYPE_CUSTOM,
|
|
||||||
code,
|
code,
|
||||||
|
type: EMOJI_TYPE_CUSTOM,
|
||||||
} satisfies EmojiStateCustom);
|
} satisfies EmojiStateCustom);
|
||||||
} else {
|
} else {
|
||||||
// Unicode emoji
|
// Unicode emoji
|
||||||
tokens.push({
|
tokens.push({
|
||||||
|
code,
|
||||||
type: EMOJI_TYPE_UNICODE,
|
type: EMOJI_TYPE_UNICODE,
|
||||||
code: code,
|
|
||||||
} satisfies EmojiStateUnicode);
|
} satisfies EmojiStateUnicode);
|
||||||
}
|
}
|
||||||
lastIndex = match.index + code.length;
|
lastIndex = match.index + code.length;
|
||||||
@ -76,8 +78,8 @@ export function stringToEmojiState(
|
|||||||
): EmojiStateUnicode | Required<EmojiStateCustom> | null {
|
): EmojiStateUnicode | Required<EmojiStateCustom> | null {
|
||||||
if (isUnicodeEmoji(code)) {
|
if (isUnicodeEmoji(code)) {
|
||||||
return {
|
return {
|
||||||
type: EMOJI_TYPE_UNICODE,
|
|
||||||
code: emojiToUnicodeHex(code),
|
code: emojiToUnicodeHex(code),
|
||||||
|
type: EMOJI_TYPE_UNICODE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,6 +97,68 @@ export function stringToEmojiState(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes an element and emojifies all native emoji.
|
||||||
|
*/
|
||||||
|
export async function updateHtmlWithEmoji({
|
||||||
|
assetHost,
|
||||||
|
darkTheme,
|
||||||
|
element,
|
||||||
|
mode,
|
||||||
|
locale,
|
||||||
|
}: {
|
||||||
|
element: Element;
|
||||||
|
locale: string;
|
||||||
|
} & Omit<EmojiAppState, 'currentLocale' | 'locales'>) {
|
||||||
|
if (mode === EMOJI_MODE_NATIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = tokenizeText(element.innerHTML);
|
||||||
|
const newChildren: (string | Element)[] = [];
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (typeof token === 'string') {
|
||||||
|
newChildren.push(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await loadEmojiDataToState(token, locale);
|
||||||
|
// Ignore custom emoji if we encounter them.
|
||||||
|
if (!state || state.type === EMOJI_TYPE_CUSTOM) {
|
||||||
|
newChildren.push(token.code);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldRenderImage(state, mode)) {
|
||||||
|
newChildren.push(state.data.unicode);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = unicodeHexToUrl({
|
||||||
|
assetHost,
|
||||||
|
darkTheme,
|
||||||
|
unicodeHex: state.data.hexcode,
|
||||||
|
});
|
||||||
|
img.alt = state.data.unicode;
|
||||||
|
img.title = state.data.label;
|
||||||
|
img.classList.add('emojione');
|
||||||
|
|
||||||
|
const inversionClass = emojiToInversionClassName(state.data.unicode);
|
||||||
|
if (inversionClass) {
|
||||||
|
img.classList.add(inversionClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
newChildren.push(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.innerHTML = newChildren.reduce<string>(
|
||||||
|
(prev, curr) =>
|
||||||
|
typeof curr === 'string' ? prev + curr : prev + curr.outerHTML,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads emoji data into the given state if not already loaded.
|
* Loads emoji data into the given state if not already loaded.
|
||||||
* @param state Emoji state to load data for.
|
* @param state Emoji state to load data for.
|
||||||
@ -121,17 +185,19 @@ export async function loadEmojiDataToState(
|
|||||||
LocaleNotLoadedError,
|
LocaleNotLoadedError,
|
||||||
} = await import('./database');
|
} = await import('./database');
|
||||||
|
|
||||||
|
const code = isUnicodeEmoji(state.code)
|
||||||
|
? emojiToUnicodeHex(state.code)
|
||||||
|
: state.code;
|
||||||
|
|
||||||
// First, try to load the data from IndexedDB.
|
// First, try to load the data from IndexedDB.
|
||||||
try {
|
try {
|
||||||
const legacyCode = await loadLegacyShortcodesByShortcode(state.code);
|
const legacyCode = await loadLegacyShortcodesByShortcode(code);
|
||||||
// This is duplicative, but that's because TS can't distinguish the state type easily.
|
// This is duplicative, but that's because TS can't distinguish the state type easily.
|
||||||
const data = await loadEmojiByHexcode(
|
const data = await loadEmojiByHexcode(legacyCode?.hexcode ?? code, locale);
|
||||||
legacyCode?.hexcode ?? state.code,
|
|
||||||
locale,
|
|
||||||
);
|
|
||||||
if (data) {
|
if (data) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
code,
|
||||||
type: EMOJI_TYPE_UNICODE,
|
type: EMOJI_TYPE_UNICODE,
|
||||||
data,
|
data,
|
||||||
// TODO: Use CLDR shortcodes when the picker supports them.
|
// TODO: Use CLDR shortcodes when the picker supports them.
|
||||||
@ -140,14 +206,14 @@ export async function loadEmojiDataToState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If not found, assume it's not an emoji and return null.
|
// If not found, assume it's not an emoji and return null.
|
||||||
log('Could not find emoji %s for locale %s', state.code, locale);
|
log('Could not find emoji %s for locale %s', code, locale);
|
||||||
return null;
|
return null;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// If the locale is not loaded, load it and retry once.
|
// If the locale is not loaded, load it and retry once.
|
||||||
if (!retry && err instanceof LocaleNotLoadedError) {
|
if (!retry && err instanceof LocaleNotLoadedError) {
|
||||||
log(
|
log(
|
||||||
'Error loading emoji %s for locale %s, loading locale and retrying.',
|
'Error loading emoji %s for locale %s, loading locale and retrying.',
|
||||||
state.code,
|
code,
|
||||||
locale,
|
locale,
|
||||||
);
|
);
|
||||||
const { importEmojiData } = await import('./loader');
|
const { importEmojiData } = await import('./loader');
|
||||||
|
|||||||
@ -272,7 +272,7 @@ export const DetailedStatus: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (taggedCollection) {
|
if (taggedCollection) {
|
||||||
media = <CollectionPreviewCard collection={taggedCollection} />;
|
media = <CollectionPreviewCard collection={taggedCollection.toJS()} />;
|
||||||
} else {
|
} else {
|
||||||
media = (
|
media = (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@ -48,10 +48,10 @@ interface InitialStateMeta {
|
|||||||
status_page_url: string;
|
status_page_url: string;
|
||||||
terms_of_service_enabled: boolean;
|
terms_of_service_enabled: boolean;
|
||||||
emoji_style?: string;
|
emoji_style?: string;
|
||||||
wrapstodon?: InitialWrapstodonState | null;
|
wrapstodon?: InitialStateWrapstodon | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Role {
|
interface IntialStateRole {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
permissions: string;
|
permissions: string;
|
||||||
@ -60,17 +60,27 @@ interface Role {
|
|||||||
collection_limit: number;
|
collection_limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InitialWrapstodonState {
|
interface InitialStateWrapstodon {
|
||||||
year: number;
|
year: number;
|
||||||
state: ApiAnnualReportState;
|
state: ApiAnnualReportState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InitialStateCompose {
|
||||||
|
text: string;
|
||||||
|
default_privacy?: string;
|
||||||
|
default_sensitive?: boolean;
|
||||||
|
default_language?: string;
|
||||||
|
default_quote_policy?: string;
|
||||||
|
me?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InitialState {
|
export interface InitialState {
|
||||||
accounts: Record<string, ApiAccountJSON>;
|
accounts: Record<string, ApiAccountJSON>;
|
||||||
languages: InitialStateLanguage[];
|
languages: InitialStateLanguage[];
|
||||||
|
compose: InitialStateCompose;
|
||||||
critical_updates_pending?: boolean;
|
critical_updates_pending?: boolean;
|
||||||
meta: InitialStateMeta;
|
meta: InitialStateMeta;
|
||||||
role?: Role;
|
role?: IntialStateRole;
|
||||||
features: string[];
|
features: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import type {
|
|||||||
ApiUpdateCollectionPayload,
|
ApiUpdateCollectionPayload,
|
||||||
CollectionAccountItem,
|
CollectionAccountItem,
|
||||||
} from '@/mastodon/api_types/collections';
|
} from '@/mastodon/api_types/collections';
|
||||||
import { me } from '@/mastodon/initial_state';
|
import { initialState, me } from '@/mastodon/initial_state';
|
||||||
import {
|
import {
|
||||||
createAppAsyncThunk,
|
createAppAsyncThunk,
|
||||||
createAppSelector,
|
createAppSelector,
|
||||||
@ -63,7 +63,7 @@ interface EditorState {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
topic: string;
|
topic: string;
|
||||||
language: string | null;
|
language: string;
|
||||||
discoverable: boolean;
|
discoverable: boolean;
|
||||||
sensitive: boolean;
|
sensitive: boolean;
|
||||||
items: EditorCollectionItem[];
|
items: EditorCollectionItem[];
|
||||||
@ -74,7 +74,7 @@ interface UpdateEditorFieldPayload<K extends keyof EditorState> {
|
|||||||
value: EditorState[K];
|
value: EditorState[K];
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: CollectionState = {
|
const initialCollectionState: CollectionState = {
|
||||||
collections: {},
|
collections: {},
|
||||||
createdBy: {},
|
createdBy: {},
|
||||||
featuring: {},
|
featuring: {},
|
||||||
@ -83,7 +83,7 @@ const initialState: CollectionState = {
|
|||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
topic: '',
|
topic: '',
|
||||||
language: null,
|
language: initialState?.compose.default_language ?? 'en',
|
||||||
discoverable: true,
|
discoverable: true,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
items: [],
|
items: [],
|
||||||
@ -92,24 +92,24 @@ const initialState: CollectionState = {
|
|||||||
|
|
||||||
const collectionSlice = createSlice({
|
const collectionSlice = createSlice({
|
||||||
name: 'collections',
|
name: 'collections',
|
||||||
initialState,
|
initialState: initialCollectionState,
|
||||||
reducers: {
|
reducers: {
|
||||||
init(state, action: PayloadAction<ApiCollectionJSON | null>) {
|
init(state, action: PayloadAction<ApiCollectionJSON>) {
|
||||||
const collection = action.payload;
|
const collection = action.payload;
|
||||||
|
|
||||||
state.editor = {
|
state.editor = {
|
||||||
id: collection?.id ?? null,
|
id: collection.id,
|
||||||
name: collection?.name ?? '',
|
name: collection.name,
|
||||||
description: collection?.description ?? '',
|
description: collection.description ?? '',
|
||||||
topic: inputToHashtag(collection?.tag?.name ?? ''),
|
topic: inputToHashtag(collection.tag?.name ?? ''),
|
||||||
language: collection?.language ?? '',
|
language: collection.language ?? '',
|
||||||
discoverable: collection?.discoverable ?? true,
|
discoverable: collection.discoverable,
|
||||||
sensitive: collection?.sensitive ?? false,
|
sensitive: collection.sensitive,
|
||||||
items: getEditorCollectionItems(collection?.items ?? []),
|
items: getEditorCollectionItems(collection.items),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
reset(state) {
|
reset(state) {
|
||||||
state.editor = initialState.editor;
|
state.editor = initialCollectionState.editor;
|
||||||
},
|
},
|
||||||
updateEditorField<K extends keyof EditorState>(
|
updateEditorField<K extends keyof EditorState>(
|
||||||
state: CollectionState,
|
state: CollectionState,
|
||||||
@ -233,7 +233,7 @@ const collectionSlice = createSlice({
|
|||||||
builder.addCase(updateCollection.fulfilled, (state, action) => {
|
builder.addCase(updateCollection.fulfilled, (state, action) => {
|
||||||
const { collection } = action.payload;
|
const { collection } = action.payload;
|
||||||
state.collections[collection.id] = collection;
|
state.collections[collection.id] = collection;
|
||||||
state.editor = initialState.editor;
|
state.editor = initialCollectionState.editor;
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -262,7 +262,7 @@ const collectionSlice = createSlice({
|
|||||||
const { collection } = actions.payload;
|
const { collection } = actions.payload;
|
||||||
|
|
||||||
state.collections[collection.id] = collection;
|
state.collections[collection.id] = collection;
|
||||||
state.editor = initialState.editor;
|
state.editor = initialCollectionState.editor;
|
||||||
|
|
||||||
if (state.createdBy[collection.account_id]) {
|
if (state.createdBy[collection.account_id]) {
|
||||||
state.createdBy[collection.account_id]?.collectionIds.unshift(
|
state.createdBy[collection.account_id]?.collectionIds.unshift(
|
||||||
|
|||||||
27
app/javascript/mastodon/utils/objects.test.ts
Normal file
27
app/javascript/mastodon/utils/objects.test.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-confusing-void-expression */
|
||||||
|
import { getNestedProperty } from './objects';
|
||||||
|
|
||||||
|
describe('getNestedProperty', () => {
|
||||||
|
test('returns the value of a nested property if it exists', () => {
|
||||||
|
const obj = { a: { b: { c: 42 } } };
|
||||||
|
expect(getNestedProperty(obj, 'a', 'b', 'c')).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined if any part of the path does not exist', () => {
|
||||||
|
const obj = { a: { b: { c: 42 } } };
|
||||||
|
expect(getNestedProperty(obj, 'a', 'x', 'c')).toBeUndefined();
|
||||||
|
expect(getNestedProperty(obj, 'a', 'b', 'x')).toBeUndefined();
|
||||||
|
expect(getNestedProperty(obj, 'x', 'b', 'c')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined if the initial object is not a record', () => {
|
||||||
|
expect(getNestedProperty(null, 'a', 'b')).toBeUndefined();
|
||||||
|
expect(getNestedProperty(42, 'a', 'b')).toBeUndefined();
|
||||||
|
expect(getNestedProperty('string', 'a', 'b')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined if no keys are provided', () => {
|
||||||
|
const obj = { a: 1 };
|
||||||
|
expect(getNestedProperty(obj)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
52
app/javascript/mastodon/utils/objects.ts
Normal file
52
app/javascript/mastodon/utils/objects.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { isPlainObject } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export type RecordObject = Record<PropertyKey, unknown>;
|
||||||
|
|
||||||
|
export function isRecordObject(obj: unknown): obj is RecordObject {
|
||||||
|
return isPlainObject(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
type NestedProperty<T, K extends readonly PropertyKey[]> = K extends readonly [
|
||||||
|
infer Head,
|
||||||
|
...infer Tail,
|
||||||
|
]
|
||||||
|
? Head extends keyof NonNullable<T>
|
||||||
|
? Tail extends readonly PropertyKey[]
|
||||||
|
? NestedProperty<NonNullable<T>[Head], Tail>
|
||||||
|
: NonNullable<T>[Head]
|
||||||
|
: undefined
|
||||||
|
: T;
|
||||||
|
|
||||||
|
export function getNestedProperty<
|
||||||
|
TObject extends RecordObject,
|
||||||
|
const TKeys extends readonly PropertyKey[],
|
||||||
|
>(object: TObject, ...keys: TKeys): NestedProperty<TObject, TKeys> | undefined;
|
||||||
|
export function getNestedProperty(
|
||||||
|
object: unknown,
|
||||||
|
...keys: PropertyKey[]
|
||||||
|
): unknown;
|
||||||
|
export function getNestedProperty(
|
||||||
|
object: unknown,
|
||||||
|
...keys: PropertyKey[]
|
||||||
|
): unknown {
|
||||||
|
if (!isRecordObject(object) || keys.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingKeys = [...keys];
|
||||||
|
let currentObject: RecordObject = object;
|
||||||
|
while (remainingKeys.length > 0) {
|
||||||
|
const currentKey = remainingKeys.shift();
|
||||||
|
if (currentKey !== undefined && currentKey in currentObject) {
|
||||||
|
const nextObject = currentObject[currentKey];
|
||||||
|
if (isRecordObject(nextObject)) {
|
||||||
|
currentObject = nextObject;
|
||||||
|
continue;
|
||||||
|
} else if (remainingKeys.length === 0) {
|
||||||
|
return nextObject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@ -26,6 +26,13 @@ module.exports = {
|
|||||||
'selector-id-pattern': null,
|
'selector-id-pattern': null,
|
||||||
'value-keyword-case': null,
|
'value-keyword-case': null,
|
||||||
'value-no-vendor-prefix': null,
|
'value-no-vendor-prefix': null,
|
||||||
|
'custom-property-pattern': [
|
||||||
|
'^_?[a-z]([a-z0-9])*(-[a-z0-9]+)*$',
|
||||||
|
{
|
||||||
|
message: (name) =>
|
||||||
|
`Expected custom property name "${name}" to be kebab-case (optional leading underscore allowed)`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
'scss/dollar-variable-empty-line-before': null,
|
'scss/dollar-variable-empty-line-before': null,
|
||||||
'scss/no-global-function-names': null,
|
'scss/no-global-function-names': null,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user