@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useAccount } from 'soapbox/api/hooks';
|
||||
import Account from 'soapbox/components/account';
|
||||
|
||||
interface IAutosuggestAccount {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const AutosuggestAccount: React.FC<IAutosuggestAccount> = ({ id }) => {
|
||||
const { account } = useAccount(id);
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<div className='snap-start snap-always'>
|
||||
<Account account={account} hideActions showProfileHoverCard={false} />
|
||||
</div>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export { AutosuggestAccount as default };
|
||||
@@ -0,0 +1,37 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { IconButton } from 'soapbox/components/ui';
|
||||
|
||||
interface IComposeFormButton {
|
||||
icon: string;
|
||||
title?: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ComposeFormButton: React.FC<IComposeFormButton> = ({
|
||||
icon,
|
||||
title,
|
||||
active,
|
||||
disabled,
|
||||
onClick,
|
||||
}) => (
|
||||
<div>
|
||||
<IconButton
|
||||
className={
|
||||
clsx({
|
||||
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': !active,
|
||||
'text-primary-500 hover:text-primary-600 dark:text-primary-500 dark:hover:text-primary-400': active,
|
||||
})
|
||||
}
|
||||
src={icon}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export { ComposeFormButton as default };
|
||||
292
packages/pl-fe/src/features/compose/components/compose-form.tsx
Normal file
292
packages/pl-fe/src/features/compose/components/compose-form.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import clsx from 'clsx';
|
||||
import { CLEAR_EDITOR_COMMAND, TextNode, type LexicalEditor } from 'lexical';
|
||||
import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { length } from 'stringz';
|
||||
|
||||
import {
|
||||
submitCompose,
|
||||
clearComposeSuggestions,
|
||||
fetchComposeSuggestions,
|
||||
selectComposeSuggestion,
|
||||
uploadCompose,
|
||||
} from 'soapbox/actions/compose';
|
||||
import { Button, HStack, Stack } from 'soapbox/components/ui';
|
||||
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
|
||||
import { ComposeEditor } from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppDispatch, useAppSelector, useCompose, useDraggedFiles, useFeatures, useInstance } from 'soapbox/hooks';
|
||||
|
||||
import QuotedStatusContainer from '../containers/quoted-status-container';
|
||||
import ReplyIndicatorContainer from '../containers/reply-indicator-container';
|
||||
import UploadButtonContainer from '../containers/upload-button-container';
|
||||
import WarningContainer from '../containers/warning-container';
|
||||
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';
|
||||
import ReplyGroupIndicator from './reply-group-indicator';
|
||||
import ReplyMentions from './reply-mentions';
|
||||
import ScheduleButton from './schedule-button';
|
||||
import ScheduleForm from './schedule-form';
|
||||
import SpoilerButton from './spoiler-button';
|
||||
import SpoilerInput from './spoiler-input';
|
||||
import TextCharacterCounter from './text-character-counter';
|
||||
import UploadForm from './upload-form';
|
||||
import VisualCharacterCounter from './visual-character-counter';
|
||||
|
||||
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
||||
import type { Emoji } from 'soapbox/features/emoji';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' },
|
||||
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic…' },
|
||||
eventPlaceholder: { id: 'compose_form.event_placeholder', defaultMessage: 'Post to this event' },
|
||||
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
|
||||
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
|
||||
message: { id: 'compose_form.message', defaultMessage: 'Message' },
|
||||
schedule: { id: 'compose_form.schedule', defaultMessage: 'Schedule' },
|
||||
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
|
||||
});
|
||||
|
||||
interface IComposeForm<ID extends string> {
|
||||
id: ID extends 'default' ? never : ID;
|
||||
shouldCondense?: boolean;
|
||||
autoFocus?: boolean;
|
||||
clickableAreaRef?: React.RefObject<HTMLDivElement>;
|
||||
event?: string;
|
||||
group?: string;
|
||||
withAvatar?: boolean;
|
||||
transparent?: boolean;
|
||||
}
|
||||
|
||||
const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickableAreaRef, event, group, withAvatar, transparent }: IComposeForm<ID>) => {
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const { configuration } = useInstance();
|
||||
|
||||
const compose = useCompose(id);
|
||||
const showSearch = useAppSelector((state) => state.search.submitted && !state.search.hidden);
|
||||
const maxTootChars = configuration.statuses.max_characters;
|
||||
const features = useFeatures();
|
||||
|
||||
const {
|
||||
spoiler_text: spoilerText,
|
||||
privacy,
|
||||
is_submitting: isSubmitting,
|
||||
is_changing_upload:
|
||||
isChangingUpload,
|
||||
is_uploading: isUploading,
|
||||
schedule: scheduledAt,
|
||||
group_id: groupId,
|
||||
text,
|
||||
modified_language: modifiedLanguage,
|
||||
} = compose;
|
||||
|
||||
const hasPoll = !!compose.poll;
|
||||
const isEditing = compose.id !== null;
|
||||
const anyMedia = compose.media_attachments.size > 0;
|
||||
|
||||
const [composeFocused, setComposeFocused] = useState(false);
|
||||
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<LexicalEditor>(null);
|
||||
|
||||
const { isDraggedOver } = useDraggedFiles(formRef);
|
||||
|
||||
const fulltext = [spoilerText, countableText(text)].join('');
|
||||
|
||||
const isEmpty = !(fulltext.trim() || anyMedia);
|
||||
const condensed = shouldCondense && !isDraggedOver && !composeFocused && isEmpty && !isUploading;
|
||||
const shouldAutoFocus = autoFocus && !showSearch;
|
||||
const canSubmit = !!editorRef.current && !isSubmitting && !isUploading && !isChangingUpload && !isEmpty && length(fulltext) <= maxTootChars;
|
||||
|
||||
const getClickableArea = () => clickableAreaRef ? clickableAreaRef.current : formRef.current;
|
||||
|
||||
const isClickOutside = (e: MouseEvent | React.MouseEvent) => ![
|
||||
// List of elements that shouldn't collapse the composer when clicked
|
||||
// FIXME: Make this less brittle
|
||||
getClickableArea(),
|
||||
document.getElementById('privacy-dropdown'),
|
||||
document.querySelector('em-emoji-picker'),
|
||||
document.getElementById('modal-overlay'),
|
||||
].some(element => element?.contains(e.target as any));
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent | React.MouseEvent) => {
|
||||
if (isEmpty && isClickOutside(e)) {
|
||||
handleClickOutside();
|
||||
}
|
||||
}, [isEmpty]);
|
||||
|
||||
const handleClickOutside = () => {
|
||||
setComposeFocused(false);
|
||||
};
|
||||
|
||||
const handleComposeFocus = () => {
|
||||
setComposeFocused(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e?: React.FormEvent<Element>) => {
|
||||
if (!canSubmit) return;
|
||||
e?.preventDefault();
|
||||
|
||||
dispatch(submitCompose(id, { history })).then(() => {
|
||||
editorRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const onSuggestionsClearRequested = () => {
|
||||
dispatch(clearComposeSuggestions(id));
|
||||
};
|
||||
|
||||
const onSuggestionsFetchRequested = (token: string | number) => {
|
||||
dispatch(fetchComposeSuggestions(id, token as string));
|
||||
};
|
||||
|
||||
const onSpoilerSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => {
|
||||
dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['spoiler_text']));
|
||||
};
|
||||
|
||||
const handleEmojiPick = (data: Emoji) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
editor.update(() => {
|
||||
editor.getEditorState()._selection?.insertNodes([$createEmojiNode(data), new TextNode(' ')]);
|
||||
});
|
||||
};
|
||||
|
||||
const onPaste = (files: FileList) => {
|
||||
dispatch(uploadCompose(id, files, intl));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleClick, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderButtons = useCallback(() => (
|
||||
<HStack alignItems='center' space={2}>
|
||||
<UploadButtonContainer composeId={id} />
|
||||
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} condensed={shouldCondense} />
|
||||
{features.polls && <PollButton composeId={id} />}
|
||||
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
|
||||
{features.spoilers && <SpoilerButton composeId={id} />}
|
||||
</HStack>
|
||||
), [features, id]);
|
||||
|
||||
const composeModifiers = !condensed && (
|
||||
<Stack space={4} className='font-[inherit] text-sm text-gray-900'>
|
||||
<UploadForm composeId={id} onSubmit={handleSubmit} />
|
||||
<PollForm composeId={id} />
|
||||
|
||||
<ScheduleForm composeId={id} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
let publishText: string | JSX.Element = '';
|
||||
let publishIcon: string | undefined = undefined;
|
||||
|
||||
if (isEditing) {
|
||||
publishText = intl.formatMessage(messages.saveChanges);
|
||||
} else if (privacy === 'direct') {
|
||||
publishIcon = require('@tabler/icons/outline/mail.svg');
|
||||
publishText = intl.formatMessage(messages.message);
|
||||
} else if (privacy === 'private' || privacy === 'mutuals_only') {
|
||||
publishIcon = require('@tabler/icons/outline/lock.svg');
|
||||
publishText = intl.formatMessage(messages.publish);
|
||||
} else {
|
||||
publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
|
||||
}
|
||||
|
||||
if (scheduledAt) {
|
||||
publishText = intl.formatMessage(messages.schedule);
|
||||
}
|
||||
|
||||
const selectButtons = [];
|
||||
|
||||
if (features.privacyScopes && !group && !groupId) selectButtons.push(<PrivacyDropdown key='privacy-dropdown' composeId={id} />);
|
||||
if (features.richText) selectButtons.push(<ContentTypeButton key='compose-type-button' composeId={id} />);
|
||||
if (features.postLanguages) selectButtons.push(<LanguageDropdown key='language-dropdown' composeId={id} />);
|
||||
|
||||
return (
|
||||
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
|
||||
<WarningContainer composeId={id} />
|
||||
|
||||
{!shouldCondense && !event && !group && groupId && <ReplyGroupIndicator composeId={id} />}
|
||||
|
||||
{!shouldCondense && !event && !group && <ReplyIndicatorContainer composeId={id} />}
|
||||
|
||||
{!shouldCondense && !event && !group && <ReplyMentions composeId={id} />}
|
||||
|
||||
{!!selectButtons && (
|
||||
<HStack space={2} wrap className='-mb-2'>
|
||||
{selectButtons}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<SpoilerInput
|
||||
composeId={id}
|
||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
onSuggestionSelected={onSpoilerSuggestionSelected}
|
||||
theme={transparent ? 'transparent' : 'normal'}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Suspense>
|
||||
<ComposeEditor
|
||||
key={modifiedLanguage}
|
||||
ref={editorRef}
|
||||
className={transparent
|
||||
? ''
|
||||
: 'rounded-md border-gray-400 px-3 py-2 ring-2 focus-within:border-primary-500 focus-within:ring-primary-500 dark:border-gray-800 dark:ring-gray-800 dark:focus-within:border-primary-500 dark:focus-within:ring-primary-500'}
|
||||
placeholderClassName={transparent ? '' : 'pt-2'}
|
||||
composeId={id}
|
||||
condensed={condensed}
|
||||
eventDiscussion={!!event}
|
||||
autoFocus={shouldAutoFocus}
|
||||
hasPoll={hasPoll}
|
||||
handleSubmit={handleSubmit}
|
||||
onFocus={handleComposeFocus}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{composeModifiers}
|
||||
|
||||
<QuotedStatusContainer composeId={id} />
|
||||
|
||||
<div
|
||||
className={clsx('flex flex-wrap items-center justify-between', {
|
||||
'hidden': condensed,
|
||||
'ml-[-56px] sm:ml-0': withAvatar,
|
||||
})}
|
||||
>
|
||||
{renderButtons()}
|
||||
|
||||
<HStack space={4} alignItems='center' className='ml-auto rtl:ml-0 rtl:mr-auto'>
|
||||
{maxTootChars && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<TextCharacterCounter max={maxTootChars} text={text} />
|
||||
<VisualCharacterCounter max={maxTootChars} text={text} />
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<Button type='submit' theme='primary' icon={publishIcon} text={publishText} disabled={!canSubmit} />
|
||||
</HStack>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { ComposeForm as default };
|
||||
@@ -0,0 +1,80 @@
|
||||
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, useInstance } 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' },
|
||||
content_type_wysiwyg: { id: 'preferences.options.content_type_wysiwyg', defaultMessage: 'WYSIWYG' },
|
||||
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 instance = useInstance();
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
if (instance.pleroma.metadata.post_formats?.includes('text/html')) {
|
||||
options.push({
|
||||
icon: require('@tabler/icons/outline/html.svg'),
|
||||
text: intl.formatMessage(messages.content_type_html),
|
||||
value: 'text/html',
|
||||
});
|
||||
}
|
||||
|
||||
options.push({
|
||||
icon: require('@tabler/icons/outline/text-caption.svg'),
|
||||
text: intl.formatMessage(messages.content_type_wysiwyg),
|
||||
value: 'wysiwyg',
|
||||
});
|
||||
|
||||
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 };
|
||||
@@ -0,0 +1,260 @@
|
||||
import clsx from 'clsx';
|
||||
import fuzzysort from 'fuzzysort';
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { addComposeLanguage, changeComposeLanguage, changeComposeModifiedLanguage, deleteComposeLanguage } from 'soapbox/actions/compose';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import { Button, Icon, Input } from 'soapbox/components/ui';
|
||||
import { type Language, languages as languagesObject } from 'soapbox/features/preferences';
|
||||
import { useAppDispatch, useAppSelector, useCompose, useFeatures } from 'soapbox/hooks';
|
||||
|
||||
const getFrequentlyUsedLanguages = createSelector([
|
||||
state => state.settings.get('frequentlyUsedLanguages', ImmutableMap()),
|
||||
], (languageCounters: ImmutableMap<Language, number>) => (
|
||||
languageCounters.keySeq()
|
||||
.sort((a, b) => languageCounters.get(a, 0) - languageCounters.get(b, 0))
|
||||
.reverse()
|
||||
.toArray()
|
||||
));
|
||||
|
||||
const languages = Object.entries(languagesObject) as Array<[Language, string]>;
|
||||
|
||||
const messages = defineMessages({
|
||||
languagePrompt: { id: 'compose.language_dropdown.prompt', defaultMessage: 'Select language' },
|
||||
languageSuggestion: { id: 'compose.language_dropdown.suggestion', defaultMessage: '{language} (detected)' },
|
||||
multipleLanguages: { id: 'compose.language_dropdown.more_languages', defaultMessage: '{count, plural, one {# more language} other {# more languages}}' },
|
||||
search: { id: 'compose.language_dropdown.search', defaultMessage: 'Search language…' },
|
||||
addLanguage: { id: 'compose.language_dropdown.add_language', defaultMessage: 'Add language' },
|
||||
deleteLanguage: { id: 'compose.language_dropdown.delete_language', defaultMessage: 'Delete language' },
|
||||
});
|
||||
|
||||
interface ILanguageDropdown {
|
||||
handleClose: () => any;
|
||||
}
|
||||
|
||||
const getLanguageDropdown = (composeId: string): React.FC<ILanguageDropdown> => ({ handleClose: handleMenuClose }) => {
|
||||
const intl = useIntl();
|
||||
const features = useFeatures();
|
||||
const dispatch = useAppDispatch();
|
||||
const frequentlyUsedLanguages = useAppSelector(getFrequentlyUsedLanguages);
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
const focusedItem = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const {
|
||||
language,
|
||||
modified_language: modifiedLanguage,
|
||||
textMap,
|
||||
} = useCompose(composeId);
|
||||
|
||||
const handleOptionClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => {
|
||||
const value = (e.currentTarget as HTMLElement)?.getAttribute('data-index') as Language;
|
||||
|
||||
if (textMap.size) {
|
||||
if (!(textMap.has(value) || language === value)) return;
|
||||
|
||||
dispatch(changeComposeModifiedLanguage(composeId, value));
|
||||
} else {
|
||||
dispatch(changeComposeLanguage(composeId, value));
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleAddLanguageClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => {
|
||||
const value = (e.currentTarget as HTMLElement)?.parentElement?.getAttribute('data-index') as Language;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(addComposeLanguage(composeId, value));
|
||||
};
|
||||
|
||||
const handleDeleteLanguageClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => {
|
||||
const value = (e.currentTarget as HTMLElement)?.parentElement?.getAttribute('data-index') as Language;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(deleteComposeLanguage(composeId, 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 (textMap.has(a[0])) {
|
||||
if (b[0] === language) return 1;
|
||||
return -1;
|
||||
}
|
||||
if (textMap.has(b[0])) {
|
||||
if (a[0] === language) return -1;
|
||||
return 1;
|
||||
}
|
||||
if (a[0] === language) {
|
||||
return -1;
|
||||
} else if (b[0] === language) {
|
||||
return 1;
|
||||
} else {
|
||||
// Sort according to frequently used languages
|
||||
|
||||
const indexOfA = frequentlyUsedLanguages.indexOf(a[0]);
|
||||
const indexOfB = frequentlyUsedLanguages.indexOf(b[0]);
|
||||
|
||||
return ((indexOfA > -1 ? indexOfA : Infinity) - (indexOfB > -1 ? indexOfB : Infinity));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return fuzzysort.go(searchValue, languages, {
|
||||
keys: ['0', '1'],
|
||||
limit: 5,
|
||||
threshold: -10000,
|
||||
}).map(result => result.obj);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSearchValue('');
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (node.current) {
|
||||
(node.current?.querySelector('div[aria-selected=true]') as HTMLDivElement)?.focus();
|
||||
}
|
||||
}, [node.current]);
|
||||
|
||||
const isSearching = searchValue !== '';
|
||||
const results = search();
|
||||
|
||||
return (
|
||||
<>
|
||||
<label className='relative block grow p-2 pt-1'>
|
||||
<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='-mb-1 h-96 w-full overflow-auto' ref={node} tabIndex={-1}>
|
||||
{results.map(([code, name]) => {
|
||||
const active = code === language;
|
||||
const modified = code === modifiedLanguage;
|
||||
|
||||
return (
|
||||
<button
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
key={code}
|
||||
data-index={code}
|
||||
onClick={handleOptionClick}
|
||||
className={clsx(
|
||||
'flex w-full gap-2 p-2.5 text-left text-sm text-gray-700 dark:text-gray-400',
|
||||
{
|
||||
'bg-gray-100 dark:bg-gray-800 black:bg-gray-900 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700': modified,
|
||||
'cursor-pointer hover:bg-gray-100 black:hover:bg-gray-900 dark:hover:bg-gray-800': !textMap.size || textMap.has(code),
|
||||
'cursor-pointer': active,
|
||||
'cursor-default': !active && !(!textMap.size || textMap.has(code)),
|
||||
},
|
||||
)}
|
||||
aria-selected={active}
|
||||
ref={active ? focusedItem : null}
|
||||
>
|
||||
<div
|
||||
className={clsx('flex-auto grow text-primary-600 dark:text-primary-400', {
|
||||
'text-black dark:text-white': modified,
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
{features.multiLanguage && !!language && !active && (
|
||||
textMap.has(code) ? (
|
||||
<button title={intl.formatMessage(messages.deleteLanguage)} onClick={handleDeleteLanguageClick}>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/outline/minus.svg')} />
|
||||
</button>
|
||||
) : (
|
||||
<button title={intl.formatMessage(messages.addLanguage)} onClick={handleAddLanguageClick}>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/outline/plus.svg')} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ILanguageDropdownButton {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
const LanguageDropdownButton: React.FC<ILanguageDropdownButton> = ({ composeId }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
language,
|
||||
modified_language: modifiedLanguage,
|
||||
suggested_language: suggestedLanguage,
|
||||
textMap,
|
||||
} = useCompose(composeId);
|
||||
|
||||
let buttonLabel = intl.formatMessage(messages.languagePrompt);
|
||||
if (language) {
|
||||
const list: string[] = [languagesObject[modifiedLanguage || language]];
|
||||
if (textMap.size) list.push(intl.formatMessage(messages.multipleLanguages, {
|
||||
count: textMap.size,
|
||||
}));
|
||||
buttonLabel = intl.formatList(list);
|
||||
} else if (suggestedLanguage) buttonLabel = intl.formatMessage(messages.languageSuggestion, {
|
||||
language: languagesObject[suggestedLanguage as Language] || suggestedLanguage,
|
||||
});
|
||||
|
||||
const LanguageDropdown = useMemo(() => getLanguageDropdown(composeId), [composeId]);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
component={LanguageDropdown}
|
||||
>
|
||||
<Button
|
||||
theme='muted'
|
||||
size='xs'
|
||||
text={buttonLabel}
|
||||
icon={require('@tabler/icons/outline/language.svg')}
|
||||
secondaryIcon={require('@tabler/icons/outline/chevron-down.svg')}
|
||||
title={intl.formatMessage(messages.languagePrompt)}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export { LanguageDropdownButton as default };
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { addPoll, removePoll } from 'soapbox/actions/compose';
|
||||
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||
|
||||
import ComposeFormButton from './compose-form-button';
|
||||
|
||||
const messages = defineMessages({
|
||||
add_poll: { id: 'poll_button.add_poll', defaultMessage: 'Add a poll' },
|
||||
remove_poll: { id: 'poll_button.remove_poll', defaultMessage: 'Remove poll' },
|
||||
});
|
||||
|
||||
interface IPollButton {
|
||||
composeId: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const PollButton: React.FC<IPollButton> = ({ composeId, disabled }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
const unavailable = compose.is_uploading;
|
||||
const active = compose.poll !== null;
|
||||
|
||||
const onClick = () => {
|
||||
if (active) {
|
||||
dispatch(removePoll(composeId));
|
||||
} else {
|
||||
dispatch(addPoll(composeId));
|
||||
}
|
||||
};
|
||||
|
||||
if (unavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ComposeFormButton
|
||||
icon={require('@tabler/icons/outline/chart-bar.svg')}
|
||||
title={intl.formatMessage(active ? messages.remove_poll : messages.add_poll)}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { PollButton as default };
|
||||
@@ -0,0 +1,78 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import DurationSelector from './duration-selector';
|
||||
|
||||
describe('<DurationSelector />', () => {
|
||||
it('defaults to 2 days', () => {
|
||||
const handler = vi.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
expect(screen.getByTestId('duration-selector-days')).toHaveValue('2');
|
||||
expect(screen.getByTestId('duration-selector-hours')).toHaveValue('0');
|
||||
expect(screen.getByTestId('duration-selector-minutes')).toHaveValue('0');
|
||||
});
|
||||
|
||||
describe('when changing the day', () => {
|
||||
it('calls the "onDurationChange" callback', async() => {
|
||||
const handler = vi.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-days'),
|
||||
screen.getByRole('option', { name: '1 day' }),
|
||||
);
|
||||
|
||||
expect(handler.mock.calls[0][0]).toEqual(172800); // 2 days
|
||||
expect(handler.mock.calls[1][0]).toEqual(86400); // 1 day
|
||||
});
|
||||
|
||||
it('should disable the hour/minute select if 7 days selected', async() => {
|
||||
const handler = vi.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
expect(screen.getByTestId('duration-selector-hours')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('duration-selector-minutes')).not.toBeDisabled();
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-days'),
|
||||
screen.getByRole('option', { name: '7 days' }),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('duration-selector-hours')).toBeDisabled();
|
||||
expect(screen.getByTestId('duration-selector-minutes')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changing the hour', () => {
|
||||
it('calls the "onDurationChange" callback', async() => {
|
||||
const handler = vi.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-hours'),
|
||||
screen.getByRole('option', { name: '1 hour' }),
|
||||
);
|
||||
|
||||
expect(handler.mock.calls[0][0]).toEqual(172800); // 2 days
|
||||
expect(handler.mock.calls[1][0]).toEqual(176400); // 2 days, 1 hour
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changing the minute', () => {
|
||||
it('calls the "onDurationChange" callback', async() => {
|
||||
const handler = vi.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-minutes'),
|
||||
screen.getByRole('option', { name: '15 minutes' }),
|
||||
);
|
||||
|
||||
expect(handler.mock.calls[0][0]).toEqual(172800); // 2 days
|
||||
expect(handler.mock.calls[1][0]).toEqual(173700); // 2 days, 1 minute
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Select } from 'soapbox/components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
|
||||
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
|
||||
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
|
||||
});
|
||||
|
||||
interface IDurationSelector {
|
||||
onDurationChange(expiresIn: number): void;
|
||||
}
|
||||
|
||||
const DurationSelector = ({ onDurationChange }: IDurationSelector) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [days, setDays] = useState<number>(2);
|
||||
const [hours, setHours] = useState<number>(0);
|
||||
const [minutes, setMinutes] = useState<number>(0);
|
||||
|
||||
const value = (days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60);
|
||||
|
||||
useEffect(() => {
|
||||
if (days === 7) {
|
||||
setHours(0);
|
||||
setMinutes(0);
|
||||
}
|
||||
}, [days]);
|
||||
|
||||
useEffect(() => {
|
||||
onDurationChange(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
|
||||
<div className='sm:col-span-1'>
|
||||
<Select
|
||||
value={days}
|
||||
onChange={(event) => setDays(Number(event.target.value))}
|
||||
data-testid='duration-selector-days'
|
||||
>
|
||||
{[...Array(8).fill(undefined)].map((_, number) => (
|
||||
<option value={number} key={number}>
|
||||
{intl.formatMessage(messages.days, { number })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='sm:col-span-1'>
|
||||
<Select
|
||||
value={hours}
|
||||
onChange={(event) => setHours(Number(event.target.value))}
|
||||
disabled={days === 7}
|
||||
data-testid='duration-selector-hours'
|
||||
>
|
||||
{[...Array(24).fill(undefined)].map((_, number) => (
|
||||
<option value={number} key={number}>
|
||||
{intl.formatMessage(messages.hours, { number })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='sm:col-span-1'>
|
||||
<Select
|
||||
value={minutes}
|
||||
onChange={(event) => setMinutes(Number(event.target.value))}
|
||||
disabled={days === 7}
|
||||
data-testid='duration-selector-minutes'
|
||||
>
|
||||
{[0, 15, 30, 45].map((number) => (
|
||||
<option value={number} key={number}>
|
||||
{intl.formatMessage(messages.minutes, { number })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { DurationSelector as default };
|
||||
@@ -0,0 +1,211 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { addPollOption, changePollOption, changePollSettings, clearComposeSuggestions, fetchComposeSuggestions, removePoll, removePollOption, selectComposeSuggestion } from 'soapbox/actions/compose';
|
||||
import AutosuggestInput from 'soapbox/components/autosuggest-input';
|
||||
import { Button, Divider, HStack, Stack, Text, Toggle } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useCompose, useInstance } from 'soapbox/hooks';
|
||||
|
||||
import DurationSelector from './duration-selector';
|
||||
|
||||
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
||||
|
||||
const messages = defineMessages({
|
||||
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Answer #{number}' },
|
||||
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add an answer' },
|
||||
pollDuration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' },
|
||||
removePoll: { id: 'compose_form.poll.remove', defaultMessage: 'Remove poll' },
|
||||
switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple answers' },
|
||||
switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single answer' },
|
||||
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
|
||||
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
|
||||
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
|
||||
multiSelect: { id: 'compose_form.poll.multiselect', defaultMessage: 'Multi-Select' },
|
||||
multiSelectDetail: { id: 'compose_form.poll.multiselect_detail', defaultMessage: 'Allow users to select multiple answers' },
|
||||
});
|
||||
|
||||
interface IOption {
|
||||
composeId: string;
|
||||
index: number;
|
||||
maxChars: number;
|
||||
numOptions: number;
|
||||
onChange(index: number, value: string): void;
|
||||
onRemove(index: number): void;
|
||||
onRemovePoll(): void;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const Option: React.FC<IOption> = ({
|
||||
composeId,
|
||||
index,
|
||||
maxChars,
|
||||
numOptions,
|
||||
onChange,
|
||||
onRemove,
|
||||
onRemovePoll,
|
||||
title,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const { suggestions } = useCompose(composeId);
|
||||
|
||||
const handleOptionTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => onChange(index, event.target.value);
|
||||
|
||||
const handleOptionRemove = () => {
|
||||
if (numOptions > 2) {
|
||||
onRemove(index);
|
||||
} else {
|
||||
onRemovePoll();
|
||||
}
|
||||
};
|
||||
|
||||
const onSuggestionsClearRequested = () => dispatch(clearComposeSuggestions(composeId));
|
||||
|
||||
const onSuggestionsFetchRequested = (token: string) => dispatch(fetchComposeSuggestions(composeId, token));
|
||||
|
||||
const onSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => {
|
||||
if (token && typeof token === 'string') {
|
||||
dispatch(selectComposeSuggestion(composeId, tokenStart, token, value, ['poll', 'options', index]));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack alignItems='center' justifyContent='between' space={4}>
|
||||
<HStack alignItems='center' space={2} grow>
|
||||
<div className='w-6'>
|
||||
<Text weight='bold'>{index + 1}.</Text>
|
||||
</div>
|
||||
|
||||
<AutosuggestInput
|
||||
className='rounded-md !bg-transparent dark:!bg-transparent'
|
||||
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
|
||||
maxLength={maxChars}
|
||||
value={title}
|
||||
onChange={handleOptionTitleChange}
|
||||
suggestions={suggestions}
|
||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
onSuggestionSelected={onSuggestionSelected}
|
||||
searchTokens={[':']}
|
||||
autoFocus={index === 0 || index >= 2}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{index > 1 && (
|
||||
<div>
|
||||
<Button theme='danger' size='sm' onClick={handleOptionRemove}>
|
||||
<FormattedMessage id='compose_form.poll.remove_option' defaultMessage='Remove this answer' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
interface IPollForm {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
const PollForm: React.FC<IPollForm> = ({ composeId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const { configuration } = useInstance();
|
||||
|
||||
const { poll, language, modified_language: modifiedLanguage } = useCompose(composeId);
|
||||
|
||||
const options = !modifiedLanguage || modifiedLanguage === language ? poll?.options : poll?.options_map.map((option, key) => option.get(modifiedLanguage, poll.options.get(key)!));
|
||||
const expiresIn = poll?.expires_in;
|
||||
const isMultiple = poll?.multiple;
|
||||
|
||||
const {
|
||||
max_options: maxOptions,
|
||||
max_characters_per_option: maxOptionChars,
|
||||
} = configuration.polls;
|
||||
|
||||
const onRemoveOption = (index: number) => dispatch(removePollOption(composeId, index));
|
||||
const onChangeOption = (index: number, title: string) => dispatch(changePollOption(composeId, index, title));
|
||||
const handleAddOption = () => dispatch(addPollOption(composeId, ''));
|
||||
const onChangeSettings = (expiresIn: number, isMultiple?: boolean) =>
|
||||
dispatch(changePollSettings(composeId, expiresIn, isMultiple));
|
||||
const handleSelectDuration = (value: number) => onChangeSettings(value, isMultiple);
|
||||
const handleToggleMultiple = () => onChangeSettings(Number(expiresIn), !isMultiple);
|
||||
const onRemovePoll = () => dispatch(removePoll(composeId));
|
||||
|
||||
if (!options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack space={4}>
|
||||
<Stack space={2}>
|
||||
{options.map((title: string, i: number) => (
|
||||
<Option
|
||||
composeId={composeId}
|
||||
title={title}
|
||||
key={i}
|
||||
index={i}
|
||||
onChange={onChangeOption}
|
||||
onRemove={onRemoveOption}
|
||||
maxChars={maxOptionChars}
|
||||
numOptions={options.size}
|
||||
onRemovePoll={onRemovePoll}
|
||||
/>
|
||||
))}
|
||||
|
||||
<HStack space={2}>
|
||||
<div className='w-6' />
|
||||
|
||||
{options.size < maxOptions && (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={handleAddOption}
|
||||
size='sm'
|
||||
block
|
||||
>
|
||||
<FormattedMessage {...messages.add_option} />
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<button type='button' onClick={handleToggleMultiple} className='text-start'>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<Stack>
|
||||
<Text weight='medium'>
|
||||
{intl.formatMessage(messages.multiSelect)}
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' size='sm'>
|
||||
{intl.formatMessage(messages.multiSelectDetail)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Toggle checked={isMultiple} onChange={handleToggleMultiple} />
|
||||
</HStack>
|
||||
</button>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Duration */}
|
||||
<Stack space={2}>
|
||||
<Text weight='medium'>
|
||||
{intl.formatMessage(messages.pollDuration)}
|
||||
</Text>
|
||||
|
||||
<DurationSelector onDurationChange={handleSelectDuration} />
|
||||
</Stack>
|
||||
|
||||
{/* Remove Poll */}
|
||||
<div className='text-center'>
|
||||
<button type='button' className='text-danger-500' onClick={onRemovePoll}>
|
||||
{intl.formatMessage(messages.removePoll)}
|
||||
</button>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { PollForm as default };
|
||||
@@ -0,0 +1,233 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useRef } from 'react';
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { changeComposeFederated, changeComposeVisibility } from 'soapbox/actions/compose';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Button, Toggle } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useCompose, useFeatures } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
|
||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
||||
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not post to public timelines' },
|
||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
|
||||
private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
|
||||
mutuals_only_short: { id: 'privacy.mutuals_only.short', defaultMessage: 'Mutuals-only' },
|
||||
mutuals_only_long: { id: 'privacy.mutuals_only.long', defaultMessage: 'Post to mutually followed users only' },
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
||||
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
|
||||
local_short: { id: 'privacy.local.short', defaultMessage: 'Local-only' },
|
||||
local_long: { id: 'privacy.local.long', defaultMessage: 'Only visible on your instance' },
|
||||
|
||||
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust post privacy' },
|
||||
local: { id: 'privacy.local', defaultMessage: '{privacy} (local-only)' },
|
||||
});
|
||||
|
||||
interface Option {
|
||||
icon: string;
|
||||
value: string;
|
||||
text: string;
|
||||
meta: string;
|
||||
}
|
||||
|
||||
interface IPrivacyDropdownMenu {
|
||||
items: any[];
|
||||
value: string;
|
||||
onClose: () => void;
|
||||
onChange: (value: string | null) => void;
|
||||
unavailable?: boolean;
|
||||
showFederated?: boolean;
|
||||
federated?: boolean;
|
||||
onChangeFederated: () => void;
|
||||
}
|
||||
|
||||
const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({
|
||||
items, value, onClose, onChange, showFederated, federated, onChangeFederated,
|
||||
}) => {
|
||||
const node = useRef<HTMLUListElement>(null);
|
||||
const focusedItem = useRef<HTMLLIElement>(null);
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler = e => {
|
||||
const index = [...e.currentTarget.parentElement!.children].indexOf(e.currentTarget);
|
||||
let element: ChildNode | null | undefined = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case 'Enter':
|
||||
handleClick(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 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element = node.current?.childNodes[index - 1] || node.current?.lastChild;
|
||||
} else {
|
||||
element = node.current?.childNodes[index + 1] || node.current?.firstChild;
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = node.current?.firstChild;
|
||||
break;
|
||||
case 'End':
|
||||
element = node.current?.lastChild;
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
(element as HTMLElement).focus();
|
||||
const value = (element as HTMLElement).getAttribute('data-index');
|
||||
if (value !== 'local_switch') onChange(value);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => {
|
||||
const value = (e.currentTarget as HTMLElement)?.getAttribute('data-index');
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (value === 'local_switch') onChangeFederated();
|
||||
else {
|
||||
onClose();
|
||||
onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ul ref={node}>
|
||||
{items.map(item => {
|
||||
const active = item.value === value;
|
||||
return (
|
||||
<li
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
key={item.value}
|
||||
data-index={item.value}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
className={clsx(
|
||||
'flex cursor-pointer items-center p-2.5 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='mr-2.5 flex items-center justify-center rtl:ml-2.5 rtl:mr-0'>
|
||||
<Icon src={item.icon} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx('flex-auto text-xs text-primary-600 dark:text-primary-400', {
|
||||
'text-black dark:text-white': active,
|
||||
})}
|
||||
>
|
||||
<strong className='block text-sm font-medium text-black dark:text-white'>{item.text}</strong>
|
||||
{item.meta}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{showFederated && (
|
||||
<li
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
data-index='local_switch'
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={onChangeFederated}
|
||||
className='flex cursor-pointer items-center p-2.5 text-xs text-gray-700 hover:bg-gray-100 focus:bg-gray-100 black:hover:bg-gray-900 black:focus:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:focus:bg-gray-800'
|
||||
>
|
||||
<div className='mr-2.5 flex items-center justify-center rtl:ml-2.5 rtl:mr-0'>
|
||||
<Icon src={require('@tabler/icons/outline/affiliate.svg')} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='flex-auto text-xs text-primary-600 dark:text-primary-400'
|
||||
>
|
||||
<strong className='block text-sm font-medium text-black focus:text-black dark:text-white dark:focus:text-primary-400'>
|
||||
<FormattedMessage id='privacy.local.short' defaultMessage='Local-only' />
|
||||
</strong>
|
||||
<FormattedMessage id='privacy.local.long' defaultMessage='Only visible on your instance' />
|
||||
</div>
|
||||
|
||||
<Toggle checked={!federated} onChange={onChangeFederated} />
|
||||
</li>
|
||||
)}
|
||||
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
interface IPrivacyDropdown {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
|
||||
composeId,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const features = useFeatures();
|
||||
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
const value = compose.privacy;
|
||||
const unavailable = compose.id;
|
||||
|
||||
const options = [
|
||||
{ icon: require('@tabler/icons/outline/world.svg'), value: 'public', text: intl.formatMessage(messages.public_short), meta: intl.formatMessage(messages.public_long) },
|
||||
{ icon: require('@tabler/icons/outline/lock-open.svg'), value: 'unlisted', text: intl.formatMessage(messages.unlisted_short), meta: intl.formatMessage(messages.unlisted_long) },
|
||||
{ icon: require('@tabler/icons/outline/lock.svg'), value: 'private', text: intl.formatMessage(messages.private_short), meta: intl.formatMessage(messages.private_long) },
|
||||
features.visibilityMutualsOnly ? { icon: require('@tabler/icons/outline/users-group.svg'), value: 'mutuals_only', text: intl.formatMessage(messages.mutuals_only_short), meta: intl.formatMessage(messages.mutuals_only_long) } : undefined,
|
||||
{ icon: require('@tabler/icons/outline/mail.svg'), value: 'direct', text: intl.formatMessage(messages.direct_short), meta: intl.formatMessage(messages.direct_long) },
|
||||
features.visibilityLocalOnly ? { icon: require('@tabler/icons/outline/affiliate.svg'), value: 'local', text: intl.formatMessage(messages.local_short), meta: intl.formatMessage(messages.local_long) } : undefined,
|
||||
].filter((option): option is Option => !!option);
|
||||
|
||||
const onChange = (value: string | null) => value && dispatch(changeComposeVisibility(composeId, value));
|
||||
|
||||
const onChangeFederated = () => dispatch(changeComposeFederated(composeId));
|
||||
|
||||
if (unavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueOption = options.find(item => item.value === value);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
component={({ handleClose }) => (
|
||||
<PrivacyDropdownMenu
|
||||
items={options}
|
||||
value={value}
|
||||
onClose={handleClose}
|
||||
onChange={onChange}
|
||||
showFederated={features.localOnlyStatuses}
|
||||
federated={compose.federated}
|
||||
onChangeFederated={onChangeFederated}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
theme='muted'
|
||||
size='xs'
|
||||
text={compose.federated ? valueOption?.text : intl.formatMessage(messages.local, {
|
||||
privacy: valueOption?.text,
|
||||
})}
|
||||
icon={valueOption?.icon}
|
||||
secondaryIcon={require('@tabler/icons/outline/chevron-down.svg')}
|
||||
title={intl.formatMessage(messages.change_privacy)}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export { PrivacyDropdown as default };
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Link from 'soapbox/components/link';
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
interface IReplyGroupIndicator {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
const ReplyGroupIndicator = (props: IReplyGroupIndicator) => {
|
||||
const { composeId } = props;
|
||||
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
|
||||
const status = useAppSelector((state) => getStatus(state, { id: state.compose.get(composeId)?.in_reply_to! }));
|
||||
const group = status?.group;
|
||||
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text theme='muted' size='sm'>
|
||||
<FormattedMessage
|
||||
id='compose.reply_group_indicator.message'
|
||||
defaultMessage='Posting to {groupLink}'
|
||||
values={{
|
||||
groupLink: <Link
|
||||
to={`/groups/${group.id}`}
|
||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||
/>,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export { ReplyGroupIndicator as default };
|
||||
@@ -0,0 +1,66 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
|
||||
import Markup from 'soapbox/components/markup';
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import { getTextDirection } from 'soapbox/utils/rtl';
|
||||
|
||||
import type { Account, Status } from 'soapbox/normalizers';
|
||||
|
||||
interface IReplyIndicator {
|
||||
className?: string;
|
||||
status?: Pick<Status, | 'contentHtml' | 'created_at' | 'media_attachments' | 'search_index' | 'sensitive'> & { account: Pick<Account, 'id'> };
|
||||
onCancel?: () => void;
|
||||
hideActions: boolean;
|
||||
}
|
||||
|
||||
const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActions, onCancel }) => {
|
||||
const handleClick = () => {
|
||||
onCancel!();
|
||||
};
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let actions = {};
|
||||
if (!hideActions && onCancel) {
|
||||
actions = {
|
||||
onActionClick: handleClick,
|
||||
actionIcon: require('@tabler/icons/outline/x.svg'),
|
||||
actionAlignment: 'top',
|
||||
actionTitle: 'Dismiss',
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack space={2} className={clsx('max-h-72 overflow-y-auto rounded-lg bg-gray-100 p-4 black:bg-gray-900 dark:bg-gray-800', className)}>
|
||||
<AccountContainer
|
||||
{...actions}
|
||||
id={status.account.id}
|
||||
timestamp={status.created_at}
|
||||
showProfileHoverCard={false}
|
||||
withLinkToProfile={false}
|
||||
hideActions={hideActions}
|
||||
/>
|
||||
|
||||
<Markup
|
||||
className='break-words'
|
||||
size='sm'
|
||||
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
|
||||
direction={getTextDirection(status.search_index)}
|
||||
/>
|
||||
|
||||
{status.media_attachments.length > 0 && (
|
||||
<AttachmentThumbs
|
||||
media={status.media_attachments}
|
||||
sensitive={status.sensitive}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { ReplyIndicator as default };
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { FormattedList, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { useAppDispatch, useAppSelector, useCompose, useFeatures } from 'soapbox/hooks';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
interface IReplyMentions {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
const ReplyMentions: React.FC<IReplyMentions> = ({ composeId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
const status = useAppSelector(state => getStatus(state, { id: compose.in_reply_to! }));
|
||||
const to = compose.to.toArray();
|
||||
|
||||
if (!features.createStatusExplicitAddressing || !status || !to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
dispatch(openModal('REPLY_MENTIONS', {
|
||||
composeId,
|
||||
}));
|
||||
};
|
||||
|
||||
if (!compose.parent_reblogged_by && to.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (to.length === 0) {
|
||||
return (
|
||||
<a href='#' className='mb-1 text-sm text-gray-700 dark:text-gray-600' onClick={handleClick}>
|
||||
<FormattedMessage
|
||||
id='reply_mentions.reply_empty'
|
||||
defaultMessage='Replying to post'
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const accounts = to.slice(0, 2).map((acct: string) => {
|
||||
const username = acct.split('@')[0];
|
||||
return (
|
||||
<span
|
||||
key={acct}
|
||||
className='inline-block text-primary-600 no-underline [direction:ltr] hover:text-primary-700 hover:underline dark:text-accent-blue dark:hover:text-accent-blue'
|
||||
>
|
||||
@{username}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
if (to.length > 2) {
|
||||
accounts.push(
|
||||
<FormattedMessage id='reply_mentions.more' defaultMessage='{count} more' values={{ count: to.length - 2 }} />,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href='#' className='mb-1 text-sm text-gray-700 dark:text-gray-600' onClick={handleClick}>
|
||||
<FormattedMessage
|
||||
id='reply_mentions.reply'
|
||||
defaultMessage='Replying to {accounts}'
|
||||
values={{
|
||||
accounts: <FormattedList type='conjunction' value={accounts} />,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export { ReplyMentions as default };
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { addSchedule, removeSchedule } from 'soapbox/actions/compose';
|
||||
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||
|
||||
import ComposeFormButton from './compose-form-button';
|
||||
|
||||
const messages = defineMessages({
|
||||
add_schedule: { id: 'schedule_button.add_schedule', defaultMessage: 'Schedule post for later' },
|
||||
remove_schedule: { id: 'schedule_button.remove_schedule', defaultMessage: 'Post immediately' },
|
||||
});
|
||||
|
||||
interface IScheduleButton {
|
||||
composeId: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ScheduleButton: React.FC<IScheduleButton> = ({ composeId, disabled }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
const active = !!compose.schedule;
|
||||
const unavailable = !!compose.id;
|
||||
|
||||
const handleClick = () => {
|
||||
if (active) {
|
||||
dispatch(removeSchedule(composeId));
|
||||
} else {
|
||||
dispatch(addSchedule(composeId));
|
||||
}
|
||||
};
|
||||
|
||||
if (unavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ComposeFormButton
|
||||
icon={require('@tabler/icons/outline/calendar-stats.svg')}
|
||||
title={intl.formatMessage(active ? messages.remove_schedule : messages.add_schedule)}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { ScheduleButton as default };
|
||||
@@ -0,0 +1,88 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { Suspense } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { setSchedule, removeSchedule } from 'soapbox/actions/compose';
|
||||
import IconButton from 'soapbox/components/icon-button';
|
||||
import { HStack, Input, Stack, Text } from 'soapbox/components/ui';
|
||||
import { DatePicker } from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||
|
||||
const isCurrentOrFutureDate = (date: Date) =>
|
||||
date && new Date().setHours(0, 0, 0, 0) <= new Date(date).setHours(0, 0, 0, 0);
|
||||
|
||||
const isFiveMinutesFromNow = (time: Date) => {
|
||||
const fiveMinutesFromNow = new Date(new Date().getTime() + 300000); // now, plus five minutes (Pleroma won't schedule posts )
|
||||
const selectedDate = new Date(time);
|
||||
|
||||
return fiveMinutesFromNow.getTime() < selectedDate.getTime();
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
schedule: { id: 'schedule.post_time', defaultMessage: 'Post Date/Time' },
|
||||
remove: { id: 'schedule.remove', defaultMessage: 'Remove schedule' },
|
||||
});
|
||||
|
||||
interface IScheduleForm {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
const ScheduleForm: React.FC<IScheduleForm> = ({ composeId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const scheduledAt = useCompose(composeId).schedule;
|
||||
const active = !!scheduledAt;
|
||||
|
||||
const onSchedule = (date: Date) => {
|
||||
dispatch(setSchedule(composeId, date));
|
||||
};
|
||||
|
||||
const handleRemove = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
dispatch(removeSchedule(composeId));
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack space={2}>
|
||||
<Text weight='medium'>
|
||||
<FormattedMessage id='datepicker.hint' defaultMessage='Scheduled to post at…' />
|
||||
</Text>
|
||||
<HStack space={2} alignItems='center'>
|
||||
<Suspense fallback={<Input type='text' disabled />}>
|
||||
<DatePicker
|
||||
selected={scheduledAt}
|
||||
showTimeSelect
|
||||
dateFormat='MMMM d, yyyy h:mm aa'
|
||||
timeIntervals={15}
|
||||
wrapperClassName='react-datepicker-wrapper'
|
||||
onChange={onSchedule}
|
||||
placeholderText={intl.formatMessage(messages.schedule)}
|
||||
filterDate={isCurrentOrFutureDate}
|
||||
filterTime={isFiveMinutesFromNow}
|
||||
className={clsx({
|
||||
'has-error': !isFiveMinutesFromNow(scheduledAt),
|
||||
})}
|
||||
/>
|
||||
</Suspense>
|
||||
<IconButton
|
||||
iconClassName='h-4 w-4'
|
||||
className='bg-transparent text-gray-400 hover:text-gray-600'
|
||||
src={require('@tabler/icons/outline/x.svg')}
|
||||
onClick={handleRemove}
|
||||
title={intl.formatMessage(messages.remove)}
|
||||
/>
|
||||
</HStack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
isCurrentOrFutureDate,
|
||||
type IScheduleForm,
|
||||
ScheduleForm as default,
|
||||
};
|
||||
@@ -0,0 +1,262 @@
|
||||
import clsx from 'clsx';
|
||||
import { List as ImmutableList, type OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { expandSearch, setFilter, setSearchAccount } from 'soapbox/actions/search';
|
||||
import { fetchTrendingStatuses } from 'soapbox/actions/trending-statuses';
|
||||
import { useAccount, useTrendingLinks } from 'soapbox/api/hooks';
|
||||
import Hashtag from 'soapbox/components/hashtag';
|
||||
import IconButton from 'soapbox/components/icon-button';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import TrendingLink from 'soapbox/components/trending-link';
|
||||
import { HStack, Spinner, Tabs, Text } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import StatusContainer from 'soapbox/containers/status-container';
|
||||
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account';
|
||||
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag';
|
||||
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
import type { SearchFilter } from 'soapbox/reducers/search';
|
||||
|
||||
const messages = defineMessages({
|
||||
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
|
||||
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
|
||||
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
|
||||
links: { id: 'search_results.links', defaultMessage: 'News' },
|
||||
});
|
||||
|
||||
const SearchResults = () => {
|
||||
const node = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
||||
const [tabKey, setTabKey] = useState(1);
|
||||
|
||||
const value = useAppSelector((state) => state.search.submittedValue);
|
||||
const results = useAppSelector((state) => state.search.results);
|
||||
const suggestions = useAppSelector((state) => state.suggestions.items);
|
||||
const trendingStatuses = useAppSelector((state) => state.trending_statuses.items);
|
||||
const trends = useAppSelector((state) => state.trends.items);
|
||||
const submitted = useAppSelector((state) => state.search.submitted);
|
||||
const selectedFilter = useAppSelector((state) => state.search.filter);
|
||||
const filterByAccount = useAppSelector((state) => state.search.accountId || undefined);
|
||||
const { trendingLinks } = useTrendingLinks();
|
||||
const { account } = useAccount(filterByAccount);
|
||||
|
||||
const handleLoadMore = () => dispatch(expandSearch(selectedFilter));
|
||||
|
||||
const handleUnsetAccount = () => dispatch(setSearchAccount(null));
|
||||
|
||||
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(value, newActiveFilter));
|
||||
|
||||
const renderFilterBar = () => {
|
||||
const items = [];
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(messages.accounts),
|
||||
action: () => selectFilter('accounts'),
|
||||
name: 'accounts',
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.statuses),
|
||||
action: () => selectFilter('statuses'),
|
||||
name: 'statuses',
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.hashtags),
|
||||
action: () => selectFilter('hashtags'),
|
||||
name: 'hashtags',
|
||||
},
|
||||
);
|
||||
|
||||
if (!submitted && features.trendingLinks) items.push({
|
||||
text: intl.formatMessage(messages.links),
|
||||
action: () => selectFilter('links'),
|
||||
name: 'links',
|
||||
});
|
||||
|
||||
return <Tabs key={tabKey} items={items} activeItem={selectedFilter} />;
|
||||
};
|
||||
|
||||
const getCurrentIndex = (id: string): number => resultsIds?.keySeq().findIndex(key => key === id);
|
||||
|
||||
const handleMoveUp = (id: string) => {
|
||||
if (!resultsIds) return;
|
||||
|
||||
const elementIndex = getCurrentIndex(id) - 1;
|
||||
selectChild(elementIndex);
|
||||
};
|
||||
|
||||
const handleMoveDown = (id: string) => {
|
||||
if (!resultsIds) return;
|
||||
|
||||
const elementIndex = getCurrentIndex(id) + 1;
|
||||
selectChild(elementIndex);
|
||||
};
|
||||
|
||||
const selectChild = (index: number) => {
|
||||
node.current?.scrollIntoView({
|
||||
index,
|
||||
behavior: 'smooth',
|
||||
done: () => {
|
||||
const element = document.querySelector<HTMLDivElement>(`#search-results [data-index="${index}"] .focusable`);
|
||||
element?.focus();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchTrendingStatuses());
|
||||
}, []);
|
||||
|
||||
let searchResults;
|
||||
let hasMore = false;
|
||||
let loaded;
|
||||
let noResultsMessage;
|
||||
let placeholderComponent = PlaceholderStatus as React.ComponentType;
|
||||
let resultsIds: ImmutableOrderedSet<string>;
|
||||
|
||||
if (selectedFilter === 'accounts') {
|
||||
hasMore = results.accountsHasMore;
|
||||
loaded = results.accountsLoaded;
|
||||
placeholderComponent = PlaceholderAccount;
|
||||
|
||||
if (results.accounts && results.accounts.size > 0) {
|
||||
searchResults = results.accounts.map(accountId => <AccountContainer key={accountId} id={accountId} />);
|
||||
} else if (!submitted && suggestions && !suggestions.isEmpty()) {
|
||||
searchResults = suggestions.map(suggestion => <AccountContainer key={suggestion.account} id={suggestion.account} />);
|
||||
} else if (loaded) {
|
||||
noResultsMessage = (
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage
|
||||
id='empty_column.search.accounts'
|
||||
defaultMessage='There are no people results for "{term}"'
|
||||
values={{ term: value }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedFilter === 'statuses') {
|
||||
hasMore = results.statusesHasMore;
|
||||
loaded = results.statusesLoaded;
|
||||
|
||||
if (results.statuses && results.statuses.size > 0) {
|
||||
searchResults = results.statuses.map((statusId: string) => (
|
||||
// @ts-ignore
|
||||
<StatusContainer
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
/>
|
||||
));
|
||||
resultsIds = results.statuses;
|
||||
} else if (!submitted && trendingStatuses && !trendingStatuses.isEmpty()) {
|
||||
searchResults = trendingStatuses.map((statusId: string) => (
|
||||
// @ts-ignore
|
||||
<StatusContainer
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
/>
|
||||
));
|
||||
resultsIds = trendingStatuses;
|
||||
} else if (loaded) {
|
||||
noResultsMessage = (
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage
|
||||
id='empty_column.search.statuses'
|
||||
defaultMessage='There are no posts results for "{term}"'
|
||||
values={{ term: value }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
noResultsMessage = <Spinner />;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedFilter === 'hashtags') {
|
||||
hasMore = results.hashtagsHasMore;
|
||||
loaded = results.hashtagsLoaded;
|
||||
placeholderComponent = PlaceholderHashtag;
|
||||
|
||||
if (results.hashtags && results.hashtags.size > 0) {
|
||||
searchResults = results.hashtags.map(hashtag => <Hashtag key={hashtag.name} hashtag={hashtag} />);
|
||||
} else if (!submitted && suggestions && !suggestions.isEmpty()) {
|
||||
searchResults = trends.map(hashtag => <Hashtag key={hashtag.name} hashtag={hashtag} />);
|
||||
} else if (loaded) {
|
||||
noResultsMessage = (
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage
|
||||
id='empty_column.search.hashtags'
|
||||
defaultMessage='There are no hashtags results for "{term}"'
|
||||
values={{ term: value }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedFilter === 'links') {
|
||||
loaded = true;
|
||||
|
||||
if (submitted) {
|
||||
selectFilter('accounts');
|
||||
setTabKey(key => ++key);
|
||||
} else if (!submitted && trendingLinks) {
|
||||
searchResults = ImmutableList(trendingLinks.map(trendingLink => <TrendingLink trendingLink={trendingLink} />));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{filterByAccount ? (
|
||||
<HStack className='border-b border-solid border-gray-200 p-2 pb-4 dark:border-gray-800' space={2}>
|
||||
<IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/outline/x.svg')} onClick={handleUnsetAccount} />
|
||||
<Text truncate>
|
||||
<FormattedMessage
|
||||
id='search_results.filter_message'
|
||||
defaultMessage='You are searching for posts from @{acct}.'
|
||||
values={{ acct: <strong className='break-words'>{account?.acct}</strong> }}
|
||||
/>
|
||||
</Text>
|
||||
</HStack>
|
||||
) : renderFilterBar()}
|
||||
|
||||
{noResultsMessage || (
|
||||
<ScrollableList
|
||||
id='search-results'
|
||||
ref={node}
|
||||
key={selectedFilter}
|
||||
scrollKey={`${selectedFilter}:${value}`}
|
||||
isLoading={submitted && !loaded}
|
||||
showLoading={submitted && !loaded && searchResults?.isEmpty()}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
placeholderComponent={placeholderComponent}
|
||||
placeholderCount={20}
|
||||
listClassName={clsx({
|
||||
'divide-gray-200 dark:divide-gray-800 divide-solid divide-y': selectedFilter === 'statuses',
|
||||
})}
|
||||
itemClassName={clsx({
|
||||
'pb-4': selectedFilter === 'accounts' || selectedFilter === 'links',
|
||||
'pb-3': selectedFilter === 'hashtags',
|
||||
})}
|
||||
>
|
||||
{searchResults || []}
|
||||
</ScrollableList>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { SearchResults as default };
|
||||
179
packages/pl-fe/src/features/compose/components/search.tsx
Normal file
179
packages/pl-fe/src/features/compose/components/search.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import clsx from 'clsx';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
clearSearch,
|
||||
clearSearchResults,
|
||||
setSearchAccount,
|
||||
showSearch,
|
||||
submitSearch,
|
||||
} from 'soapbox/actions/search';
|
||||
import AutosuggestAccountInput from 'soapbox/components/autosuggest-account-input';
|
||||
import { Input } from 'soapbox/components/ui';
|
||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { selectAccount } from 'soapbox/selectors';
|
||||
import { AppDispatch, RootState } from 'soapbox/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||
action: { id: 'search.action', defaultMessage: 'Search for “{query}”' },
|
||||
});
|
||||
|
||||
const redirectToAccount = (accountId: string, routerHistory: any) =>
|
||||
(_dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const acct = selectAccount(getState(), accountId)!.acct;
|
||||
|
||||
if (acct && routerHistory) {
|
||||
routerHistory.push(`/@${acct}`);
|
||||
}
|
||||
};
|
||||
|
||||
interface ISearch {
|
||||
autoFocus?: boolean;
|
||||
autoSubmit?: boolean;
|
||||
autosuggest?: boolean;
|
||||
openInRoute?: boolean;
|
||||
}
|
||||
|
||||
const Search = (props: ISearch) => {
|
||||
const submittedValue = useAppSelector((state) => state.search.submittedValue);
|
||||
const [value, setValue] = useState(submittedValue);
|
||||
const {
|
||||
autoFocus = false,
|
||||
autoSubmit = false,
|
||||
autosuggest = false,
|
||||
openInRoute = false,
|
||||
} = props;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
|
||||
const submitted = useAppSelector((state) => state.search.submitted);
|
||||
|
||||
const debouncedSubmit = useCallback(debounce((value: string) => {
|
||||
dispatch(submitSearch(value));
|
||||
}, 900), []);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
|
||||
setValue(value);
|
||||
|
||||
if (autoSubmit) {
|
||||
debouncedSubmit(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (value.length > 0 || submitted) {
|
||||
dispatch(clearSearchResults());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (openInRoute) {
|
||||
dispatch(setSearchAccount(null));
|
||||
dispatch(submitSearch(value));
|
||||
|
||||
history.push('/search');
|
||||
} else {
|
||||
dispatch(submitSearch(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
handleSubmit();
|
||||
} else if (event.key === 'Escape') {
|
||||
document.querySelector('.ui')?.parentElement?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
dispatch(showSearch());
|
||||
};
|
||||
|
||||
const handleSelected = (accountId: string) => {
|
||||
dispatch(clearSearch());
|
||||
dispatch(redirectToAccount(accountId, history));
|
||||
};
|
||||
|
||||
const makeMenu = () => [
|
||||
{
|
||||
text: intl.formatMessage(messages.action, { query: value }),
|
||||
icon: require('@tabler/icons/outline/search.svg'),
|
||||
action: handleSubmit,
|
||||
},
|
||||
];
|
||||
|
||||
const hasValue = value.length > 0 || submitted;
|
||||
const componentProps: any = {
|
||||
type: 'text',
|
||||
id: 'search',
|
||||
placeholder: intl.formatMessage(messages.placeholder),
|
||||
value,
|
||||
onChange: handleChange,
|
||||
onKeyDown: handleKeyDown,
|
||||
onFocus: handleFocus,
|
||||
autoFocus: autoFocus,
|
||||
theme: 'search',
|
||||
className: 'pr-10 rtl:pl-10 rtl:pr-3',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== submittedValue) setValue(submittedValue);
|
||||
}, [submittedValue]);
|
||||
|
||||
if (autosuggest) {
|
||||
componentProps.onSelected = handleSelected;
|
||||
componentProps.menu = makeMenu();
|
||||
componentProps.autoSelect = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('w-full', {
|
||||
'sticky top-[76px] z-10 bg-white/90 backdrop-blur black:bg-black/80 dark:bg-primary-900/90': !openInRoute,
|
||||
})}
|
||||
>
|
||||
<label htmlFor='search' className='sr-only'>{intl.formatMessage(messages.placeholder)}</label>
|
||||
|
||||
<div className='relative'>
|
||||
{autosuggest ? (
|
||||
<AutosuggestAccountInput {...componentProps} />
|
||||
) : (
|
||||
<Input {...componentProps} />
|
||||
)}
|
||||
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='absolute inset-y-0 right-0 flex cursor-pointer items-center px-3 rtl:left-0 rtl:right-auto'
|
||||
onClick={handleClear}
|
||||
>
|
||||
<SvgIcon
|
||||
src={require('@tabler/icons/outline/search.svg')}
|
||||
className={clsx('h-4 w-4 text-gray-600', { hidden: hasValue })}
|
||||
/>
|
||||
|
||||
<SvgIcon
|
||||
src={require('@tabler/icons/outline/x.svg')}
|
||||
className={clsx('h-4 w-4 text-gray-600', { hidden: !hasValue })}
|
||||
aria-label={intl.formatMessage(messages.placeholder)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Search as default };
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { changeComposeSpoilerness } from 'soapbox/actions/compose';
|
||||
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||
|
||||
import ComposeFormButton from './compose-form-button';
|
||||
|
||||
const messages = defineMessages({
|
||||
marked: { id: 'compose_form.spoiler.marked', defaultMessage: 'Media is marked as sensitive' },
|
||||
unmarked: { id: 'compose_form.spoiler.unmarked', defaultMessage: 'Media is not marked as sensitive' },
|
||||
});
|
||||
|
||||
interface ISpoilerButton {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
const SpoilerButton: React.FC<ISpoilerButton> = ({ composeId }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const active = useCompose(composeId).sensitive;
|
||||
|
||||
const onClick = () =>
|
||||
dispatch(changeComposeSpoilerness(composeId));
|
||||
|
||||
return (
|
||||
<ComposeFormButton
|
||||
icon={require('@tabler/icons/outline/alert-triangle.svg')}
|
||||
title={intl.formatMessage(active ? messages.marked : messages.unmarked)}
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { SpoilerButton as default };
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { changeComposeSpoilerText } from 'soapbox/actions/compose';
|
||||
import AutosuggestInput, { IAutosuggestInput } from 'soapbox/components/autosuggest-input';
|
||||
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Subject (optional)' },
|
||||
});
|
||||
|
||||
interface ISpoilerInput extends Pick<IAutosuggestInput, 'onSuggestionsFetchRequested' | 'onSuggestionsClearRequested' | 'onSuggestionSelected' | 'theme'> {
|
||||
composeId: string extends 'default' ? never : string;
|
||||
}
|
||||
|
||||
/** Text input for content warning in composer. */
|
||||
const SpoilerInput = React.forwardRef<AutosuggestInput, ISpoilerInput>(({
|
||||
composeId,
|
||||
onSuggestionsFetchRequested,
|
||||
onSuggestionsClearRequested,
|
||||
onSuggestionSelected,
|
||||
theme,
|
||||
}, ref) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const { language, modified_language, spoiler_text: spoilerText, spoilerTextMap, suggestions } = useCompose(composeId);
|
||||
|
||||
const handleChangeSpoilerText: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
dispatch(changeComposeSpoilerText(composeId, e.target.value));
|
||||
};
|
||||
|
||||
const value = !modified_language || modified_language === language ? spoilerText : spoilerTextMap.get(modified_language, '');
|
||||
|
||||
return (
|
||||
<AutosuggestInput
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={value}
|
||||
onChange={handleChangeSpoilerText}
|
||||
suggestions={suggestions}
|
||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
onSuggestionSelected={onSuggestionSelected}
|
||||
theme={theme}
|
||||
searchTokens={[':']}
|
||||
id='cw-spoiler-input'
|
||||
className='rounded-md !bg-transparent dark:!bg-transparent'
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export { SpoilerInput as default };
|
||||
@@ -0,0 +1,26 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { length } from 'stringz';
|
||||
|
||||
interface ITextCharacterCounter {
|
||||
max: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const TextCharacterCounter: React.FC<ITextCharacterCounter> = ({ text, max }) => {
|
||||
const checkRemainingText = (diff: number) => (
|
||||
<span
|
||||
className={clsx('text-sm font-medium', {
|
||||
'text-gray-700': diff >= 0,
|
||||
'text-secondary-600': diff < 0,
|
||||
})}
|
||||
>
|
||||
{diff}
|
||||
</span>
|
||||
);
|
||||
|
||||
const diff = max - length(text);
|
||||
return checkRemainingText(diff);
|
||||
};
|
||||
|
||||
export { TextCharacterCounter as default };
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { defineMessages, IntlShape, useIntl } from 'react-intl';
|
||||
|
||||
import { IconButton } from 'soapbox/components/ui';
|
||||
import { useInstance } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
upload: { id: 'upload_button.label', defaultMessage: 'Add media attachment' },
|
||||
});
|
||||
|
||||
const onlyImages = (types: string[] | undefined): boolean =>
|
||||
types?.every((type) => type.startsWith('image/')) ?? false;
|
||||
|
||||
interface IUploadButton {
|
||||
disabled?: boolean;
|
||||
unavailable?: boolean;
|
||||
onSelectFile: (files: FileList, intl: IntlShape) => void;
|
||||
style?: React.CSSProperties;
|
||||
resetFileKey: number | null;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const UploadButton: React.FC<IUploadButton> = ({
|
||||
disabled = false,
|
||||
unavailable = false,
|
||||
onSelectFile,
|
||||
resetFileKey,
|
||||
className = 'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500',
|
||||
iconClassName,
|
||||
icon,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { configuration } = useInstance();
|
||||
|
||||
const fileElement = useRef<HTMLInputElement>(null);
|
||||
const attachmentTypes = configuration.media_attachments.supported_mime_types;
|
||||
|
||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
if (e.target.files?.length) {
|
||||
onSelectFile(e.target.files, intl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileElement.current?.click();
|
||||
};
|
||||
|
||||
if (unavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const src = icon || (
|
||||
onlyImages(attachmentTypes)
|
||||
? require('@tabler/icons/outline/photo.svg')
|
||||
: require('@tabler/icons/outline/paperclip.svg')
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<IconButton
|
||||
src={src}
|
||||
className={className}
|
||||
iconClassName={iconClassName}
|
||||
title={intl.formatMessage(messages.upload)}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
|
||||
<label>
|
||||
<span className='sr-only'>{intl.formatMessage(messages.upload)}</span>
|
||||
<input
|
||||
key={resetFileKey}
|
||||
ref={fileElement}
|
||||
type='file'
|
||||
multiple
|
||||
accept={attachmentTypes?.join(',')}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className='hidden'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
onlyImages,
|
||||
type IUploadButton,
|
||||
UploadButton as default,
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
|
||||
import { changeMediaOrder } from 'soapbox/actions/compose';
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useCompose } from 'soapbox/hooks';
|
||||
|
||||
import Upload from './upload';
|
||||
import UploadProgress from './upload-progress';
|
||||
|
||||
interface IUploadForm {
|
||||
composeId: string;
|
||||
onSubmit(): void;
|
||||
}
|
||||
|
||||
const UploadForm: React.FC<IUploadForm> = ({ composeId, onSubmit }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const mediaIds = useCompose(composeId).media_attachments.map((item) => item.id);
|
||||
|
||||
const dragItem = useRef<string | null>();
|
||||
const dragOverItem = useRef<string | null>();
|
||||
|
||||
const handleDragStart = useCallback((id: string) => {
|
||||
dragItem.current = id;
|
||||
}, [dragItem]);
|
||||
|
||||
const handleDragEnter = useCallback((id: string) => {
|
||||
dragOverItem.current = id;
|
||||
}, [dragOverItem]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
dispatch(changeMediaOrder(composeId, dragItem.current!, dragOverItem.current!));
|
||||
dragItem.current = null;
|
||||
dragOverItem.current = null;
|
||||
}, [dragItem, dragOverItem]);
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden'>
|
||||
<UploadProgress composeId={composeId} />
|
||||
|
||||
<HStack wrap className={clsx('overflow-hidden', mediaIds.size !== 0 && 'p-1')}>
|
||||
{mediaIds.map((id: string) => (
|
||||
<Upload
|
||||
id={id}
|
||||
key={id}
|
||||
composeId={composeId}
|
||||
onSubmit={onSubmit}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { UploadForm as default };
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
import UploadProgress from 'soapbox/components/upload-progress';
|
||||
import { useCompose } from 'soapbox/hooks';
|
||||
|
||||
interface IComposeUploadProgress {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
/** File upload progress bar for post composer. */
|
||||
const ComposeUploadProgress: React.FC<IComposeUploadProgress> = ({ composeId }) => {
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
const active = compose.is_uploading;
|
||||
const progress = compose.progress;
|
||||
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UploadProgress progress={progress} />
|
||||
);
|
||||
};
|
||||
|
||||
export { ComposeUploadProgress as default };
|
||||
53
packages/pl-fe/src/features/compose/components/upload.tsx
Normal file
53
packages/pl-fe/src/features/compose/components/upload.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { undoUploadCompose, changeUploadCompose } from 'soapbox/actions/compose';
|
||||
import Upload from 'soapbox/components/upload';
|
||||
import { useAppDispatch, useCompose, useInstance } from 'soapbox/hooks';
|
||||
|
||||
interface IUploadCompose {
|
||||
id: string;
|
||||
composeId: string;
|
||||
onSubmit?(): void;
|
||||
onDragStart: (id: string) => void;
|
||||
onDragEnter: (id: string) => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
const UploadCompose: React.FC<IUploadCompose> = ({ composeId, id, onSubmit, onDragStart, onDragEnter, onDragEnd }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { pleroma: { metadata: { description_limit: descriptionLimit } } } = useInstance();
|
||||
|
||||
const media = useCompose(composeId).media_attachments.find(item => item.id === id)!;
|
||||
|
||||
const handleDescriptionChange = (description: string) => {
|
||||
dispatch(changeUploadCompose(composeId, media.id, { description }));
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
dispatch(undoUploadCompose(composeId, media.id));
|
||||
};
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
onDragStart(id);
|
||||
}, [onDragStart, id]);
|
||||
|
||||
const handleDragEnter = useCallback(() => {
|
||||
onDragEnter(id);
|
||||
}, [onDragEnter, id]);
|
||||
|
||||
return (
|
||||
<Upload
|
||||
media={media}
|
||||
onDelete={handleDelete}
|
||||
onDescriptionChange={handleDescriptionChange}
|
||||
onSubmit={onSubmit}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragEnd={onDragEnd}
|
||||
descriptionLimit={descriptionLimit}
|
||||
withPreview
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { UploadCompose as default };
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { length } from 'stringz';
|
||||
|
||||
import ProgressCircle from 'soapbox/components/progress-circle';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'compose.character_counter.title', defaultMessage: 'Used {chars} out of {maxChars} {maxChars, plural, one {character} other {characters}}' },
|
||||
});
|
||||
|
||||
interface IVisualCharacterCounter {
|
||||
/** max text allowed */
|
||||
max: number;
|
||||
/** text to use to measure */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Renders a character counter */
|
||||
const VisualCharacterCounter: React.FC<IVisualCharacterCounter> = ({ text, max }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const textLength = length(text);
|
||||
const progress = textLength / max;
|
||||
|
||||
return (
|
||||
<ProgressCircle
|
||||
title={intl.formatMessage(messages.title, { chars: textLength, maxChars: max })}
|
||||
progress={progress}
|
||||
radius={10}
|
||||
stroke={3}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { VisualCharacterCounter as default };
|
||||
21
packages/pl-fe/src/features/compose/components/warning.tsx
Normal file
21
packages/pl-fe/src/features/compose/components/warning.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { spring } from 'react-motion';
|
||||
|
||||
import Motion from '../../ui/util/optional-motion';
|
||||
|
||||
interface IWarning {
|
||||
message: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Warning message displayed in ComposeForm. */
|
||||
const Warning: React.FC<IWarning> = ({ message }) => (
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
<div className='mb-2.5 rounded border border-solid border-gray-400 bg-transparent px-2.5 py-2 text-xs text-gray-900 dark:border-gray-800 dark:text-white' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
|
||||
export { Warning as default };
|
||||
Reference in New Issue
Block a user