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:
marcin mikołajczak 2024-05-15 23:47:11 +02:00
parent 4ceef0fe81
commit 2e43f8967f
11 changed files with 444 additions and 15 deletions

View file

@ -113,6 +113,7 @@
"escape-html": "^1.0.3",
"eslint-plugin-formatjs": "^4.12.2",
"exifr": "^7.1.3",
"fuzzysort": "^3.0.0",
"graphemesplit": "^2.4.4",
"html-react-parser": "^5.0.0",
"http-link-header": "^1.0.2",

View file

@ -5,6 +5,7 @@ import { defineMessages, IntlShape } from 'react-intl';
import api from 'soapbox/api';
import { isNativeEmoji } from 'soapbox/features/emoji';
import emojiSearch from 'soapbox/features/emoji/search';
import { Language } from 'soapbox/features/preferences';
import { normalizeTag } from 'soapbox/normalizers';
import { selectAccount, selectOwnAccount, makeGetAccount } from 'soapbox/selectors';
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_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_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_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT' as const;
@ -372,6 +374,7 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) =>
content_type: compose.content_type,
poll: compose.poll,
scheduled_at: compose.schedule,
language: compose.language,
to,
};
@ -725,6 +728,12 @@ const changeComposeVisibility = (composeId: string, value: string) => ({
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) => ({
type: COMPOSE_EMOJI_INSERT,
id: composeId,
@ -908,6 +917,7 @@ type ComposeAction =
| ReturnType<typeof changeComposeContentType>
| ReturnType<typeof changeComposeSpoilerText>
| ReturnType<typeof changeComposeVisibility>
| ReturnType<typeof changeComposeLanguage>
| ReturnType<typeof insertEmojiCompose>
| ReturnType<typeof addPoll>
| ReturnType<typeof removePoll>
@ -953,6 +963,7 @@ export {
COMPOSE_TYPE_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE,
COMPOSE_LANGUAGE_CHANGE,
COMPOSE_LISTABILITY_CHANGE,
COMPOSE_EMOJI_INSERT,
COMPOSE_UPLOAD_CHANGE_REQUEST,
@ -1012,6 +1023,7 @@ export {
changeComposeContentType,
changeComposeSpoilerText,
changeComposeVisibility,
changeComposeLanguage,
insertEmojiCompose,
addPoll,
removePoll,

View file

@ -10,7 +10,7 @@ import type { ButtonSizes, ButtonThemes } from './useButtonStyles';
interface IButton extends Pick<
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. */
block?: boolean;

View file

@ -18,17 +18,17 @@ const alignItemsOptions = {
};
const spaces = {
0: 'gap-x-0',
[0.5]: 'gap-x-0.5',
1: 'gap-x-1',
1.5: 'gap-x-1.5',
2: 'gap-x-2',
2.5: 'gap-x-2.5',
3: 'gap-x-3',
4: 'gap-x-4',
5: 'gap-x-5',
6: 'gap-x-6',
8: 'gap-x-8',
0: 'gap-0',
[0.5]: 'gap-0.5',
1: 'gap-1',
1.5: 'gap-1.5',
2: 'gap-2',
2.5: 'gap-2.5',
3: 'gap-3',
4: 'gap-4',
5: 'gap-5',
6: 'gap-6',
8: 'gap-8',
};
interface IHStack extends Pick<React.HTMLAttributes<HTMLDivElement>, 'children' | 'className' | 'onClick' | 'style' | 'title'> {

View file

@ -27,6 +27,7 @@ import { $createEmojiNode } from '../editor/nodes/emoji-node';
import { countableText } from '../util/counter';
import ContentTypeButton from './content-type-button';
import LanguageDropdown from './language-dropdown';
import PollButton from './poll-button';
import PollForm from './polls/poll-form';
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.richText) selectButtons.push(<ContentTypeButton composeId={id} />);
selectButtons.push(<LanguageDropdown composeId={id} />);
return (
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>

View 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 };

View 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 };

View file

@ -73,7 +73,9 @@ const languages = {
'zh-CN': '简体中文',
'zh-HK': '繁體中文(香港)',
'zh-TW': '繁體中文(臺灣)',
};
} as const;
type Language = keyof typeof languages;
const messages = defineMessages({
heading: { id: 'column.preferences', defaultMessage: 'Preferences' },
@ -242,4 +244,4 @@ const Preferences = () => {
);
};
export { Preferences as default, languages };
export { Preferences as default, languages, type Language };

View file

@ -414,6 +414,7 @@
"compose.character_counter.title": "Used {chars} out of {maxChars} {maxChars, plural, one {character} other {characters}}",
"compose.edit_success": "Your post was edited",
"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.submit_success": "Your post was sent!",
"compose_event.create": "Create",

View file

@ -33,6 +33,7 @@ import {
COMPOSE_TYPE_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE,
COMPOSE_LANGUAGE_CHANGE,
COMPOSE_EMOJI_INSERT,
COMPOSE_UPLOAD_CHANGE_REQUEST,
COMPOSE_UPLOAD_CHANGE_SUCCESS,
@ -52,9 +53,9 @@ import {
COMPOSE_SET_STATUS,
COMPOSE_EVENT_REPLY,
COMPOSE_EDITOR_STATE_SET,
ComposeAction,
COMPOSE_CHANGE_MEDIA_ORDER,
COMPOSE_ADD_SUGGESTED_QUOTE,
ComposeAction,
} from '../actions/compose';
import { EVENT_COMPOSE_CANCEL, EVENT_FORM_SET, type EventsAction } from '../actions/events';
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 type { Emoji } from 'soapbox/features/emoji';
import type { Language } from 'soapbox/features/preferences';
import type {
APIEntity,
Attachment as AttachmentEntity,
@ -111,6 +113,7 @@ const ReducerCompose = ImmutableRecord({
to: ImmutableOrderedSet<string>(),
parent_reblogged_by: null as string | null,
dismissed_quotes: ImmutableOrderedSet<string>(),
language: null as Language | null,
});
type State = ImmutableMap<string, Compose>;
@ -301,6 +304,11 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | Me
return updateCompose(state, action.id, compose => compose
.set('privacy', action.value)
.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:
return updateCompose(state, action.id, compose => compose
.set('text', action.text)

View file

@ -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"
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:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"