Allow HTML, add language picker
Utilizes code from Mastodon LanguageDropdown: https://github.com/mastodon/mastodon/blob/main/app/javascript/mastodon/features/compose/components/language_dropdown.jsx Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
4ceef0fe81
commit
2e43f8967f
11 changed files with 444 additions and 15 deletions
|
@ -113,6 +113,7 @@
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
"eslint-plugin-formatjs": "^4.12.2",
|
"eslint-plugin-formatjs": "^4.12.2",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
|
"fuzzysort": "^3.0.0",
|
||||||
"graphemesplit": "^2.4.4",
|
"graphemesplit": "^2.4.4",
|
||||||
"html-react-parser": "^5.0.0",
|
"html-react-parser": "^5.0.0",
|
||||||
"http-link-header": "^1.0.2",
|
"http-link-header": "^1.0.2",
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { defineMessages, IntlShape } from 'react-intl';
|
||||||
import api from 'soapbox/api';
|
import api from 'soapbox/api';
|
||||||
import { isNativeEmoji } from 'soapbox/features/emoji';
|
import { isNativeEmoji } from 'soapbox/features/emoji';
|
||||||
import emojiSearch from 'soapbox/features/emoji/search';
|
import emojiSearch from 'soapbox/features/emoji/search';
|
||||||
|
import { Language } from 'soapbox/features/preferences';
|
||||||
import { normalizeTag } from 'soapbox/normalizers';
|
import { normalizeTag } from 'soapbox/normalizers';
|
||||||
import { selectAccount, selectOwnAccount, makeGetAccount } from 'soapbox/selectors';
|
import { selectAccount, selectOwnAccount, makeGetAccount } from 'soapbox/selectors';
|
||||||
import { tagHistory } from 'soapbox/settings';
|
import { tagHistory } from 'soapbox/settings';
|
||||||
|
@ -59,6 +60,7 @@ const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE' as const;
|
||||||
const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE' as const;
|
const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE' as const;
|
||||||
const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE' as const;
|
const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE' as const;
|
||||||
const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE' as const;
|
const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE' as const;
|
||||||
|
const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE' as const;
|
||||||
const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE' as const;
|
const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE' as const;
|
||||||
|
|
||||||
const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT' as const;
|
const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT' as const;
|
||||||
|
@ -372,6 +374,7 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) =>
|
||||||
content_type: compose.content_type,
|
content_type: compose.content_type,
|
||||||
poll: compose.poll,
|
poll: compose.poll,
|
||||||
scheduled_at: compose.schedule,
|
scheduled_at: compose.schedule,
|
||||||
|
language: compose.language,
|
||||||
to,
|
to,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -725,6 +728,12 @@ const changeComposeVisibility = (composeId: string, value: string) => ({
|
||||||
value,
|
value,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const changeComposeLanguage = (composeId: string, value: Language | null) => ({
|
||||||
|
type: COMPOSE_LANGUAGE_CHANGE,
|
||||||
|
id: composeId,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
|
||||||
const insertEmojiCompose = (composeId: string, position: number, emoji: Emoji, needsSpace: boolean) => ({
|
const insertEmojiCompose = (composeId: string, position: number, emoji: Emoji, needsSpace: boolean) => ({
|
||||||
type: COMPOSE_EMOJI_INSERT,
|
type: COMPOSE_EMOJI_INSERT,
|
||||||
id: composeId,
|
id: composeId,
|
||||||
|
@ -908,6 +917,7 @@ type ComposeAction =
|
||||||
| ReturnType<typeof changeComposeContentType>
|
| ReturnType<typeof changeComposeContentType>
|
||||||
| ReturnType<typeof changeComposeSpoilerText>
|
| ReturnType<typeof changeComposeSpoilerText>
|
||||||
| ReturnType<typeof changeComposeVisibility>
|
| ReturnType<typeof changeComposeVisibility>
|
||||||
|
| ReturnType<typeof changeComposeLanguage>
|
||||||
| ReturnType<typeof insertEmojiCompose>
|
| ReturnType<typeof insertEmojiCompose>
|
||||||
| ReturnType<typeof addPoll>
|
| ReturnType<typeof addPoll>
|
||||||
| ReturnType<typeof removePoll>
|
| ReturnType<typeof removePoll>
|
||||||
|
@ -953,6 +963,7 @@ export {
|
||||||
COMPOSE_TYPE_CHANGE,
|
COMPOSE_TYPE_CHANGE,
|
||||||
COMPOSE_SPOILER_TEXT_CHANGE,
|
COMPOSE_SPOILER_TEXT_CHANGE,
|
||||||
COMPOSE_VISIBILITY_CHANGE,
|
COMPOSE_VISIBILITY_CHANGE,
|
||||||
|
COMPOSE_LANGUAGE_CHANGE,
|
||||||
COMPOSE_LISTABILITY_CHANGE,
|
COMPOSE_LISTABILITY_CHANGE,
|
||||||
COMPOSE_EMOJI_INSERT,
|
COMPOSE_EMOJI_INSERT,
|
||||||
COMPOSE_UPLOAD_CHANGE_REQUEST,
|
COMPOSE_UPLOAD_CHANGE_REQUEST,
|
||||||
|
@ -1012,6 +1023,7 @@ export {
|
||||||
changeComposeContentType,
|
changeComposeContentType,
|
||||||
changeComposeSpoilerText,
|
changeComposeSpoilerText,
|
||||||
changeComposeVisibility,
|
changeComposeVisibility,
|
||||||
|
changeComposeLanguage,
|
||||||
insertEmojiCompose,
|
insertEmojiCompose,
|
||||||
addPoll,
|
addPoll,
|
||||||
removePoll,
|
removePoll,
|
||||||
|
|
|
@ -10,7 +10,7 @@ import type { ButtonSizes, ButtonThemes } from './useButtonStyles';
|
||||||
|
|
||||||
interface IButton extends Pick<
|
interface IButton extends Pick<
|
||||||
React.ComponentProps<'button'>,
|
React.ComponentProps<'button'>,
|
||||||
'children' | 'className' | 'disabled' | 'onClick' | 'onMouseDown' | 'onKeyDown' | 'title' | 'type'
|
'children' | 'className' | 'disabled' | 'onClick' | 'onMouseDown' | 'onKeyDown' | 'onKeyPress' | 'title' | 'type'
|
||||||
> {
|
> {
|
||||||
/** Whether this button expands the width of its container. */
|
/** Whether this button expands the width of its container. */
|
||||||
block?: boolean;
|
block?: boolean;
|
||||||
|
|
|
@ -18,17 +18,17 @@ const alignItemsOptions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const spaces = {
|
const spaces = {
|
||||||
0: 'gap-x-0',
|
0: 'gap-0',
|
||||||
[0.5]: 'gap-x-0.5',
|
[0.5]: 'gap-0.5',
|
||||||
1: 'gap-x-1',
|
1: 'gap-1',
|
||||||
1.5: 'gap-x-1.5',
|
1.5: 'gap-1.5',
|
||||||
2: 'gap-x-2',
|
2: 'gap-2',
|
||||||
2.5: 'gap-x-2.5',
|
2.5: 'gap-2.5',
|
||||||
3: 'gap-x-3',
|
3: 'gap-3',
|
||||||
4: 'gap-x-4',
|
4: 'gap-4',
|
||||||
5: 'gap-x-5',
|
5: 'gap-5',
|
||||||
6: 'gap-x-6',
|
6: 'gap-6',
|
||||||
8: 'gap-x-8',
|
8: 'gap-8',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IHStack extends Pick<React.HTMLAttributes<HTMLDivElement>, 'children' | 'className' | 'onClick' | 'style' | 'title'> {
|
interface IHStack extends Pick<React.HTMLAttributes<HTMLDivElement>, 'children' | 'className' | 'onClick' | 'style' | 'title'> {
|
||||||
|
|
|
@ -27,6 +27,7 @@ import { $createEmojiNode } from '../editor/nodes/emoji-node';
|
||||||
import { countableText } from '../util/counter';
|
import { countableText } from '../util/counter';
|
||||||
|
|
||||||
import ContentTypeButton from './content-type-button';
|
import ContentTypeButton from './content-type-button';
|
||||||
|
import LanguageDropdown from './language-dropdown';
|
||||||
import PollButton from './poll-button';
|
import PollButton from './poll-button';
|
||||||
import PollForm from './polls/poll-form';
|
import PollForm from './polls/poll-form';
|
||||||
import PrivacyDropdown from './privacy-dropdown';
|
import PrivacyDropdown from './privacy-dropdown';
|
||||||
|
@ -242,6 +243,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
|
|
||||||
if (features.privacyScopes && !group && !groupId) selectButtons.push(<PrivacyDropdown composeId={id} />);
|
if (features.privacyScopes && !group && !groupId) selectButtons.push(<PrivacyDropdown composeId={id} />);
|
||||||
if (features.richText) selectButtons.push(<ContentTypeButton composeId={id} />);
|
if (features.richText) selectButtons.push(<ContentTypeButton composeId={id} />);
|
||||||
|
selectButtons.push(<LanguageDropdown composeId={id} />);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
|
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
|
||||||
|
|
69
src/features/compose/components/content-type-button.tsx
Normal file
69
src/features/compose/components/content-type-button.tsx
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { changeComposeContentType } from 'soapbox/actions/compose';
|
||||||
|
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||||
|
import { Button } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
content_type_plaintext: { id: 'preferences.options.content_type_plaintext', defaultMessage: 'Plain text' },
|
||||||
|
content_type_markdown: { id: 'preferences.options.content_type_markdown', defaultMessage: 'Markdown' },
|
||||||
|
content_type_html: { id: 'preferences.options.content_type_html', defaultMessage: 'HTML' },
|
||||||
|
change_content_type: { id: 'compose_form.content_type.change', defaultMessage: 'Change content type' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IContentTypeButton {
|
||||||
|
composeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContentTypeButton: React.FC<IContentTypeButton> = ({ composeId }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const contentType = useCompose(composeId).content_type;
|
||||||
|
|
||||||
|
const handleChange = (contentType: string) => () => dispatch(changeComposeContentType(composeId, contentType));
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
icon: require('@tabler/icons/outline/pilcrow.svg'),
|
||||||
|
text: intl.formatMessage(messages.content_type_plaintext),
|
||||||
|
value: 'text/plain',
|
||||||
|
},
|
||||||
|
{ icon: require('@tabler/icons/outline/markdown.svg'),
|
||||||
|
text: intl.formatMessage(messages.content_type_markdown),
|
||||||
|
value: 'text/markdown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: require('@tabler/icons/outline/html.svg'),
|
||||||
|
text: intl.formatMessage(messages.content_type_html),
|
||||||
|
value: 'text/html',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const option = options.find(({ value }) => value === contentType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
items={options.map(({ icon, text, value }) => ({
|
||||||
|
icon,
|
||||||
|
text,
|
||||||
|
action: handleChange(value),
|
||||||
|
active: contentType === value,
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
theme='muted'
|
||||||
|
size='xs'
|
||||||
|
text={option?.text}
|
||||||
|
icon={option?.icon}
|
||||||
|
secondaryIcon={require('@tabler/icons/outline/chevron-down.svg')}
|
||||||
|
title={intl.formatMessage(messages.change_content_type)}
|
||||||
|
/>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ContentTypeButton as default };
|
329
src/features/compose/components/language-dropdown.tsx
Normal file
329
src/features/compose/components/language-dropdown.tsx
Normal file
|
@ -0,0 +1,329 @@
|
||||||
|
import { offset, useFloating, flip, arrow, shift } from '@floating-ui/react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||||
|
import fuzzysort from 'fuzzysort';
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { changeComposeLanguage } from 'soapbox/actions/compose';
|
||||||
|
import { Button, Icon, Input, Portal } from 'soapbox/components/ui';
|
||||||
|
import { type Language, languages as languagesObject } from 'soapbox/features/preferences';
|
||||||
|
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||||
|
|
||||||
|
const languages = Object.entries(languagesObject) as Array<[Language, string]>;
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
languagePrompt: { id: 'compose.language_dropdown.prompt', defaultMessage: 'Select language' },
|
||||||
|
search: { id: 'compose.language_dropdown.search', defaultMessage: 'Search language…' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ILanguageDropdown {
|
||||||
|
composeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LanguageDropdown: React.FC<ILanguageDropdown> = ({ composeId }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const node = useRef<HTMLDivElement>(null);
|
||||||
|
const focusedItem = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
|
||||||
|
const arrowRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { x, y, strategy, refs, middlewareData, placement } = useFloating<HTMLButtonElement>({
|
||||||
|
placement: 'top',
|
||||||
|
middleware: [
|
||||||
|
offset(12),
|
||||||
|
flip(),
|
||||||
|
shift({
|
||||||
|
padding: 8,
|
||||||
|
}),
|
||||||
|
arrow({
|
||||||
|
element: arrowRef,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const language = useCompose(composeId).language;
|
||||||
|
|
||||||
|
const handleClick: React.EventHandler<
|
||||||
|
React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>
|
||||||
|
> = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (event) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case ' ':
|
||||||
|
case 'Enter':
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
handleClick(event);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (language: Language | null) => dispatch(changeComposeLanguage(composeId, language));
|
||||||
|
|
||||||
|
const handleOptionKeyDown: React.KeyboardEventHandler = e => {
|
||||||
|
const value = e.currentTarget.getAttribute('data-index');
|
||||||
|
const index = results.findIndex(([key]) => key === value);
|
||||||
|
let element: ChildNode | null | undefined = null;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
setIsOpen(false);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
handleOptionClick(e);
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
element = node.current?.childNodes[index + 1] || node.current?.firstChild;
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
element = node.current?.childNodes[index - 1] || node.current?.lastChild;
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
element = node.current?.firstChild;
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
element = node.current?.lastChild;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
(element as HTMLElement).focus();
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOptionClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => {
|
||||||
|
const value = (e.currentTarget as HTMLElement)?.getAttribute('data-index') as Language;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
handleChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear: React.MouseEventHandler = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setSearchValue('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const search = () => {
|
||||||
|
if (searchValue === '') {
|
||||||
|
return [...languages].sort((a, b) => {
|
||||||
|
// Push current selection to the top of the list
|
||||||
|
|
||||||
|
if (a[0] === language) {
|
||||||
|
return -1;
|
||||||
|
} else if (b[0] === language) {
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return fuzzysort.go(searchValue, languages, {
|
||||||
|
keys: ['0', '1'],
|
||||||
|
limit: 5,
|
||||||
|
threshold: -10000,
|
||||||
|
}).map(result => result.obj);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocumentClick = (event: Event) => {
|
||||||
|
if (refs.floating.current && !refs.floating.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!refs.floating.current) return;
|
||||||
|
|
||||||
|
const items = Array.from(refs.floating.current.getElementsByTagName('a'));
|
||||||
|
const index = items.indexOf(document.activeElement as any);
|
||||||
|
|
||||||
|
let element = null;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
element = items[index + 1] || items[0];
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
element = items[index - 1] || items[items.length - 1];
|
||||||
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
if (e.shiftKey) {
|
||||||
|
element = items[index - 1] || items[items.length - 1];
|
||||||
|
} else {
|
||||||
|
element = items[index + 1] || items[0];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
element = items[0];
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
element = items[items.length - 1];
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
setIsOpen(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const arrowProps: React.CSSProperties = useMemo(() => {
|
||||||
|
if (middlewareData.arrow) {
|
||||||
|
const { x, y } = middlewareData.arrow;
|
||||||
|
|
||||||
|
const staticPlacement = {
|
||||||
|
top: 'bottom',
|
||||||
|
right: 'left',
|
||||||
|
bottom: 'top',
|
||||||
|
left: 'right',
|
||||||
|
}[placement.split('-')[0]];
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: x !== null ? `${x}px` : '',
|
||||||
|
top: y !== null ? `${y}px` : '',
|
||||||
|
// Ensure the static side gets unset when
|
||||||
|
// flipping to other placements' axes.
|
||||||
|
right: '',
|
||||||
|
bottom: '',
|
||||||
|
[staticPlacement as string]: `${(-(arrowRef.current?.offsetWidth || 0)) / 2}px`,
|
||||||
|
transform: 'rotate(45deg)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}, [middlewareData.arrow, placement]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (refs.floating.current) {
|
||||||
|
(refs.floating.current?.querySelector('li a[role=\'button\']') as HTMLAnchorElement)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', handleDocumentClick, false);
|
||||||
|
document.addEventListener('keydown', handleKeyDown, false);
|
||||||
|
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleDocumentClick);
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
document.removeEventListener('touchend', handleDocumentClick);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isOpen, refs.floating.current]);
|
||||||
|
|
||||||
|
const isSearching = searchValue !== '';
|
||||||
|
const results = search();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
theme='muted'
|
||||||
|
size='xs'
|
||||||
|
text={language ? languagesObject[language] : intl.formatMessage(messages.languagePrompt)}
|
||||||
|
icon={require('@tabler/icons/outline/language.svg')}
|
||||||
|
secondaryIcon={require('@tabler/icons/outline/chevron-down.svg')}
|
||||||
|
title={intl.formatMessage(messages.languagePrompt)}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
ref={refs.setReference}
|
||||||
|
/>
|
||||||
|
{isOpen ? (
|
||||||
|
<Portal>
|
||||||
|
<div
|
||||||
|
id='language-dropdown'
|
||||||
|
ref={refs.setFloating}
|
||||||
|
className={clsx('z-[1001] flex flex-col rounded-md bg-white text-sm shadow-lg transition-opacity duration-100 focus:outline-none black:border black:border-gray-800 black:bg-black dark:bg-gray-900 dark:ring-2 dark:ring-primary-700', {
|
||||||
|
'opacity-0 pointer-events-none': !isOpen,
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
position: strategy,
|
||||||
|
top: y ?? 0,
|
||||||
|
left: x ?? 0,
|
||||||
|
}}
|
||||||
|
role='listbox'
|
||||||
|
>
|
||||||
|
<label className='relative grow p-2'>
|
||||||
|
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
className='w-64'
|
||||||
|
type='text'
|
||||||
|
value={searchValue}
|
||||||
|
onChange={({ target }) => setSearchValue(target.value)}
|
||||||
|
outerClassName='mt-0'
|
||||||
|
placeholder={intl.formatMessage(messages.search)}
|
||||||
|
/>
|
||||||
|
<div role='button' tabIndex={0} className='absolute inset-y-0 right-0 flex cursor-pointer items-center px-5 rtl:left-0 rtl:right-auto' onClick={handleClear}>
|
||||||
|
<Icon
|
||||||
|
className='h-5 w-5 text-gray-600'
|
||||||
|
src={isSearching ? require('@tabler/icons/outline/backspace.svg') : require('@tabler/icons/outline/search.svg')}
|
||||||
|
aria-label={intl.formatMessage(messages.search)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div className='h-96 w-full overflow-scroll' ref={node} tabIndex={-1}>
|
||||||
|
{results.map(([code, name]) => {
|
||||||
|
const active = code === language;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role='option'
|
||||||
|
tabIndex={0}
|
||||||
|
key={code}
|
||||||
|
data-index={code}
|
||||||
|
onKeyDown={handleOptionKeyDown}
|
||||||
|
onClick={handleOptionClick}
|
||||||
|
className={clsx(
|
||||||
|
'flex cursor-pointer p-2.5 text-sm text-gray-700 hover:bg-gray-100 black:hover:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800',
|
||||||
|
{ 'bg-gray-100 dark:bg-gray-800 black:bg-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700': active },
|
||||||
|
)}
|
||||||
|
aria-selected={active}
|
||||||
|
ref={active ? focusedItem : null}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx('flex-auto text-primary-600 dark:text-primary-400', {
|
||||||
|
'text-black dark:text-white': active,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={arrowRef}
|
||||||
|
style={arrowProps}
|
||||||
|
className='pointer-events-none absolute z-[-1] h-3 w-3 bg-white black:bg-black dark:bg-gray-900'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export { LanguageDropdown as default };
|
|
@ -73,7 +73,9 @@ const languages = {
|
||||||
'zh-CN': '简体中文',
|
'zh-CN': '简体中文',
|
||||||
'zh-HK': '繁體中文(香港)',
|
'zh-HK': '繁體中文(香港)',
|
||||||
'zh-TW': '繁體中文(臺灣)',
|
'zh-TW': '繁體中文(臺灣)',
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
|
type Language = keyof typeof languages;
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'column.preferences', defaultMessage: 'Preferences' },
|
heading: { id: 'column.preferences', defaultMessage: 'Preferences' },
|
||||||
|
@ -242,4 +244,4 @@ const Preferences = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { Preferences as default, languages };
|
export { Preferences as default, languages, type Language };
|
||||||
|
|
|
@ -414,6 +414,7 @@
|
||||||
"compose.character_counter.title": "Used {chars} out of {maxChars} {maxChars, plural, one {character} other {characters}}",
|
"compose.character_counter.title": "Used {chars} out of {maxChars} {maxChars, plural, one {character} other {characters}}",
|
||||||
"compose.edit_success": "Your post was edited",
|
"compose.edit_success": "Your post was edited",
|
||||||
"compose.invalid_schedule": "You must schedule a post at least 5 minutes out.",
|
"compose.invalid_schedule": "You must schedule a post at least 5 minutes out.",
|
||||||
|
"compose.language_dropdown.prompt": "Select language",
|
||||||
"compose.reply_group_indicator.message": "Posting to {groupLink}",
|
"compose.reply_group_indicator.message": "Posting to {groupLink}",
|
||||||
"compose.submit_success": "Your post was sent!",
|
"compose.submit_success": "Your post was sent!",
|
||||||
"compose_event.create": "Create",
|
"compose_event.create": "Create",
|
||||||
|
|
|
@ -33,6 +33,7 @@ import {
|
||||||
COMPOSE_TYPE_CHANGE,
|
COMPOSE_TYPE_CHANGE,
|
||||||
COMPOSE_SPOILER_TEXT_CHANGE,
|
COMPOSE_SPOILER_TEXT_CHANGE,
|
||||||
COMPOSE_VISIBILITY_CHANGE,
|
COMPOSE_VISIBILITY_CHANGE,
|
||||||
|
COMPOSE_LANGUAGE_CHANGE,
|
||||||
COMPOSE_EMOJI_INSERT,
|
COMPOSE_EMOJI_INSERT,
|
||||||
COMPOSE_UPLOAD_CHANGE_REQUEST,
|
COMPOSE_UPLOAD_CHANGE_REQUEST,
|
||||||
COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
||||||
|
@ -52,9 +53,9 @@ import {
|
||||||
COMPOSE_SET_STATUS,
|
COMPOSE_SET_STATUS,
|
||||||
COMPOSE_EVENT_REPLY,
|
COMPOSE_EVENT_REPLY,
|
||||||
COMPOSE_EDITOR_STATE_SET,
|
COMPOSE_EDITOR_STATE_SET,
|
||||||
ComposeAction,
|
|
||||||
COMPOSE_CHANGE_MEDIA_ORDER,
|
COMPOSE_CHANGE_MEDIA_ORDER,
|
||||||
COMPOSE_ADD_SUGGESTED_QUOTE,
|
COMPOSE_ADD_SUGGESTED_QUOTE,
|
||||||
|
ComposeAction,
|
||||||
} from '../actions/compose';
|
} from '../actions/compose';
|
||||||
import { EVENT_COMPOSE_CANCEL, EVENT_FORM_SET, type EventsAction } from '../actions/events';
|
import { EVENT_COMPOSE_CANCEL, EVENT_FORM_SET, type EventsAction } from '../actions/events';
|
||||||
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS, MeAction } from '../actions/me';
|
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS, MeAction } from '../actions/me';
|
||||||
|
@ -64,6 +65,7 @@ import { normalizeAttachment } from '../normalizers/attachment';
|
||||||
import { unescapeHTML } from '../utils/html';
|
import { unescapeHTML } from '../utils/html';
|
||||||
|
|
||||||
import type { Emoji } from 'soapbox/features/emoji';
|
import type { Emoji } from 'soapbox/features/emoji';
|
||||||
|
import type { Language } from 'soapbox/features/preferences';
|
||||||
import type {
|
import type {
|
||||||
APIEntity,
|
APIEntity,
|
||||||
Attachment as AttachmentEntity,
|
Attachment as AttachmentEntity,
|
||||||
|
@ -111,6 +113,7 @@ const ReducerCompose = ImmutableRecord({
|
||||||
to: ImmutableOrderedSet<string>(),
|
to: ImmutableOrderedSet<string>(),
|
||||||
parent_reblogged_by: null as string | null,
|
parent_reblogged_by: null as string | null,
|
||||||
dismissed_quotes: ImmutableOrderedSet<string>(),
|
dismissed_quotes: ImmutableOrderedSet<string>(),
|
||||||
|
language: null as Language | null,
|
||||||
});
|
});
|
||||||
|
|
||||||
type State = ImmutableMap<string, Compose>;
|
type State = ImmutableMap<string, Compose>;
|
||||||
|
@ -301,6 +304,11 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | Me
|
||||||
return updateCompose(state, action.id, compose => compose
|
return updateCompose(state, action.id, compose => compose
|
||||||
.set('privacy', action.value)
|
.set('privacy', action.value)
|
||||||
.set('idempotencyKey', uuid()));
|
.set('idempotencyKey', uuid()));
|
||||||
|
case COMPOSE_LANGUAGE_CHANGE:
|
||||||
|
return updateCompose(state, action.id, compose => compose.withMutations(map => {
|
||||||
|
map.set('language', action.value);
|
||||||
|
map.set('idempotencyKey', uuid());
|
||||||
|
}));
|
||||||
case COMPOSE_CHANGE:
|
case COMPOSE_CHANGE:
|
||||||
return updateCompose(state, action.id, compose => compose
|
return updateCompose(state, action.id, compose => compose
|
||||||
.set('text', action.text)
|
.set('text', action.text)
|
||||||
|
|
|
@ -4953,6 +4953,11 @@ functions-have-names@^1.2.3:
|
||||||
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
|
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
|
||||||
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
|
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
|
||||||
|
|
||||||
|
fuzzysort@^3.0.0:
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/fuzzysort/-/fuzzysort-3.0.0.tgz#3dbc8fb712a506b8aa5ebefc7bc0e5e733e5040a"
|
||||||
|
integrity sha512-FIq+LaBRMt4aybcsNpFVWSsMNZOY9LbCRyy559+vvk6Ckgl4LptjpgTE7pgwTMQ+K1Q6u6LyFmJ1N0qUJIjp+Q==
|
||||||
|
|
||||||
gensync@^1.0.0-beta.2:
|
gensync@^1.0.0-beta.2:
|
||||||
version "1.0.0-beta.2"
|
version "1.0.0-beta.2"
|
||||||
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||||
|
|
Loading…
Reference in a new issue