Merge remote-tracking branch 'soapbox/main' into lexical

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2023-09-20 23:49:55 +02:00
1515 changed files with 6883 additions and 14598 deletions

View File

@ -0,0 +1,32 @@
test.skip('skip', () => {});
// import userEvent from '@testing-library/user-event';
// import React from 'react';
// import { __stub } from 'soapbox/api';
// import { render, screen, waitFor } from '../../../../jest/test-helpers';
// import Search from '../search';
// describe('<Search />', () => {
// it('successfully renders', async() => {
// render(<Search autosuggest />);
// expect(screen.getByLabelText('Search')).toBeInTheDocument();
// });
// it('handles onChange', async() => {
// __stub(mock => {
// mock.onGet('/api/v1/accounts/search').reply(200, [{ id: 1 }]);
// });
// const user = userEvent.setup();
// render(<Search autosuggest />);
// await user.type(screen.getByLabelText('Search'), '@jus');
// await waitFor(() => {
// expect(screen.getByLabelText('Search')).toHaveValue('@jus');
// expect(screen.getByTestId('account')).toBeInTheDocument();
// });
// });
// });

View File

@ -0,0 +1,18 @@
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 <Account account={account} hideActions showProfileHoverCard={false} />;
};
export default AutosuggestAccount;

View File

@ -0,0 +1,39 @@
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,
}) => {
return (
<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 default ComposeFormButton;

View File

@ -0,0 +1,405 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl, type MessageDescriptor } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import { length } from 'stringz';
import {
changeCompose,
submitCompose,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
insertEmojiCompose,
uploadCompose,
} from 'soapbox/actions/compose';
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input';
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
import { Button, HStack, Stack } from 'soapbox/components/ui';
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
import Bundle from 'soapbox/features/ui/components/bundle';
import { ComposeEditor } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector, useCompose, useDraggedFiles, useFeatures, useInstance, usePrevious, useSettings } from 'soapbox/hooks';
import { isMobile } from 'soapbox/is-mobile';
import QuotedStatusContainer from '../containers/quoted-status-container';
import ReplyIndicatorContainer from '../containers/reply-indicator-container';
import ScheduleFormContainer from '../containers/schedule-form-container';
import UploadButtonContainer from '../containers/upload-button-container';
import WarningContainer from '../containers/warning-container';
import { countableText } from '../util/counter';
import MarkdownButton from './markdown-button';
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 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 Warning from './warning';
import type { Emoji } from 'soapbox/features/emoji';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
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
extra?: React.ReactNode
}
const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickableAreaRef, event, group, extra }: 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 isModalOpen = useAppSelector((state) => !!(state.modals.size && state.modals.last()!.modalType === 'COMPOSE'));
const maxTootChars = configuration.getIn(['statuses', 'max_characters']) as number;
const scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size);
const features = useFeatures();
const wysiwygEditor = useSettings().get('wysiwyg');
const { text: composeText, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt, group_id: groupId } = compose;
const prevSpoiler = usePrevious(spoiler);
const hasPoll = !!compose.poll;
const isEditing = compose.id !== null;
const anyMedia = compose.media_attachments.size > 0;
const [composeFocused, setComposeFocused] = useState(false);
const firstRender = useRef(true);
const formRef = useRef<HTMLDivElement>(null);
const spoilerTextRef = useRef<AutosuggestInput>(null);
const autosuggestTextareaRef = useRef<AutosuggestTextarea>(null);
const editorStateRef = useRef<string>(null);
const text = wysiwygEditor ? editorStateRef.current || '' : composeText;
const { isDraggedOver } = useDraggedFiles(formRef);
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
dispatch(changeCompose(id, e.target.value));
};
const handleKeyDown: React.KeyboardEventHandler = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
handleSubmit();
e.preventDefault(); // Prevent bubbling to other ComposeForm instances
}
};
const getClickableArea = () => {
return clickableAreaRef ? clickableAreaRef.current : formRef.current;
};
const isEmpty = () => {
return !(text || spoilerText || anyMedia);
};
const isClickOutside = (e: MouseEvent | React.MouseEvent) => {
return ![
// List of elements that shouldn't collapse the composer when clicked
// FIXME: Make this less brittle
getClickableArea(),
document.querySelector('.privacy-dropdown__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();
}
}, []);
const handleClickOutside = () => {
setComposeFocused(false);
};
const handleComposeFocus = () => {
setComposeFocused(true);
};
const handleSubmit = (e?: React.FormEvent<Element>) => {
if (wysiwygEditor) {
dispatch(changeCompose(id, editorStateRef.current!));
} else {
if (text !== autosuggestTextareaRef.current?.textarea?.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
dispatch(changeCompose(id, autosuggestTextareaRef.current!.textarea!.value));
}
}
// Submit disabled:
const fulltext = [spoilerText, countableText(text)].join('');
if (e) {
e.preventDefault();
}
if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxTootChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
return;
}
dispatch(submitCompose(id, history));
};
const onSuggestionsClearRequested = () => {
dispatch(clearComposeSuggestions(id));
};
const onSuggestionsFetchRequested = (token: string | number) => {
dispatch(fetchComposeSuggestions(id, token as string));
};
const onSuggestionSelected = (tokenStart: number, token: string | null, value: string | undefined) => {
if (value) dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['text']));
};
const onSpoilerSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => {
dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['spoiler_text']));
};
const setCursor = (start: number, end: number = start) => {
if (!autosuggestTextareaRef.current?.textarea) return;
autosuggestTextareaRef.current.textarea.setSelectionRange(start, end);
};
const handleEmojiPick = (data: Emoji) => {
const position = autosuggestTextareaRef.current!.textarea!.selectionStart;
const needsSpace = !!data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
dispatch(insertEmojiCompose(id, position, data, needsSpace));
};
const onPaste = (files: FileList) => {
dispatch(uploadCompose(id, files, intl));
};
const focusSpoilerInput = () => {
spoilerTextRef.current?.input?.focus();
};
const focusTextarea = () => {
autosuggestTextareaRef.current?.textarea?.focus();
};
useEffect(() => {
const length = text.length;
document.addEventListener('click', handleClick, true);
if (length > 0) {
setCursor(length); // Set cursor at end
}
return () => {
document.removeEventListener('click', handleClick, true);
};
}, []);
useEffect(() => {
if (spoiler && firstRender.current) {
focusTextarea();
firstRender.current = false;
} else if (!spoiler && prevSpoiler) {
focusTextarea();
} else if (spoiler && !prevSpoiler) {
focusSpoilerInput();
}
}, [spoiler]);
useEffect(() => {
if (typeof caretPosition === 'number') {
setCursor(caretPosition);
}
}, [focusDate]);
const renderButtons = useCallback(() => (
<HStack alignItems='center' space={2}>
{features.media && <UploadButtonContainer composeId={id} />}
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} condensed={shouldCondense} />
{features.polls && <PollButton composeId={id} />}
{features.privacyScopes && !group && !groupId && <PrivacyDropdown composeId={id} />}
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
{features.spoilers && <SpoilerButton composeId={id} />}
{!wysiwygEditor && features.richText && <MarkdownButton composeId={id} />}
</HStack>
), [features, id]);
const condensed = shouldCondense && !isDraggedOver && !composeFocused && isEmpty() && !isUploading;
const disabled = isSubmitting;
const countedText = [spoilerText, countableText(text)].join('');
const disabledButton = disabled || isUploading || isChangingUpload || length(countedText) > maxTootChars || (countedText.length !== 0 && countedText.trim().length === 0 && !anyMedia);
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
const composeModifiers = !condensed && (
<Stack space={4} className='compose-form__modifiers'>
<UploadForm composeId={id} />
<PollForm composeId={id} />
<SpoilerInput
composeId={id}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSpoilerSuggestionSelected}
ref={spoilerTextRef}
/>
<ScheduleFormContainer composeId={id} />
</Stack>
);
let publishText: string | JSX.Element = '';
let publishIcon: string | undefined = undefined;
let textareaPlaceholder: MessageDescriptor;
if (isEditing) {
publishText = intl.formatMessage(messages.saveChanges);
} else if (privacy === 'direct') {
publishIcon = require('@tabler/icons/mail.svg');
publishText = intl.formatMessage(messages.message);
} else if (privacy === 'private') {
publishIcon = require('@tabler/icons/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);
}
if (event) {
textareaPlaceholder = messages.eventPlaceholder;
} else if (hasPoll) {
textareaPlaceholder = messages.pollPlaceholder;
} else {
textareaPlaceholder = messages.placeholder;
}
return (
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
{scheduledStatusCount > 0 && !event && !group && (
<Warning
message={(
<FormattedMessage
id='compose_form.scheduled_statuses.message'
defaultMessage='You have scheduled posts. {click_here} to see them.'
values={{ click_here: (
<Link to='/scheduled_statuses'>
<FormattedMessage
id='compose_form.scheduled_statuses.click_here'
defaultMessage='Click here'
/>
</Link>
) }}
/>)
}
/>
)}
<WarningContainer composeId={id} />
{!shouldCondense && !event && !group && groupId && <ReplyGroupIndicator composeId={id} />}
{!shouldCondense && !event && !group && <ReplyIndicatorContainer composeId={id} />}
{!shouldCondense && !event && !group && <ReplyMentions composeId={id} />}
{wysiwygEditor ? (
<div>
<Bundle fetchComponent={ComposeEditor}>
{(Component: any) => (
<Component
ref={editorStateRef}
className='my-2'
composeId={id}
condensed={condensed}
eventDiscussion={!!event}
autoFocus={shouldAutoFocus}
hasPoll={hasPoll}
handleSubmit={handleSubmit}
onFocus={handleComposeFocus}
onPaste={onPaste}
/>
)}
</Bundle>
{composeModifiers}
</div>
) : (
<AutosuggestTextarea
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
placeholder={intl.formatMessage(textareaPlaceholder)}
disabled={disabled}
value={text}
onChange={handleChange}
suggestions={suggestions}
onKeyDown={handleKeyDown}
onFocus={handleComposeFocus}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSuggestionSelected}
onPaste={onPaste}
autoFocus={shouldAutoFocus}
condensed={condensed}
id='compose-textarea'
>
{composeModifiers}
</AutosuggestTextarea>
)}
<QuotedStatusContainer composeId={id} />
{extra && <div className={clsx({ 'hidden': condensed })}>{extra}</div>}
<div
className={clsx('flex flex-wrap items-center justify-between', {
'hidden': condensed,
})}
>
{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={disabledButton} />
</HStack>
</div>
</Stack>
);
};
export default ComposeForm;

View File

@ -0,0 +1,37 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeComposeContentType } from 'soapbox/actions/compose';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import ComposeFormButton from './compose-form-button';
const messages = defineMessages({
marked: { id: 'compose_form.markdown.marked', defaultMessage: 'Post markdown enabled' },
unmarked: { id: 'compose_form.markdown.unmarked', defaultMessage: 'Post markdown disabled' },
});
interface IMarkdownButton {
composeId: string
}
const MarkdownButton: React.FC<IMarkdownButton> = ({ composeId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const active = useCompose(composeId).content_type === 'text/markdown';
const onClick = () => dispatch(changeComposeContentType(composeId, active ? 'text/plain' : 'text/markdown'));
return (
<ComposeFormButton
icon={require('@tabler/icons/markdown.svg')}
title={intl.formatMessage(active ? messages.marked : messages.unmarked)}
active={active}
onClick={onClick}
/>
);
};
export default MarkdownButton;

View File

@ -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/chart-bar.svg')}
title={intl.formatMessage(active ? messages.remove_poll : messages.add_poll)}
active={active}
disabled={disabled}
onClick={onClick}
/>
);
};
export default PollButton;

View File

@ -0,0 +1,77 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { render, screen } from '../../../../../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
});
});
});

View File

@ -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 default DurationSelector;

View File

@ -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 { Map as ImmutableMap } from 'immutable';
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: '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).suggestions;
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='Delete' />
</Button>
</div>
)}
</HStack>
);
};
interface IPollForm {
composeId: string
}
const PollForm: React.FC<IPollForm> = ({ composeId }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { configuration } = useInstance();
const compose = useCompose(composeId);
const pollLimits = configuration.get('polls') as ImmutableMap<string, number>;
const options = compose.poll?.options;
const expiresIn = compose.poll?.expires_in;
const isMultiple = compose.poll?.multiple;
const maxOptions = pollLimits.get('max_options') as number;
const maxOptionChars = pollLimits.get('max_characters_per_option') as number;
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 default PollForm;

View File

@ -0,0 +1,270 @@
import clsx from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import React, { useState, useRef, useEffect } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { spring } from 'react-motion';
// @ts-ignore
import Overlay from 'react-overlays/lib/Overlay';
import { changeComposeVisibility } from 'soapbox/actions/compose';
import { closeModal, openModal } from 'soapbox/actions/modals';
import Icon from 'soapbox/components/icon';
import { IconButton } from 'soapbox/components/ui';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is-mobile';
import Motion from '../../ui/util/optional-motion';
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' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust post privacy' },
});
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
interface IPrivacyDropdownMenu {
style?: React.CSSProperties
items: any[]
value: string
placement: string
onClose: () => void
onChange: (value: string | null) => void
unavailable?: boolean
}
const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({ style, items, placement, value, onClose, onChange }) => {
const node = useRef<HTMLDivElement>(null);
const focusedItem = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState<boolean>(false);
const handleDocumentClick = (e: MouseEvent | TouchEvent) => {
if (node.current && !node.current.contains(e.target as HTMLElement)) {
onClose();
}
};
const handleKeyDown: React.KeyboardEventHandler = e => {
const value = e.currentTarget.getAttribute('data-index');
const index = items.findIndex(item => item.value === value);
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();
onChange((element as HTMLElement).getAttribute('data-index'));
e.preventDefault();
e.stopPropagation();
}
};
const handleClick: React.EventHandler<any> = (e: MouseEvent | KeyboardEvent) => {
const value = (e.currentTarget as HTMLElement)?.getAttribute('data-index');
e.preventDefault();
onClose();
onChange(value);
};
useEffect(() => {
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
focusedItem.current?.focus({ preventScroll: true });
setMounted(true);
return () => {
document.removeEventListener('click', handleDocumentClick, false);
document.removeEventListener('touchend', handleDocumentClick);
};
}, []);
return (
<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 }) => (
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div className={clsx('privacy-dropdown__dropdown', placement)} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }} role='listbox' ref={node}>
{items.map(item => (
<div role='option' tabIndex={0} key={item.value} data-index={item.value} onKeyDown={handleKeyDown} onClick={handleClick} className={clsx('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? focusedItem : null}>
<div className='privacy-dropdown__option__icon'>
<Icon src={item.icon} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
</div>
))}
</div>
)}
</Motion>
);
};
interface IPrivacyDropdown {
composeId: string
}
const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
composeId,
}) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const node = useRef<HTMLDivElement>(null);
const activeElement = useRef<HTMLElement | null>(null);
const compose = useCompose(composeId);
const value = compose.privacy;
const unavailable = compose.id;
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState('bottom');
const options = [
{ icon: require('@tabler/icons/world.svg'), value: 'public', text: intl.formatMessage(messages.public_short), meta: intl.formatMessage(messages.public_long) },
{ icon: require('@tabler/icons/lock-open.svg'), value: 'unlisted', text: intl.formatMessage(messages.unlisted_short), meta: intl.formatMessage(messages.unlisted_long) },
{ icon: require('@tabler/icons/lock.svg'), value: 'private', text: intl.formatMessage(messages.private_short), meta: intl.formatMessage(messages.private_long) },
{ icon: require('@tabler/icons/mail.svg'), value: 'direct', text: intl.formatMessage(messages.direct_short), meta: intl.formatMessage(messages.direct_long) },
];
const onChange = (value: string | null) => value && dispatch(changeComposeVisibility(composeId, value));
const onModalOpen = (props: Record<string, any>) => dispatch(openModal('ACTIONS', props));
const onModalClose = () => dispatch(closeModal('ACTIONS'));
const handleToggle: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (isUserTouching()) {
if (open) {
onModalClose();
} else {
onModalOpen({
actions: options.map(option => ({ ...option, active: option.value === value })),
onClick: handleModalActionClick,
});
}
} else {
const { top } = e.currentTarget.getBoundingClientRect();
if (open) {
activeElement.current?.focus();
}
setPlacement(top * 2 < innerHeight ? 'bottom' : 'top');
setOpen(!open);
}
e.stopPropagation();
};
const handleModalActionClick: React.MouseEventHandler = (e) => {
e.preventDefault();
const { value } = options[e.currentTarget.getAttribute('data-index') as any];
onModalClose();
onChange(value);
};
const handleKeyDown: React.KeyboardEventHandler = e => {
switch (e.key) {
case 'Escape':
handleClose();
break;
}
};
const handleMouseDown = () => {
if (!open) {
activeElement.current = document.activeElement as HTMLElement | null;
}
};
const handleButtonKeyDown: React.KeyboardEventHandler = (e) => {
switch (e.key) {
case ' ':
case 'Enter':
handleMouseDown();
break;
}
};
const handleClose = () => {
if (open) {
activeElement.current?.focus();
}
setOpen(false);
};
if (unavailable) {
return null;
}
const valueOption = options.find(item => item.value === value);
return (
<div className={clsx('privacy-dropdown', placement, { active: open })} onKeyDown={handleKeyDown} ref={node}>
<div className={clsx('privacy-dropdown__value', { active: valueOption && options.indexOf(valueOption) === 0 })}>
<IconButton
className={clsx({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': !open,
'text-primary-500 hover:text-primary-600 dark:text-primary-500 dark:hover:text-primary-400': open,
})}
src={valueOption?.icon}
title={intl.formatMessage(messages.change_privacy)}
onClick={handleToggle}
onMouseDown={handleMouseDown}
onKeyDown={handleButtonKeyDown}
/>
</div>
<Overlay show={open} placement={placement} target={node.current}>
<PrivacyDropdownMenu
items={options}
value={value}
onClose={handleClose}
onChange={onChange}
placement={placement}
/>
</Overlay>
</div>
);
};
export default PrivacyDropdown;

View File

@ -0,0 +1,42 @@
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 { Group } from 'soapbox/schemas';
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 as 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={`/group/${group.slug}`}
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
/>,
}}
/>
</Text>
);
};
export default ReplyGroupIndicator;

View File

@ -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 { isRtl } from 'soapbox/rtl';
import type { Status } from 'soapbox/types/entities';
interface IReplyIndicator {
className?: string
status?: Status
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/x.svg'),
actionAlignment: 'top',
actionTitle: 'Dismiss',
};
}
return (
<Stack space={2} className={clsx('rounded-lg bg-gray-100 p-4 dark:bg-gray-800', className)}>
<AccountContainer
{...actions}
id={status.getIn(['account', 'id']) as string}
timestamp={status.created_at}
showProfileHoverCard={false}
withLinkToProfile={false}
hideActions={hideActions}
/>
<Markup
className='break-words'
size='sm'
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
direction={isRtl(status.search_index) ? 'rtl' : 'ltr'}
/>
{status.media_attachments.size > 0 && (
<AttachmentThumbs
media={status.media_attachments}
sensitive={status.sensitive}
/>
)}
</Stack>
);
};
export default ReplyIndicator;

View File

@ -0,0 +1,83 @@
import React, { useCallback } from 'react';
import { FormattedList, FormattedMessage } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import { useAppDispatch, useAppSelector, useCompose, useFeatures, useOwnAccount } from 'soapbox/hooks';
import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose';
import { makeGetStatus } from 'soapbox/selectors';
import { isPubkey } from 'soapbox/utils/nostr';
import type { Status as StatusEntity } from 'soapbox/types/entities';
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<StatusEntity | null>(state => getStatus(state, { id: compose.in_reply_to! }));
const to = compose.to;
const { account } = useOwnAccount();
if (!features.explicitAddressing || !status || !to) {
return null;
}
const parentTo = status && statusToMentionsAccountIdsArray(status, account!);
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
dispatch(openModal('REPLY_MENTIONS', {
composeId,
}));
};
if (!parentTo || (parentTo.size === 0)) {
return null;
}
if (to.size === 0) {
return (
<a href='#' className='reply-mentions' 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 className='reply-mentions__account'>
@{isPubkey(username) ? username.slice(0, 8) : username}
</span>
);
}).toArray();
if (to.size > 2) {
accounts.push(
<FormattedMessage id='reply_mentions.more' defaultMessage='{count} more' values={{ count: to.size - 2 }} />,
);
}
return (
<a href='#' className='reply-mentions' onClick={handleClick}>
<FormattedMessage
id='reply_mentions.reply'
defaultMessage='Replying to {accounts}'
values={{
accounts: <FormattedList type='conjunction' value={accounts} />,
}}
/>
</a>
);
};
export default ReplyMentions;

View File

@ -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/calendar-stats.svg')}
title={intl.formatMessage(active ? messages.remove_schedule : messages.add_schedule)}
active={active}
disabled={disabled}
onClick={handleClick}
/>
);
};
export default ScheduleButton;

View File

@ -0,0 +1,86 @@
import clsx from 'clsx';
import React 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, Stack, Text } from 'soapbox/components/ui';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import { DatePicker } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
export const isCurrentOrFutureDate = (date: Date) => {
return 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' },
});
export 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'>
<BundleContainer fetchComponent={DatePicker}>
{Component => (<Component
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),
})}
/>)}
</BundleContainer>
<IconButton
iconClassName='h-4 w-4'
className='bg-transparent text-gray-400 hover:text-gray-600'
src={require('@tabler/icons/x.svg')}
onClick={handleRemove}
title={intl.formatMessage(messages.remove)}
/>
</HStack>
</Stack>
);
};
export default ScheduleForm;

View File

@ -0,0 +1,242 @@
import clsx from 'clsx';
import React, { useEffect, useRef } 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 } 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 { HStack, 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 } from 'soapbox/hooks';
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
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' },
});
const SearchResults = () => {
const node = useRef<VirtuosoHandle>(null);
const intl = useIntl();
const dispatch = useAppDispatch();
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 { account } = useAccount(filterByAccount);
const handleLoadMore = () => dispatch(expandSearch(selectedFilter));
const handleUnsetAccount = () => dispatch(setSearchAccount(null));
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(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',
},
);
items.push(
{
text: intl.formatMessage(messages.hashtags),
action: () => selectFilter('hashtags'),
name: 'hashtags',
},
);
return <Tabs items={items} activeItem={selectedFilter} />;
};
const getCurrentIndex = (id: string): number => {
return 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>
);
}
}
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>
);
}
}
return (
<>
{filterByAccount ? (
<HStack className='mb-4 border-b border-solid border-gray-200 px-2 pb-4 dark:border-gray-800' space={2}>
<IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/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}
className={clsx({
'divide-gray-200 dark:divide-gray-800 divide-solid divide-y': selectedFilter === 'statuses',
})}
itemClassName={clsx({
'pb-4': selectedFilter === 'accounts',
'pb-3': selectedFilter === 'hashtags',
})}
>
{searchResults || []}
</ScrollableList>
)}
</>
);
};
export default SearchResults;

View File

@ -0,0 +1,184 @@
import clsx from 'clsx';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import {
changeSearch,
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}”' },
});
function redirectToAccount(accountId: string, routerHistory: any) {
return (_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 {
autoFocus = false,
autoSubmit = false,
autosuggest = false,
openInRoute = false,
} = props;
const dispatch = useAppDispatch();
const history = useHistory();
const intl = useIntl();
const value = useAppSelector((state) => state.search.value);
const submitted = useAppSelector((state) => state.search.submitted);
const debouncedSubmit = useCallback(debounce(() => {
dispatch(submitSearch());
}, 900), []);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
dispatch(changeSearch(value));
if (autoSubmit) {
debouncedSubmit();
}
};
const handleClear = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
if (value.length > 0 || submitted) {
dispatch(clearSearchResults());
}
};
const handleSubmit = () => {
if (openInRoute) {
dispatch(setSearchAccount(null));
dispatch(submitSearch());
history.push('/search');
} else {
dispatch(submitSearch());
}
};
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/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',
};
if (autosuggest) {
componentProps.onSelected = handleSelected;
componentProps.menu = makeMenu();
componentProps.autoSelect = false;
}
useEffect(() => {
return () => {
const newPath = history.location.pathname;
const shouldPersistSearch = !!newPath.match(/@.+\/posts\/[a-zA-Z0-9]+/g)
|| !!newPath.match(/\/tags\/.+/g);
if (!shouldPersistSearch) {
dispatch(changeSearch(''));
}
};
}, []);
return (
<div className='w-full'>
<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/search.svg')}
className={clsx('h-4 w-4 text-gray-600', { hidden: hasValue })}
/>
<SvgIcon
src={require('@tabler/icons/x.svg')}
className={clsx('h-4 w-4 text-gray-600', { hidden: !hasValue })}
aria-label={intl.formatMessage(messages.placeholder)}
/>
</div>
</div>
</div>
);
};
export default Search;

View File

@ -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: 'Text is hidden behind warning' },
unmarked: { id: 'compose_form.spoiler.unmarked', defaultMessage: 'Text is not hidden' },
});
interface ISpoilerButton {
composeId: string
}
const SpoilerButton: React.FC<ISpoilerButton> = ({ composeId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const active = useCompose(composeId).spoiler;
const onClick = () =>
dispatch(changeComposeSpoilerness(composeId));
return (
<ComposeFormButton
icon={require('@tabler/icons/alert-triangle.svg')}
title={intl.formatMessage(active ? messages.marked : messages.unmarked)}
active={active}
onClick={onClick}
/>
);
};
export default SpoilerButton;

View File

@ -0,0 +1,80 @@
import clsx from 'clsx';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeComposeSpoilerness, changeComposeSpoilerText } from 'soapbox/actions/compose';
import AutosuggestInput, { IAutosuggestInput } from 'soapbox/components/autosuggest-input';
import { Divider, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
const messages = defineMessages({
title: { id: 'compose_form.spoiler_title', defaultMessage: 'Sensitive content' },
placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here (optional)' },
remove: { id: 'compose_form.spoiler_remove', defaultMessage: 'Remove sensitive' },
});
interface ISpoilerInput extends Pick<IAutosuggestInput, 'onSuggestionsFetchRequested' | 'onSuggestionsClearRequested' | 'onSuggestionSelected'> {
composeId: string extends 'default' ? never : string
}
/** Text input for content warning in composer. */
const SpoilerInput = React.forwardRef<AutosuggestInput, ISpoilerInput>(({
composeId,
onSuggestionsFetchRequested,
onSuggestionsClearRequested,
onSuggestionSelected,
}, ref) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const compose = useCompose(composeId);
const handleChangeSpoilerText: React.ChangeEventHandler<HTMLInputElement> = (e) => {
dispatch(changeComposeSpoilerText(composeId, e.target.value));
};
const handleRemove = () => {
dispatch(changeComposeSpoilerness(composeId));
};
return (
<Stack
space={4}
className={clsx({
'relative transition-height': true,
'hidden': !compose.spoiler,
})}
>
<Divider />
<Stack space={2}>
<Text weight='medium'>
{intl.formatMessage(messages.title)}
</Text>
<AutosuggestInput
placeholder={intl.formatMessage(messages.placeholder)}
value={compose.spoiler_text}
onChange={handleChangeSpoilerText}
disabled={!compose.spoiler}
suggestions={compose.suggestions}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='rounded-md !bg-transparent dark:!bg-transparent'
ref={ref}
autoFocus
/>
<div className='text-center'>
<button type='button' className='text-danger-500' onClick={handleRemove}>
{intl.formatMessage(messages.remove)}
</button>
</div>
</Stack>
</Stack>
);
});
export default SpoilerInput;

View File

@ -0,0 +1,28 @@
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) => {
return (
<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 default TextCharacterCounter;

View File

@ -0,0 +1,91 @@
import React, { useRef } from 'react';
import { defineMessages, IntlShape, useIntl } from 'react-intl';
import { IconButton } from 'soapbox/components/ui';
import { useInstance } from 'soapbox/hooks';
import type { List as ImmutableList } from 'immutable';
const messages = defineMessages({
upload: { id: 'upload_button.label', defaultMessage: 'Add media attachment' },
});
export const onlyImages = (types: ImmutableList<string>) => {
return Boolean(types && types.every(type => type.startsWith('image/')));
};
export 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.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>;
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/photo.svg')
: require('@tabler/icons/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 && attachmentTypes.toArray().join(',')}
onChange={handleChange}
disabled={disabled}
className='hidden'
/>
</label>
</div>
);
};
export default UploadButton;

View File

@ -0,0 +1,32 @@
import clsx from 'clsx';
import React from 'react';
import { HStack } from 'soapbox/components/ui';
import { useCompose } from 'soapbox/hooks';
import Upload from './upload';
import UploadProgress from './upload-progress';
import type { Attachment as AttachmentEntity } from 'soapbox/types/entities';
interface IUploadForm {
composeId: string
}
const UploadForm: React.FC<IUploadForm> = ({ composeId }) => {
const mediaIds = useCompose(composeId).media_attachments.map((item: AttachmentEntity) => item.id);
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} />
))}
</HStack>
</div>
);
};
export default UploadForm;

View File

@ -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 default ComposeUploadProgress;

View File

@ -0,0 +1,44 @@
import React from 'react';
import { useHistory } from 'react-router-dom';
import { undoUploadCompose, changeUploadCompose, submitCompose } from 'soapbox/actions/compose';
import Upload from 'soapbox/components/upload';
import { useAppDispatch, useCompose, useInstance } from 'soapbox/hooks';
interface IUploadCompose {
id: string
composeId: string
}
const UploadCompose: React.FC<IUploadCompose> = ({ composeId, id }) => {
const history = useHistory();
const dispatch = useAppDispatch();
const { description_limit: descriptionLimit } = useInstance();
const media = useCompose(composeId).media_attachments.find(item => item.id === id)!;
const handleSubmit = () => {
dispatch(submitCompose(composeId, history));
};
const handleDescriptionChange = (description: string) => {
dispatch(changeUploadCompose(composeId, media.id, { description }));
};
const handleDelete = () => {
dispatch(undoUploadCompose(composeId, media.id));
};
return (
<Upload
media={media}
onDelete={handleDelete}
onDescriptionChange={handleDescriptionChange}
onSubmit={handleSubmit}
descriptionLimit={descriptionLimit}
withPreview
/>
);
};
export default UploadCompose;

View File

@ -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 default VisualCharacterCounter;

View 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='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
{message}
</div>
)}
</Motion>
);
export default Warning;

View File

@ -0,0 +1,38 @@
import React, { useCallback } from 'react';
import { cancelQuoteCompose } from 'soapbox/actions/compose';
import QuotedStatus from 'soapbox/components/quoted-status';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetStatus } from 'soapbox/selectors';
interface IQuotedStatusContainer {
composeId: string
}
/** QuotedStatus shown in post composer. */
const QuotedStatusContainer: React.FC<IQuotedStatusContainer> = ({ composeId }) => {
const dispatch = useAppDispatch();
const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector(state => getStatus(state, { id: state.compose.get(composeId)?.quote! }));
const onCancel = () => {
dispatch(cancelQuoteCompose());
};
if (!status) {
return null;
}
return (
<div className='mb-2'>
<QuotedStatus
status={status}
onCancel={onCancel}
compose
/>
</div>
);
};
export default QuotedStatusContainer;

View File

@ -0,0 +1,35 @@
import { connect } from 'react-redux';
import { cancelReplyCompose } from 'soapbox/actions/compose';
import { makeGetStatus } from 'soapbox/selectors';
import ReplyIndicator from '../components/reply-indicator';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { Status } from 'soapbox/types/entities';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state: RootState, { composeId }: { composeId: string }) => {
const statusId = state.compose.get(composeId)?.in_reply_to!;
const editing = !!state.compose.get(composeId)?.id;
return {
status: getStatus(state, { id: statusId }) as Status,
hideActions: editing,
};
};
return mapStateToProps;
};
const mapDispatchToProps = (dispatch: AppDispatch) => ({
onCancel() {
dispatch(cancelReplyCompose());
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);

View File

@ -0,0 +1,14 @@
import React from 'react';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import { ScheduleForm } from 'soapbox/features/ui/util/async-components';
import type { IScheduleForm } from '../components/schedule-form';
const ScheduleFormContainer: React.FC<IScheduleForm> = (props) => (
<BundleContainer fetchComponent={ScheduleForm}>
{Component => <Component {...props} />}
</BundleContainer>
);
export default ScheduleFormContainer;

View File

@ -0,0 +1,23 @@
import { connect } from 'react-redux';
import { uploadCompose } from 'soapbox/actions/compose';
import UploadButton from '../components/upload-button';
import type { IntlShape } from 'react-intl';
import type { AppDispatch, RootState } from 'soapbox/store';
const mapStateToProps = (state: RootState, { composeId }: { composeId: string }) => ({
disabled: state.compose.get(composeId)?.is_uploading,
resetFileKey: state.compose.get(composeId)?.resetFileKey!,
});
const mapDispatchToProps = (dispatch: AppDispatch, { composeId }: { composeId: string }) => ({
onSelectFile(files: FileList, intl: IntlShape) {
dispatch(uploadCompose(composeId, files, intl));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);

View File

@ -0,0 +1,72 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { useAppSelector, useCompose } from 'soapbox/hooks';
import { selectOwnAccount } from 'soapbox/selectors';
import Warning from '../components/warning';
const APPROX_HASHTAG_RE = /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i;
interface IWarningWrapper {
composeId: string
}
const WarningWrapper: React.FC<IWarningWrapper> = ({ composeId }) => {
const compose = useCompose(composeId);
const needsLockWarning = useAppSelector((state) => compose.privacy === 'private' && !selectOwnAccount(state)!.locked);
const hashtagWarning = (compose.privacy !== 'public' && compose.privacy !== 'group') && APPROX_HASHTAG_RE.test(compose.text);
const directMessageWarning = compose.privacy === 'direct';
if (needsLockWarning) {
return (
<Warning
message={(
<FormattedMessage
id='compose_form.lock_disclaimer'
defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.'
values={{
locked: (
<Link to='/settings/profile'>
<FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' />
</Link>
),
}}
/>
)}
/>
);
}
if (hashtagWarning) {
return (
<Warning
message={(
<FormattedMessage
id='compose_form.hashtag_warning'
defaultMessage="This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag."
/>
)}
/>
);
}
if (directMessageWarning) {
const message = (
<span>
<FormattedMessage
id='compose_form.direct_message_warning'
defaultMessage='This post will only be sent to the mentioned users.'
/>
</span>
);
return <Warning message={message} />;
}
return null;
};
export default WarningWrapper;

View File

@ -0,0 +1,17 @@
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,11 @@
import { $createImageNode } from '../nodes/image-node';
import type { ImportHandler } from '@mkljczk/lexical-remark';
import type { LexicalNode } from 'lexical';
const importImage: ImportHandler<any> /* TODO */ = (node: LexicalNode, parser) => {
const lexicalNode = $createImageNode({ altText: node.alt ?? '', src: node.url });
parser.append(lexicalNode);
};
export { importImage };

View File

@ -0,0 +1,204 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { AutoLinkPlugin, createLinkMatcherWithRegExp } from '@lexical/react/LexicalAutoLinkPlugin';
import { LexicalComposer, InitialConfigType } from '@lexical/react/LexicalComposer';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
import { HashtagPlugin } from '@lexical/react/LexicalHashtagPlugin';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { $createRemarkExport, $createRemarkImport } from '@mkljczk/lexical-remark';
import clsx from 'clsx';
import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical';
import React, { useMemo, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
import { importImage } from './handlers/image';
import { useNodes } from './nodes';
import AutosuggestPlugin from './plugins/autosuggest-plugin';
import FloatingBlockTypeToolbarPlugin from './plugins/floating-block-type-toolbar-plugin';
import FloatingLinkEditorPlugin from './plugins/floating-link-editor-plugin';
import FloatingTextFormatToolbarPlugin from './plugins/floating-text-format-toolbar-plugin';
import FocusPlugin from './plugins/focus-plugin';
import MentionPlugin from './plugins/mention-plugin';
import StatePlugin from './plugins/state-plugin';
const LINK_MATCHERS = [
createLinkMatcherWithRegExp(
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/,
(text) => text.startsWith('http') ? text : `https://${text}`,
),
];
interface IComposeEditor {
className?: string
placeholderClassName?: string
composeId: string
condensed?: boolean
eventDiscussion?: boolean
hasPoll?: boolean
autoFocus?: boolean
handleSubmit?: () => void
onFocus?: React.FocusEventHandler<HTMLDivElement>
onPaste?: (files: FileList) => void
placeholder?: JSX.Element | string
}
const theme = {
hashtag: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue',
mention: 'hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue',
text: {
bold: 'font-bold',
code: 'font-mono',
italic: 'italic',
strikethrough: 'line-through',
underline: 'underline',
underlineStrikethrough: 'underline-line-through',
},
heading: {
h1: 'text-2xl font-bold',
h2: 'text-xl font-bold',
h3: 'text-lg font-semibold',
},
};
const ComposeEditor = React.forwardRef<string, IComposeEditor>(({
className,
placeholderClassName,
composeId,
condensed,
eventDiscussion,
hasPoll,
autoFocus,
handleSubmit,
onFocus,
onPaste,
placeholder,
}, editorStateRef) => {
const dispatch = useAppDispatch();
const features = useFeatures();
const nodes = useNodes();
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
const initialConfig: InitialConfigType = useMemo(() => ({
namespace: 'ComposeForm',
onError: console.error,
nodes,
theme,
editorState: dispatch((_, getState) => {
const state = getState();
const compose = state.compose.get(composeId);
if (!compose) return;
if (compose.editorState) {
return compose.editorState;
}
return () => {
if (compose.content_type === 'text/markdown') {
$createRemarkImport({
handlers: {
image: importImage,
},
})(compose.text);
} else {
const paragraph = $createParagraphNode();
const textNode = $createTextNode(compose.text);
paragraph.append(textNode);
$getRoot()
.clear()
.append(paragraph);
}
};
}),
}), []);
const [floatingAnchorElem, setFloatingAnchorElem] =
useState<HTMLDivElement | null>(null);
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
if (_floatingAnchorElem !== null) {
setFloatingAnchorElem(_floatingAnchorElem);
}
};
const handlePaste: React.ClipboardEventHandler<HTMLDivElement> = (e) => {
if (onPaste && e.clipboardData && e.clipboardData.files.length === 1) {
onPaste(e.clipboardData.files);
e.preventDefault();
}
};
let textareaPlaceholder = placeholder || <FormattedMessage id='compose_form.placeholder' defaultMessage="What's on your mind?" />;
if (eventDiscussion) {
textareaPlaceholder = <FormattedMessage id='compose_form.event_placeholder' defaultMessage='Post to this event' />;
} else if (hasPoll) {
textareaPlaceholder = <FormattedMessage id='compose_form.poll_placeholder' defaultMessage='Add a poll topic…' />;
}
return (
<LexicalComposer initialConfig={initialConfig}>
<div className={clsx('lexical relative', className)} data-markup>
<RichTextPlugin
contentEditable={
<div className='editor' ref={onRef} onFocus={onFocus} onPaste={handlePaste}>
<ContentEditable
className={clsx('outline-none transition-[min-height] motion-reduce:transition-none', {
'min-h-[40px]': condensed,
'min-h-[100px]': !condensed,
})}
/>
</div>
}
placeholder={(
<div
className={clsx(
'pointer-events-none absolute top-0 select-none text-gray-600 dark:placeholder:text-gray-600',
placeholderClassName,
)}
>
{textareaPlaceholder}
</div>
)}
ErrorBoundary={LexicalErrorBoundary}
/>
<OnChangePlugin onChange={(_, editor) => {
if (editorStateRef) (editorStateRef as any).current = editor.getEditorState().read($createRemarkExport());
}}
/>
<HistoryPlugin />
<HashtagPlugin />
<MentionPlugin />
<AutosuggestPlugin composeId={composeId} suggestionsHidden={suggestionsHidden} setSuggestionsHidden={setSuggestionsHidden} />
<AutoLinkPlugin matchers={LINK_MATCHERS} />
{features.richText && <LinkPlugin />}
{features.richText && <ListPlugin />}
{features.richText && floatingAnchorElem && (
<>
<FloatingBlockTypeToolbarPlugin anchorElem={floatingAnchorElem} />
<FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} />
<FloatingLinkEditorPlugin anchorElem={floatingAnchorElem} />
</>
)}
<StatePlugin composeId={composeId} handleSubmit={handleSubmit} />
<FocusPlugin autoFocus={autoFocus} />
</div>
</LexicalComposer>
);
});
export default ComposeEditor;

View File

@ -0,0 +1,101 @@
import { $applyNodeReplacement, DecoratorNode } from 'lexical';
import React from 'react';
import { Emoji } from 'soapbox/components/ui';
import type {
DOMExportOutput,
EditorConfig,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from 'lexical';
type SerializedEmojiNode = Spread<{
name: string
src: string
type: 'emoji'
version: 1
}, SerializedLexicalNode>;
class EmojiNode extends DecoratorNode<JSX.Element> {
__name: string;
__src: string;
static getType(): 'emoji' {
return 'emoji';
}
static clone(node: EmojiNode): EmojiNode {
return new EmojiNode(node.__name, node.__src);
}
constructor(name: string, src: string, key?: NodeKey) {
super(key);
this.__name = name;
this.__src = src;
}
createDOM(config: EditorConfig): HTMLElement {
const span = document.createElement('span');
const theme = config.theme;
const className = theme.emoji;
if (className !== undefined) {
span.className = className;
}
return span;
}
updateDOM(): false {
return false;
}
exportDOM(): DOMExportOutput {
const element = document.createElement('img');
element.setAttribute('src', this.__src);
element.setAttribute('alt', this.__name);
element.classList.add('h-4', 'w-4');
return { element };
}
static importJSON(serializedNode: SerializedEmojiNode): EmojiNode {
const { name, src } =
serializedNode;
const node = $createEmojiNode(name, src);
return node;
}
exportJSON(): SerializedEmojiNode {
return {
name: this.__name,
src: this.__src,
type: 'emoji',
version: 1,
};
}
canInsertTextBefore(): boolean {
return false;
}
isTextEntity(): true {
return true;
}
decorate(): JSX.Element {
return (
<Emoji src={this.__src} alt={this.__name} className='emojione h-4 w-4' />
);
}
}
const $createEmojiNode = (name = '', src: string): EmojiNode => $applyNodeReplacement(new EmojiNode(name, src));
const $isEmojiNode = (
node: LexicalNode | null | undefined,
): node is EmojiNode => node instanceof EmojiNode;
export { EmojiNode, $createEmojiNode, $isEmojiNode };

View File

@ -0,0 +1,359 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
import { mergeRegister } from '@lexical/utils';
import clsx from 'clsx';
import { List as ImmutableList } from 'immutable';
import {
$getNodeByKey,
$getSelection,
$isNodeSelection,
$setSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
DRAGSTART_COMMAND,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
KEY_ENTER_COMMAND,
KEY_ESCAPE_COMMAND,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import * as React from 'react';
import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import { HStack, IconButton } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import { normalizeAttachment } from 'soapbox/normalizers';
import { $isImageNode } from './image-node';
import type {
GridSelection,
LexicalEditor,
NodeKey,
NodeSelection,
RangeSelection,
} from 'lexical';
const messages = defineMessages({
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
});
const imageCache = new Set();
const useSuspenseImage = (src: string) => {
if (!imageCache.has(src)) {
throw new Promise((resolve) => {
const img = new Image();
img.src = src;
img.onload = () => {
imageCache.add(src);
resolve(null);
};
});
}
};
const LazyImage = ({
altText,
className,
imageRef,
src,
}: {
altText: string
className: string | null
imageRef: {current: null | HTMLImageElement}
src: string
}): JSX.Element => {
useSuspenseImage(src);
return (
<img
className={className || undefined}
src={src}
alt={altText}
ref={imageRef}
draggable='false'
/>
);
};
const ImageComponent = ({
src,
altText,
nodeKey,
}: {
altText: string
nodeKey: NodeKey
src: string
}): JSX.Element => {
const intl = useIntl();
const dispatch = useAppDispatch();
const imageRef = useRef<null | HTMLImageElement>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey);
const [editor] = useLexicalComposerContext();
const [selection, setSelection] = useState<
RangeSelection | NodeSelection | GridSelection | null
>(null);
const activeEditorRef = useRef<LexicalEditor | null>(null);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [dirtyDescription, setDirtyDescription] = useState<string | null>(null);
const deleteNode = useCallback(
() => {
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if ($isImageNode(node)) {
node.remove();
}
});
},
[nodeKey],
);
const previewImage = () => {
const image = normalizeAttachment({
type: 'image',
url: src,
altText,
});
dispatch(openModal('MEDIA', { media: ImmutableList.of(image), index: 0 }));
};
const onDelete = useCallback(
(payload: KeyboardEvent) => {
if (isSelected && $isNodeSelection($getSelection())) {
const event: KeyboardEvent = payload;
event.preventDefault();
deleteNode();
}
return false;
},
[isSelected, nodeKey],
);
const onEnter = useCallback(
(event: KeyboardEvent) => {
const latestSelection = $getSelection();
const buttonElem = buttonRef.current;
if (isSelected && $isNodeSelection(latestSelection) && latestSelection.getNodes().length === 1) {
if (buttonElem !== null && buttonElem !== document.activeElement) {
event.preventDefault();
buttonElem.focus();
return true;
}
}
return false;
},
[isSelected],
);
const onEscape = useCallback(
(event: KeyboardEvent) => {
if (buttonRef.current === event.target) {
$setSelection(null);
editor.update(() => {
setSelected(true);
const parentRootElement = editor.getRootElement();
if (parentRootElement !== null) {
parentRootElement.focus();
}
});
return true;
}
return false;
},
[editor, setSelected],
);
const handleKeyDown: React.KeyboardEventHandler = (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
handleInputBlur();
}
};
const handleInputBlur = () => {
setFocused(false);
if (dirtyDescription !== null) {
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if ($isImageNode(node)) {
node.setAltText(dirtyDescription);
}
setDirtyDescription(null);
});
}
};
const handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement> = e => {
setDirtyDescription(e.target.value);
};
const handleMouseEnter = () => {
setHovered(true);
};
const handleMouseLeave = () => {
setHovered(false);
};
const handleInputFocus = () => {
setFocused(true);
};
const handleClick = () => {
setFocused(true);
};
useEffect(() => {
let isMounted = true;
const unregister = mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
if (isMounted) {
setSelection(editorState.read(() => $getSelection()));
}
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_, activeEditor) => {
activeEditorRef.current = activeEditor;
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<MouseEvent>(
CLICK_COMMAND,
(payload) => {
const event = payload;
if (event.target === imageRef.current) {
if (event.shiftKey) {
setSelected(!isSelected);
} else {
clearSelection();
setSelected(true);
}
return true;
}
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
DRAGSTART_COMMAND,
(event) => {
if (event.target === imageRef.current) {
// TODO This is just a temporary workaround for FF to behave like other browsers.
// Ideally, this handles drag & drop too (and all browsers).
event.preventDefault();
return true;
}
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_DELETE_COMMAND,
onDelete,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_BACKSPACE_COMMAND,
onDelete,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(KEY_ENTER_COMMAND, onEnter, COMMAND_PRIORITY_LOW),
editor.registerCommand(
KEY_ESCAPE_COMMAND,
onEscape,
COMMAND_PRIORITY_LOW,
),
);
return () => {
isMounted = false;
unregister();
};
}, [
clearSelection,
editor,
isSelected,
nodeKey,
onDelete,
onEnter,
onEscape,
setSelected,
]);
const active = hovered || focused;
const description = dirtyDescription || (dirtyDescription !== '' && altText) || '';
const draggable = isSelected && $isNodeSelection(selection);
return (
<Suspense fallback={null}>
<>
<div className='relative' draggable={draggable} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={handleClick} role='button'>
<HStack className='absolute right-2 top-2 z-10' space={2}>
<IconButton
onClick={previewImage}
src={require('@tabler/icons/zoom-in.svg')}
theme='dark'
className='!p-1.5 hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
/>
<IconButton
onClick={deleteNode}
src={require('@tabler/icons/x.svg')}
theme='dark'
className='!p-1.5 hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
/>
</HStack>
<div className={clsx('compose-form__upload-description', { active })}>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
<textarea
placeholder={intl.formatMessage(messages.description)}
value={description}
onFocus={handleInputFocus}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
/>
</label>
</div>
<LazyImage
className={
clsx('cursor-default', {
'select-none': isSelected,
'cursor-grab active:cursor-grabbing': isSelected && $isNodeSelection(selection),
})
}
src={src}
altText={altText}
imageRef={imageRef}
/>
</div>
</>
</Suspense>
);
};
export default ImageComponent;

View File

@ -0,0 +1,179 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { $applyNodeReplacement, DecoratorNode } from 'lexical';
import * as React from 'react';
import { Suspense } from 'react';
import type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from 'lexical';
const ImageComponent = React.lazy(() => import('./image-component'));
interface ImagePayload {
altText?: string
key?: NodeKey
src: string
}
const convertImageElement = (domNode: Node): null | DOMConversionOutput => {
if (domNode instanceof HTMLImageElement) {
const { alt: altText, src } = domNode;
const node = $createImageNode({ altText, src });
return { node };
}
return null;
};
type SerializedImageNode = Spread<
{
altText: string
src: string
},
SerializedLexicalNode
>;
class ImageNode extends DecoratorNode<JSX.Element> {
__src: string;
__altText: string;
static getType(): string {
return 'image';
}
static clone(node: ImageNode): ImageNode {
return new ImageNode(
node.__src,
node.__altText,
node.__key,
);
}
static importJSON(serializedNode: SerializedImageNode): ImageNode {
const { altText, src } =
serializedNode;
const node = $createImageNode({
altText,
src,
});
return node;
}
exportDOM(): DOMExportOutput {
const element = document.createElement('img');
element.setAttribute('src', this.__src);
element.setAttribute('alt', this.__altText);
return { element };
}
static importDOM(): DOMConversionMap | null {
return {
img: (node: Node) => ({
conversion: convertImageElement,
priority: 0,
}),
};
}
constructor(
src: string,
altText: string,
key?: NodeKey,
) {
super(key);
this.__src = src;
this.__altText = altText;
}
exportJSON(): SerializedImageNode {
return {
altText: this.getAltText(),
src: this.getSrc(),
type: 'image',
version: 1,
};
}
// View
createDOM(config: EditorConfig): HTMLElement {
const span = document.createElement('span');
const theme = config.theme;
const className = theme.image;
if (className !== undefined) {
span.className = className;
}
return span;
}
updateDOM(): false {
return false;
}
getSrc(): string {
return this.__src;
}
getAltText(): string {
return this.__altText;
}
setAltText(altText: string): void {
const writable = this.getWritable();
if (altText !== undefined) {
writable.__altText = altText;
}
}
decorate(): JSX.Element {
return (
<Suspense fallback={null}>
<ImageComponent
src={this.__src}
altText={this.__altText}
nodeKey={this.getKey()}
/>
</Suspense>
);
}
}
const $createImageNode = ({
altText = '',
src,
key,
}: ImagePayload): ImageNode => {
return $applyNodeReplacement(
new ImageNode(
src,
altText,
key,
),
);
};
const $isImageNode = (
node: LexicalNode | null | undefined,
): node is ImageNode => node instanceof ImageNode;
export {
type ImagePayload,
type SerializedImageNode,
ImageNode,
$createImageNode,
$isImageNode,
};

View File

@ -0,0 +1,51 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { CodeHighlightNode, CodeNode } from '@lexical/code';
import { HashtagNode } from '@lexical/hashtag';
import { AutoLinkNode, LinkNode } from '@lexical/link';
import { ListItemNode, ListNode } from '@lexical/list';
import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode';
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import { useFeatures, useInstance } from 'soapbox/hooks';
import { EmojiNode } from './emoji-node';
import { ImageNode } from './image-node';
import { MentionNode } from './mention-node';
import type { Klass, LexicalNode } from 'lexical';
const useNodes = () => {
const features = useFeatures();
const instance = useInstance();
const nodes: Array<Klass<LexicalNode>> = [
AutoLinkNode,
HashtagNode,
EmojiNode,
MentionNode,
];
if (features.richText) {
nodes.push(
CodeHighlightNode,
CodeNode,
HorizontalRuleNode,
LinkNode,
ListItemNode,
ListNode,
QuoteNode,
);
}
if (instance.pleroma.getIn(['metadata', 'markup', 'allow_headings'])) nodes.push(HeadingNode);
if (instance.pleroma.getIn(['metadata', 'markup', 'allow_inline_images'])) nodes.push(ImageNode);
return nodes;
};
export { useNodes };

View File

@ -0,0 +1,69 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { addClassNamesToElement } from '@lexical/utils';
import { $applyNodeReplacement, TextNode } from 'lexical';
import type {
EditorConfig,
LexicalNode,
NodeKey,
SerializedTextNode,
} from 'lexical';
class MentionNode extends TextNode {
static getType(): string {
return 'mention';
}
static clone(node: MentionNode): MentionNode {
return new MentionNode(node.__text, node.__key);
}
constructor(text: string, key?: NodeKey) {
super(text, key);
}
createDOM(config: EditorConfig): HTMLElement {
const element = super.createDOM(config);
addClassNamesToElement(element, config.theme.mention);
return element;
}
static importJSON(serializedNode: SerializedTextNode): MentionNode {
const node = $createMentionNode(serializedNode.text);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
}
exportJSON(): SerializedTextNode {
return {
...super.exportJSON(),
type: 'mention',
};
}
canInsertTextBefore(): boolean {
return false;
}
isTextEntity(): true {
return true;
}
}
const $createMentionNode = (text = ''): MentionNode => $applyNodeReplacement(new MentionNode(text));
const $isMentionNode = (
node: LexicalNode | null | undefined,
): node is MentionNode => node instanceof MentionNode;
export { MentionNode, $createMentionNode, $isMentionNode };

View File

@ -0,0 +1,69 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { addClassNamesToElement } from '@lexical/utils';
import { $applyNodeReplacement, TextNode } from 'lexical';
import type {
EditorConfig,
LexicalNode,
NodeKey,
SerializedTextNode,
} from 'lexical';
class MentionNode extends TextNode {
static getType(): string {
return 'mention';
}
static clone(node: MentionNode): MentionNode {
return new MentionNode(node.__text, node.__key);
}
constructor(text: string, key?: NodeKey) {
super(text, key);
}
createDOM(config: EditorConfig): HTMLElement {
const element = super.createDOM(config);
addClassNamesToElement(element, config.theme.mention);
return element;
}
static importJSON(serializedNode: SerializedTextNode): MentionNode {
const node = $createMentionNode(serializedNode.text);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
}
exportJSON(): SerializedTextNode {
return {
...super.exportJSON(),
type: 'mention',
};
}
canInsertTextBefore(): boolean {
return false;
}
isTextEntity(): true {
return true;
}
}
const $createMentionNode = (text = ''): MentionNode => $applyNodeReplacement(new MentionNode(text));
const $isMentionNode = (
node: LexicalNode | null | undefined,
): node is MentionNode => node instanceof MentionNode;
export { MentionNode, $createMentionNode, $isMentionNode };

View File

@ -0,0 +1,568 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { mergeRegister } from '@lexical/utils';
import clsx from 'clsx';
import {
$getSelection,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_LOW,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_UP_COMMAND,
KEY_ENTER_COMMAND,
KEY_ESCAPE_COMMAND,
KEY_TAB_COMMAND,
LexicalEditor,
RangeSelection,
} from 'lexical';
import React, {
MutableRefObject,
ReactPortal,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import ReactDOM from 'react-dom';
import { fetchComposeSuggestions } from 'soapbox/actions/compose';
import { useEmoji } from 'soapbox/actions/emojis';
import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji';
import { isNativeEmoji } from 'soapbox/features/emoji';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import { selectAccount } from 'soapbox/selectors';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import AutosuggestAccount from '../../components/autosuggest-account';
import { $createEmojiNode } from '../nodes/emoji-node';
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
type QueryMatch = {
leadOffset: number
matchingString: string
};
type Resolution = {
match: QueryMatch
getRect: () => DOMRect
};
type MenuRenderFn = (
anchorElementRef: MutableRefObject<HTMLElement | null>,
) => ReactPortal | JSX.Element | null;
const tryToPositionRange = (leadOffset: number, range: Range): boolean => {
const domSelection = window.getSelection();
if (domSelection === null || !domSelection.isCollapsed) {
return false;
}
const anchorNode = domSelection.anchorNode;
const startOffset = leadOffset;
const endOffset = domSelection.anchorOffset;
if (!anchorNode || !endOffset) {
return false;
}
try {
range.setStart(anchorNode, startOffset);
range.setEnd(anchorNode, endOffset);
} catch (error) {
return false;
}
return true;
};
const isSelectionOnEntityBoundary = (
editor: LexicalEditor,
offset: number,
): boolean => {
if (offset !== 0) {
return false;
}
return editor.getEditorState().read(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const anchor = selection.anchor;
const anchorNode = anchor.getNode();
const prevSibling = anchorNode.getPreviousSibling();
return $isTextNode(prevSibling) && prevSibling.isTextEntity();
}
return false;
});
};
const startTransition = (callback: () => void) => {
if (React.startTransition) {
React.startTransition(callback);
} else {
callback();
}
};
// Got from https://stackoverflow.com/a/42543908/2013580
const getScrollParent = (
element: HTMLElement,
includeHidden: boolean,
): HTMLElement | HTMLBodyElement => {
let style = getComputedStyle(element);
const excludeStaticParent = style.position === 'absolute';
const overflowRegex = includeHidden
? /(auto|scroll|hidden)/
: /(auto|scroll)/;
if (style.position === 'fixed') {
return document.body;
}
for (let parent: HTMLElement | null = element; (parent = parent.parentElement);) {
style = getComputedStyle(parent);
if (excludeStaticParent && style.position === 'static') {
continue;
}
if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX)) {
return parent;
}
}
return document.body;
};
const isTriggerVisibleInNearestScrollContainer = (
targetElement: HTMLElement,
containerElement: HTMLElement,
): boolean => {
const tRect = targetElement.getBoundingClientRect();
const cRect = containerElement.getBoundingClientRect();
return tRect.top > cRect.top && tRect.top < cRect.bottom;
};
// Reposition the menu on scroll, window resize, and element resize.
const useDynamicPositioning = (
resolution: Resolution | null,
targetElement: HTMLElement | null,
onReposition: () => void,
onVisibilityChange?: (isInView: boolean) => void,
) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (targetElement && resolution) {
const rootElement = editor.getRootElement();
const rootScrollParent =
rootElement
? getScrollParent(rootElement, false)
: document.body;
let ticking = false;
let previousIsInView = isTriggerVisibleInNearestScrollContainer(
targetElement,
rootScrollParent,
);
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
onReposition();
ticking = false;
});
ticking = true;
}
const isInView = isTriggerVisibleInNearestScrollContainer(
targetElement,
rootScrollParent,
);
if (isInView !== previousIsInView) {
previousIsInView = isInView;
if (onVisibilityChange) {
onVisibilityChange(isInView);
}
}
};
const resizeObserver = new ResizeObserver(onReposition);
window.addEventListener('resize', onReposition);
document.addEventListener('scroll', handleScroll, {
capture: true,
passive: true,
});
resizeObserver.observe(targetElement);
return () => {
resizeObserver.unobserve(targetElement);
window.removeEventListener('resize', onReposition);
document.removeEventListener('scroll', handleScroll);
};
}
}, [targetElement, editor, onVisibilityChange, onReposition, resolution]);
};
const LexicalPopoverMenu = ({ anchorElementRef, menuRenderFn }: {
anchorElementRef: MutableRefObject<HTMLElement>
menuRenderFn: MenuRenderFn
}): JSX.Element | null => menuRenderFn(anchorElementRef);
const useMenuAnchorRef = (
resolution: Resolution | null,
setResolution: (r: Resolution | null) => void,
): MutableRefObject<HTMLElement> => {
const [editor] = useLexicalComposerContext();
const anchorElementRef = useRef<HTMLElement>(document.createElement('div'));
const positionMenu = useCallback(() => {
const rootElement = editor.getRootElement();
const containerDiv = anchorElementRef.current;
if (rootElement !== null && resolution !== null) {
const { left, top, width, height } = resolution.getRect();
containerDiv.style.top = `${top + window.pageYOffset}px`;
containerDiv.style.left = `${left + window.pageXOffset}px`;
containerDiv.style.height = `${height}px`;
containerDiv.style.width = `${width}px`;
if (!containerDiv.isConnected) {
containerDiv.setAttribute('aria-label', 'Typeahead menu');
containerDiv.setAttribute('id', 'typeahead-menu');
containerDiv.setAttribute('role', 'listbox');
containerDiv.style.display = 'block';
containerDiv.style.position = 'absolute';
document.body.append(containerDiv);
}
anchorElementRef.current = containerDiv;
rootElement.setAttribute('aria-controls', 'typeahead-menu');
}
}, [editor, resolution]);
useEffect(() => {
const rootElement = editor.getRootElement();
if (resolution !== null) {
positionMenu();
return () => {
if (rootElement !== null) {
rootElement.removeAttribute('aria-controls');
}
const containerDiv = anchorElementRef.current;
if (containerDiv !== null && containerDiv.isConnected) {
containerDiv.remove();
}
};
}
}, [editor, positionMenu, resolution]);
const onVisibilityChange = useCallback(
(isInView: boolean) => {
if (resolution !== null) {
if (!isInView) {
setResolution(null);
}
}
},
[resolution, setResolution],
);
useDynamicPositioning(
resolution,
anchorElementRef.current,
positionMenu,
onVisibilityChange,
);
return anchorElementRef;
};
type AutosuggestPluginProps = {
composeId: string
suggestionsHidden: boolean
setSuggestionsHidden: (value: boolean) => void
};
const AutosuggestPlugin = ({
composeId,
suggestionsHidden,
setSuggestionsHidden,
}: AutosuggestPluginProps): JSX.Element | null => {
const { suggestions } = useCompose(composeId);
const dispatch = useAppDispatch();
const [editor] = useLexicalComposerContext();
const [resolution, setResolution] = useState<Resolution | null>(null);
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
const anchorElementRef = useMenuAnchorRef(
resolution,
setResolution,
);
const handleSelectSuggestion: React.MouseEventHandler<HTMLDivElement> = (e) => {
e.preventDefault();
const index = e.currentTarget.getAttribute('data-index');
return onSelectSuggestion(index);
};
const onSelectSuggestion = (index: any) => {
const suggestion = suggestions.get(index) as AutoSuggestion;
editor.update(() => {
dispatch((dispatch, getState) => {
const state = editor.getEditorState();
const node = (state._selection as RangeSelection)?.anchor?.getNode();
if (typeof suggestion === 'object') {
if (!suggestion.id) return;
dispatch(useEmoji(suggestion)); // eslint-disable-line react-hooks/rules-of-hooks
const { leadOffset, matchingString } = resolution!.match;
if (isNativeEmoji(suggestion)) {
node.spliceText(leadOffset - 1, matchingString.length, `${suggestion.native} `, true);
} else {
const completion = suggestion.colons;
let emojiText;
if (leadOffset === 1) emojiText = node;
else [, emojiText] = node.splitText(leadOffset - 1);
[emojiText] = emojiText.splitText(matchingString.length);
emojiText?.replace($createEmojiNode(completion, suggestion.imageUrl));
}
} else if (suggestion[0] === '#') {
node.setTextContent(`${suggestion} `);
node.select();
} else {
const content = selectAccount(getState(), suggestion)!.acct;
node.setTextContent(`@${content} `);
node.select();
}
});
});
};
const getQueryTextForSearch = (editor: LexicalEditor) => {
const state = editor.getEditorState();
const node = (state._selection as RangeSelection)?.anchor?.getNode();
if (!node) return null;
if (['mention', 'hashtag'].includes(node.getType())) {
const matchingString = node.getTextContent();
return { leadOffset: 0, matchingString };
}
if (node.getType() === 'text') {
const [leadOffset, matchingString] = textAtCursorMatchesToken(node.getTextContent(), (state._selection as RangeSelection)?.anchor?.offset, [':']);
if (!leadOffset || !matchingString) return null;
return { leadOffset, matchingString };
}
return null;
};
const renderSuggestion = (suggestion: AutoSuggestion, i: number) => {
let inner: string | JSX.Element;
let key: React.Key;
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
} else if (suggestion[0] === '#') {
inner = suggestion;
key = suggestion;
} else {
inner = <AutosuggestAccount id={suggestion} />;
key = suggestion;
}
return (
<div
role='button'
tabIndex={0}
key={key}
data-index={i}
className={clsx({
'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 group': true,
'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800': i === selectedSuggestion,
})}
onMouseDown={handleSelectSuggestion}
>
{inner}
</div>
);
};
const closeTypeahead = useCallback(() => {
setResolution(null);
}, [resolution]);
const openTypeahead = useCallback(
(res: Resolution) => {
setResolution(res);
},
[resolution],
);
useEffect(() => {
const updateListener = () => {
editor.getEditorState().read(() => {
const range = document.createRange();
const match = getQueryTextForSearch(editor);
const nativeSelection = window.getSelection();
if (!match || nativeSelection?.anchorOffset !== nativeSelection?.focusOffset) {
closeTypeahead();
return;
}
dispatch(fetchComposeSuggestions(composeId, match.matchingString.trim()));
if (!isSelectionOnEntityBoundary(editor, match.leadOffset)) {
const isRangePositioned = tryToPositionRange(match.leadOffset, range);
if (isRangePositioned !== null) {
startTransition(() =>
openTypeahead({
getRect: () => range.getBoundingClientRect(),
match,
}),
);
return;
}
}
closeTypeahead();
});
};
const removeUpdateListener = editor.registerUpdateListener(updateListener);
return () => {
removeUpdateListener();
};
}, [
editor,
resolution,
closeTypeahead,
openTypeahead,
]);
useEffect(() => {
if (suggestions && suggestions.size > 0) setSuggestionsHidden(false);
}, [suggestions]);
useEffect(() => {
if (resolution !== null && !suggestionsHidden && !suggestions.isEmpty()) {
const handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!editor._rootElement?.contains(target) && !anchorElementRef.current.contains(target)) {
setResolution(null);
}
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}
}, [resolution, suggestionsHidden, suggestions.isEmpty()]);
useEffect(() => {
if (resolution === null) return;
return mergeRegister(
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_UP_COMMAND,
(payload) => {
const event = payload;
if (suggestions !== null && suggestions.size && selectedSuggestion !== null) {
const newSelectedSuggestion = selectedSuggestion !== 0 ? selectedSuggestion - 1 : suggestions.size - 1;
setSelectedSuggestion(newSelectedSuggestion);
event.preventDefault();
event.stopImmediatePropagation();
}
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_DOWN_COMMAND,
(payload) => {
const event = payload;
if (suggestions !== null && suggestions.size && selectedSuggestion !== null) {
const newSelectedSuggestion = selectedSuggestion !== suggestions.size - 1 ? selectedSuggestion + 1 : 0;
setSelectedSuggestion(newSelectedSuggestion);
event.preventDefault();
event.stopImmediatePropagation();
}
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<KeyboardEvent>(
KEY_TAB_COMMAND,
(payload) => {
const event = payload;
if (suggestions !== null && suggestions.size && selectedSuggestion !== null) {
const newSelectedSuggestion = event.shiftKey
? (selectedSuggestion !== 0 ? selectedSuggestion - 1 : suggestions.size - 1)
: (selectedSuggestion !== suggestions.size - 1 ? selectedSuggestion + 1 : 0);
setSelectedSuggestion(newSelectedSuggestion);
event.preventDefault();
event.stopImmediatePropagation();
}
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<KeyboardEvent>(
KEY_ENTER_COMMAND,
(payload) => {
const event = payload;
event.preventDefault();
event.stopImmediatePropagation();
onSelectSuggestion(selectedSuggestion);
setResolution(null);
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<KeyboardEvent>(
KEY_ESCAPE_COMMAND,
(payload) => {
const event = payload;
event.preventDefault();
event.stopImmediatePropagation();
setResolution(null);
return true;
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, selectedSuggestion, resolution]);
return resolution === null || editor === null ? null : (
<LexicalPopoverMenu
anchorElementRef={anchorElementRef}
menuRenderFn={(anchorElementRef) =>
anchorElementRef.current
? ReactDOM.createPortal(
<div
className={clsx({
'mt-6 fixed z-1000 shadow bg-white dark:bg-gray-900 rounded-lg py-1 space-y-0 dark:ring-2 dark:ring-primary-700 focus:outline-none': true,
hidden: suggestionsHidden || suggestions.isEmpty(),
block: !suggestionsHidden && !suggestions.isEmpty(),
})}
>
{suggestions.map(renderSuggestion)}
</div>,
anchorElementRef.current,
)
: null
}
/>
);
};
export default AutosuggestPlugin;

View File

@ -0,0 +1,297 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $createHorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode';
import { $wrapNodeInElement, mergeRegister } from '@lexical/utils';
import {
$createParagraphNode,
$getSelection,
$insertNodes,
$isRangeSelection,
$isRootOrShadowRoot,
COMMAND_PRIORITY_LOW,
DEPRECATED_$isGridSelection,
LexicalEditor,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { defineMessages, useIntl } from 'react-intl';
import { uploadFile } from 'soapbox/actions/compose';
import { useAppDispatch, useInstance } from 'soapbox/hooks';
import { $createImageNode } from '../nodes/image-node';
import { setFloatingElemPosition } from '../utils/set-floating-elem-position';
import { ToolbarButton } from './floating-text-format-toolbar-plugin';
import type { List as ImmutableList } from 'immutable';
const messages = defineMessages({
createHorizontalLine: { id: 'compose_form.lexical.create_horizontal_line', defaultMessage: 'Create horizontal line' },
uploadMedia: { id: 'compose_form.lexical.upload_media', defaultMessage: 'Upload media' },
});
interface IUploadButton {
onSelectFile: (src: string) => void
}
const UploadButton: React.FC<IUploadButton> = ({ onSelectFile }) => {
const intl = useIntl();
const { configuration } = useInstance();
const dispatch = useAppDispatch();
const [disabled, setDisabled] = useState(false);
const fileElement = useRef<HTMLInputElement>(null);
const attachmentTypes = configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>;
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
if (e.target.files?.length) {
setDisabled(true);
// @ts-ignore
dispatch(uploadFile(
e.target.files.item(0) as File,
intl,
({ url }) => {
onSelectFile(url);
setDisabled(false);
},
() => setDisabled(false),
));
}
};
const handleClick = () => {
fileElement.current?.click();
};
const src = require('@tabler/icons/photo.svg');
return (
<label>
<ToolbarButton
onClick={handleClick}
aria-label={intl.formatMessage(messages.uploadMedia)}
icon={src}
/>
<input
ref={fileElement}
type='file'
multiple
accept={attachmentTypes ? attachmentTypes.filter(type => type.startsWith('image/')).toArray().join(',') : 'image/*'}
onChange={handleChange}
disabled={disabled}
className='hidden'
/>
</label>
);
};
const BlockTypeFloatingToolbar = ({
editor,
anchorElem,
}: {
editor: LexicalEditor
anchorElem: HTMLElement
}): JSX.Element => {
const intl = useIntl();
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null);
const instance = useInstance();
const allowInlineImages = instance.pleroma.getIn(['metadata', 'markup', 'allow_inline_images']);
const updateBlockTypeFloatingToolbar = useCallback(() => {
const selection = $getSelection();
const popupCharStylesEditorElem = popupCharStylesEditorRef.current;
const nativeSelection = window.getSelection();
if (popupCharStylesEditorElem === null) {
return;
}
const rootElement = editor.getRootElement();
if (
selection !== null &&
nativeSelection !== null &&
!nativeSelection.anchorNode?.textContent &&
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode)
) {
setFloatingElemPosition((nativeSelection.focusNode as HTMLParagraphElement)?.getBoundingClientRect(), popupCharStylesEditorElem, anchorElem);
}
}, [editor, anchorElem]);
useEffect(() => {
const scrollerElem = anchorElem.parentElement;
const update = () => {
editor.getEditorState().read(() => {
updateBlockTypeFloatingToolbar();
});
};
window.addEventListener('resize', update);
if (scrollerElem) {
scrollerElem.addEventListener('scroll', update);
}
return () => {
window.removeEventListener('resize', update);
if (scrollerElem) {
scrollerElem.removeEventListener('scroll', update);
}
};
}, [editor, updateBlockTypeFloatingToolbar, anchorElem]);
useEffect(() => {
editor.getEditorState().read(() => {
updateBlockTypeFloatingToolbar();
});
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateBlockTypeFloatingToolbar();
});
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateBlockTypeFloatingToolbar();
return false;
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, updateBlockTypeFloatingToolbar]);
const createHorizontalLine = () => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
const selectionNode = selection.anchor.getNode();
selectionNode.replace($createHorizontalRuleNode());
}
});
};
const createImage = (src: string) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
const imageNode = $createImageNode({ altText: '', src });
$insertNodes([imageNode]);
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd();
}
}
});
};
return (
<div
ref={popupCharStylesEditorRef}
className='absolute left-0 top-0 z-10 flex h-[38px] gap-0.5 rounded-lg bg-white p-1 opacity-0 shadow-lg transition-[opacity] dark:bg-gray-900'
>
{editor.isEditable() && (
<>
{allowInlineImages && <UploadButton onSelectFile={createImage} />}
<ToolbarButton
onClick={createHorizontalLine}
aria-label={intl.formatMessage(messages.createHorizontalLine)}
icon={require('@tabler/icons/line-dashed.svg')}
/>
</>
)}
</div>
);
};
const useFloatingBlockTypeToolbar = (
editor: LexicalEditor,
anchorElem: HTMLElement,
): JSX.Element | null => {
const [isEmptyBlock, setIsEmptyBlock] = useState(false);
const updatePopup = useCallback(() => {
editor.getEditorState().read(() => {
// Should not to pop up the floating toolbar when using IME input
if (editor.isComposing()) {
return;
}
const selection = $getSelection();
const nativeSelection = window.getSelection();
const rootElement = editor.getRootElement();
if (
nativeSelection !== null &&
(!$isRangeSelection(selection) ||
rootElement === null ||
!rootElement.contains(nativeSelection.anchorNode))
) {
setIsEmptyBlock(false);
return;
}
if (!$isRangeSelection(selection)) {
return;
}
const anchorNode = selection.anchor.getNode();
setIsEmptyBlock(anchorNode.getType() === 'paragraph' && anchorNode.getTextContentSize() === 0);
});
}, [editor]);
useEffect(() => {
document.addEventListener('selectionchange', updatePopup);
return () => {
document.removeEventListener('selectionchange', updatePopup);
};
}, [updatePopup]);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(() => {
updatePopup();
}),
editor.registerRootListener(() => {
if (editor.getRootElement() === null) {
setIsEmptyBlock(false);
}
}),
);
}, [editor, updatePopup]);
if (!isEmptyBlock) {
return null;
}
return createPortal(
<BlockTypeFloatingToolbar
editor={editor}
anchorElem={anchorElem}
/>,
anchorElem,
);
};
const FloatingBlockTypeToolbarPlugin = ({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement
}): JSX.Element | null => {
const [editor] = useLexicalComposerContext();
return useFloatingBlockTypeToolbar(editor, anchorElem);
};
export default FloatingBlockTypeToolbarPlugin;

View File

@ -0,0 +1,278 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $findMatchingParent, mergeRegister } from '@lexical/utils';
import {
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_CRITICAL,
COMMAND_PRIORITY_LOW,
GridSelection,
LexicalEditor,
NodeSelection,
RangeSelection,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { Icon } from 'soapbox/components/ui';
import { getSelectedNode } from '../utils/get-selected-node';
import { setFloatingElemPosition } from '../utils/set-floating-elem-position';
import { sanitizeUrl } from '../utils/url';
const FloatingLinkEditor = ({
editor,
anchorElem,
}: {
editor: LexicalEditor
anchorElem: HTMLElement
}): JSX.Element => {
const editorRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [linkUrl, setLinkUrl] = useState('');
const [isEditMode, setEditMode] = useState(false);
const [lastSelection, setLastSelection] = useState<
RangeSelection | GridSelection | NodeSelection | null
>(null);
const updateLinkEditor = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const node = getSelectedNode(selection);
const parent = node.getParent();
if ($isLinkNode(parent)) {
setLinkUrl(parent.getURL());
} else if ($isLinkNode(node)) {
setLinkUrl(node.getURL());
} else {
setLinkUrl('');
}
}
const editorElem = editorRef.current;
const nativeSelection = window.getSelection();
const activeElement = document.activeElement;
if (editorElem === null) {
return;
}
const rootElement = editor.getRootElement();
if (
selection !== null &&
nativeSelection !== null &&
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode)
) {
const domRange = nativeSelection.getRangeAt(0);
let rect;
if (nativeSelection.anchorNode === rootElement) {
let inner = rootElement;
while (inner.firstElementChild !== null) {
inner = inner.firstElementChild as HTMLElement;
}
rect = inner.getBoundingClientRect();
} else {
rect = domRange.getBoundingClientRect();
}
setFloatingElemPosition(rect, editorElem, anchorElem);
setLastSelection(selection);
} else if (!activeElement || activeElement.className !== 'link-input') {
if (rootElement !== null) {
setFloatingElemPosition(null, editorElem, anchorElem);
}
setLastSelection(null);
setEditMode(false);
setLinkUrl('');
}
return true;
}, [anchorElem, editor]);
useEffect(() => {
const scrollerElem = anchorElem.parentElement;
const update = () => {
editor.getEditorState().read(() => {
updateLinkEditor();
});
};
window.addEventListener('resize', update);
if (scrollerElem) {
scrollerElem.addEventListener('scroll', update);
}
return () => {
window.removeEventListener('resize', update);
if (scrollerElem) {
scrollerElem.removeEventListener('scroll', update);
}
};
}, [anchorElem.parentElement, editor, updateLinkEditor]);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateLinkEditor();
});
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateLinkEditor();
return true;
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, updateLinkEditor]);
useEffect(() => {
editor.getEditorState().read(() => {
updateLinkEditor();
});
}, [editor, updateLinkEditor]);
useEffect(() => {
if (isEditMode && inputRef.current) {
inputRef.current.focus();
}
}, [isEditMode]);
return (
<div
ref={editorRef}
className='absolute left-0 top-0 z-10 w-full max-w-sm rounded-lg bg-white opacity-0 shadow-md transition-opacity will-change-transform dark:bg-gray-900'
>
<div className='relative mx-3 my-2 box-border block rounded-2xl border-0 bg-gray-100 px-3 py-2 text-sm text-gray-800 outline-0 dark:bg-gray-800 dark:text-gray-100'>
{isEditMode ? (
<>
<input
className='-mx-3 -my-2 w-full border-0 bg-transparent px-3 py-2 text-sm text-gray-900 outline-0 dark:text-gray-100'
ref={inputRef}
value={linkUrl}
onChange={(event) => {
setLinkUrl(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
if (lastSelection !== null) {
if (linkUrl !== '') {
editor.dispatchCommand(
TOGGLE_LINK_COMMAND,
sanitizeUrl(linkUrl),
);
} else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}
setEditMode(false);
}
} else if (event.key === 'Escape') {
event.preventDefault();
setEditMode(false);
}
}}
/>
<div
className='absolute inset-y-0 right-0 flex w-9 cursor-pointer items-center justify-center'
role='button'
tabIndex={0}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}}
>
<Icon className='h-5 w-5' src={require('@tabler/icons/x.svg')} />
</div>
</>
) : (
<>
<a className='mr-8 block overflow-hidden text-ellipsis whitespace-nowrap text-primary-600 no-underline hover:underline dark:text-accent-blue' href={linkUrl} target='_blank' rel='noopener noreferrer'>
{linkUrl}
</a>
<div
className='absolute inset-y-0 right-0 flex w-9 cursor-pointer items-center justify-center'
role='button'
tabIndex={0}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setEditMode(true);
}}
>
<Icon className='h-5 w-5' src={require('@tabler/icons/pencil.svg')} />
</div>
</>
)}
</div>
</div>
);
};
const useFloatingLinkEditorToolbar = (
editor: LexicalEditor,
anchorElem: HTMLElement,
): JSX.Element | null => {
const [activeEditor, setActiveEditor] = useState(editor);
const [isLink, setIsLink] = useState(false);
const updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const node = getSelectedNode(selection);
const linkParent = $findMatchingParent(node, $isLinkNode);
const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode);
// We don't want this menu to open for auto links.
if (linkParent !== null && autoLinkParent === null) {
setIsLink(true);
} else {
setIsLink(false);
}
}
}, []);
useEffect(() => {
return editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => {
updateToolbar();
setActiveEditor(newEditor);
return false;
},
COMMAND_PRIORITY_CRITICAL,
);
}, [editor, updateToolbar]);
return isLink
? createPortal(
<FloatingLinkEditor editor={activeEditor} anchorElem={anchorElem} />,
anchorElem,
)
: null;
};
const FloatingLinkEditorPlugin = ({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement
}): JSX.Element | null => {
const [editor] = useLexicalComposerContext();
return useFloatingLinkEditorToolbar(editor, anchorElem);
};
export default FloatingLinkEditorPlugin;

View File

@ -0,0 +1,566 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { $createCodeNode, $isCodeHighlightNode } from '@lexical/code';
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
import {
$isListNode,
INSERT_ORDERED_LIST_COMMAND,
INSERT_UNORDERED_LIST_COMMAND,
ListNode,
REMOVE_LIST_COMMAND,
} from '@lexical/list';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import {
$createHeadingNode,
$createQuoteNode,
$isHeadingNode,
HeadingTagType,
} from '@lexical/rich-text';
import {
$setBlocksType,
} from '@lexical/selection';
import { $findMatchingParent, $getNearestNodeOfType, mergeRegister } from '@lexical/utils';
import clsx from 'clsx';
import {
$createParagraphNode,
$getSelection,
$isRangeSelection,
$isRootOrShadowRoot,
$isTextNode,
COMMAND_PRIORITY_LOW,
DEPRECATED_$isGridSelection,
FORMAT_TEXT_COMMAND,
LexicalEditor,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { defineMessages, useIntl } from 'react-intl';
import { Icon } from 'soapbox/components/ui';
import { useInstance } from 'soapbox/hooks';
import { getDOMRangeRect } from '../utils/get-dom-range-rect';
import { getSelectedNode } from '../utils/get-selected-node';
import { setFloatingElemPosition } from '../utils/set-floating-elem-position';
const messages = defineMessages({
formatBold: { id: 'compose_form.lexical.format_bold', defaultMessage: 'Format bold' },
formatItalic: { id: 'compose_form.lexical.format_italic', defaultMessage: 'Format italic' },
formatUnderline: { id: 'compose_form.lexical.format_underline', defaultMessage: 'Format underline' },
formatStrikethrough: { id: 'compose_form.lexical.format_strikethrough', defaultMessage: 'Format strikethrough' },
insertCodeBlock: { id: 'compose_form.lexical.insert_code_block', defaultMessage: 'Insert code block' },
insertLink: { id: 'compose_form.lexical.insert_link', defaultMessage: 'Insert link' },
});
const blockTypeToIcon = {
bullet: require('@tabler/icons/list.svg'),
check: require('@tabler/icons/list-check.svg'),
code: require('@tabler/icons/code.svg'),
h1: require('@tabler/icons/h-1.svg'),
h2: require('@tabler/icons/h-2.svg'),
h3: require('@tabler/icons/h-3.svg'),
h4: require('@tabler/icons/h-4.svg'),
h5: require('@tabler/icons/h-5.svg'),
h6: require('@tabler/icons/h-6.svg'),
number: require('@tabler/icons/list-numbers.svg'),
paragraph: require('@tabler/icons/align-left.svg'),
quote: require('@tabler/icons/blockquote.svg'),
};
const blockTypeToBlockName = {
bullet: 'Bulleted List',
check: 'Check List',
code: 'Code Block',
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
h4: 'Heading 4',
h5: 'Heading 5',
h6: 'Heading 6',
number: 'Numbered List',
paragraph: 'Normal',
quote: 'Quote',
};
interface IToolbarButton extends React.HTMLAttributes<HTMLButtonElement> {
active?: boolean
icon: string
}
export const ToolbarButton: React.FC<IToolbarButton> = ({ active, icon, ...props }) => (
<button
className={clsx(
'flex cursor-pointer rounded-lg border-0 bg-none p-1 align-middle hover:bg-gray-100 disabled:cursor-not-allowed disabled:hover:bg-none hover:dark:bg-primary-700',
{ 'bg-gray-100/30 dark:bg-gray-800/30': active },
)}
type='button'
{...props}
>
<Icon className='h-5 w-5' src={icon} />
</button>
);
const BlockTypeDropdown = ({ editor, anchorElem, blockType, icon }: {
editor: LexicalEditor
anchorElem: HTMLElement
blockType: keyof typeof blockTypeToBlockName
icon: string
}) => {
const instance = useInstance();
const [showDropDown, setShowDropDown] = useState(false);
const formatParagraph = () => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
$setBlocksType(selection, () => $createParagraphNode());
}
});
};
const formatHeading = (headingSize: HeadingTagType) => {
if (blockType !== headingSize) {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
$setBlocksType(selection, () => $createHeadingNode(headingSize));
}
});
}
};
const formatBulletList = () => {
if (blockType !== 'bullet') {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
}
};
const formatNumberedList = () => {
if (blockType !== 'number') {
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
}
};
const formatQuote = () => {
if (blockType !== 'quote') {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
$setBlocksType(selection, () => $createQuoteNode());
}
});
}
};
const formatCode = () => {
if (blockType !== 'code') {
editor.update(() => {
let selection = $getSelection();
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
if (selection.isCollapsed()) {
$setBlocksType(selection, () => $createCodeNode());
} else {
const textContent = selection.getTextContent();
const codeNode = $createCodeNode();
selection.insertNodes([codeNode]);
selection = $getSelection();
if ($isRangeSelection(selection))
selection.insertRawText(textContent);
}
}
});
}
};
return (
<>
<button
onClick={() => setShowDropDown(!showDropDown)}
className='relative flex cursor-pointer rounded-lg border-0 bg-none p-1 align-middle hover:bg-gray-100 disabled:cursor-not-allowed disabled:hover:bg-none hover:dark:bg-primary-700'
aria-label=''
type='button'
>
<Icon src={icon} />
<Icon src={require('@tabler/icons/chevron-down.svg')} className='-bottom-2 h-4 w-4' />
{showDropDown && (
<div
className='absolute left-0 top-9 z-10 flex h-[38px] gap-0.5 rounded-lg bg-white p-1 shadow-lg transition-[opacity] dark:bg-gray-900'
>
<ToolbarButton
onClick={formatParagraph}
active={blockType === 'paragraph'}
icon={blockTypeToIcon.paragraph}
/>
{instance.pleroma.getIn(['metadata', 'markup', 'allow_headings']) === true && (
<>
<ToolbarButton
onClick={() => formatHeading('h1')}
active={blockType === 'h1'}
icon={blockTypeToIcon.h1}
/>
<ToolbarButton
onClick={() => formatHeading('h2')}
active={blockType === 'h2'}
icon={blockTypeToIcon.h2}
/>
<ToolbarButton
onClick={() => formatHeading('h3')}
active={blockType === 'h3'}
icon={blockTypeToIcon.h3}
/>
</>
)}
<ToolbarButton
onClick={formatBulletList}
active={blockType === 'bullet'}
icon={blockTypeToIcon.bullet}
/>
<ToolbarButton
onClick={formatNumberedList}
active={blockType === 'number'}
icon={blockTypeToIcon.number}
/>
<ToolbarButton
onClick={formatQuote}
active={blockType === 'quote'}
icon={blockTypeToIcon.quote}
/>
<ToolbarButton
onClick={formatCode}
active={blockType === 'code'}
icon={blockTypeToIcon.code}
/>
</div>
)}
</button>
</>
);
};
const TextFormatFloatingToolbar = ({
editor,
anchorElem,
blockType,
isLink,
isBold,
isItalic,
isUnderline,
isCode,
isStrikethrough,
}: {
editor: LexicalEditor
anchorElem: HTMLElement
blockType: keyof typeof blockTypeToBlockName
isBold: boolean
isCode: boolean
isItalic: boolean
isLink: boolean
isStrikethrough: boolean
isUnderline: boolean
}): JSX.Element => {
const intl = useIntl();
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null);
const insertLink = useCallback(() => {
if (!isLink) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://');
} else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}
}, [editor, isLink]);
const updateTextFormatFloatingToolbar = useCallback(() => {
const selection = $getSelection();
const popupCharStylesEditorElem = popupCharStylesEditorRef.current;
const nativeSelection = window.getSelection();
if (popupCharStylesEditorElem === null) {
return;
}
const rootElement = editor.getRootElement();
if (
selection !== null &&
nativeSelection !== null &&
!nativeSelection.isCollapsed &&
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode)
) {
const rangeRect = getDOMRangeRect(nativeSelection, rootElement);
setFloatingElemPosition(rangeRect, popupCharStylesEditorElem, anchorElem);
}
}, [editor, anchorElem]);
useEffect(() => {
const scrollerElem = anchorElem.parentElement;
const update = () => {
editor.getEditorState().read(() => {
updateTextFormatFloatingToolbar();
});
};
window.addEventListener('resize', update);
if (scrollerElem) {
scrollerElem.addEventListener('scroll', update);
}
return () => {
window.removeEventListener('resize', update);
if (scrollerElem) {
scrollerElem.removeEventListener('scroll', update);
}
};
}, [editor, updateTextFormatFloatingToolbar, anchorElem]);
useEffect(() => {
editor.getEditorState().read(() => {
updateTextFormatFloatingToolbar();
});
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateTextFormatFloatingToolbar();
});
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateTextFormatFloatingToolbar();
return false;
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, updateTextFormatFloatingToolbar]);
return (
<div
ref={popupCharStylesEditorRef}
className='absolute left-0 top-0 z-10 flex h-[38px] gap-0.5 rounded-lg bg-white p-1 opacity-0 shadow-lg transition-[opacity] dark:bg-gray-900'
>
{editor.isEditable() && (
<>
<BlockTypeDropdown
editor={editor}
anchorElem={anchorElem}
blockType={blockType}
icon={blockTypeToIcon[blockType]}
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
}}
active={isBold}
aria-label={intl.formatMessage(messages.formatBold)}
icon={require('@tabler/icons/bold.svg')}
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
}}
active={isItalic}
aria-label={intl.formatMessage(messages.formatItalic)}
icon={require('@tabler/icons/italic.svg')}
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
}}
active={isUnderline}
aria-label={intl.formatMessage(messages.formatUnderline)}
icon={require('@tabler/icons/underline.svg')}
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
}}
active={isStrikethrough}
aria-label={intl.formatMessage(messages.formatStrikethrough)}
icon={require('@tabler/icons/strikethrough.svg')}
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
}}
active={isCode}
aria-label={intl.formatMessage(messages.insertCodeBlock)}
icon={require('@tabler/icons/code.svg')}
/>
<ToolbarButton
onClick={insertLink}
active={isLink}
aria-label={intl.formatMessage(messages.insertLink)}
icon={require('@tabler/icons/link.svg')}
/>
</>
)}
</div>
);
};
const useFloatingTextFormatToolbar = (
editor: LexicalEditor,
anchorElem: HTMLElement,
): JSX.Element | null => {
const [blockType, setBlockType] =
useState<keyof typeof blockTypeToBlockName>('paragraph');
const [isText, setIsText] = useState(false);
const [isLink, setIsLink] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isCode, setIsCode] = useState(false);
const updatePopup = useCallback(() => {
editor.getEditorState().read(() => {
// Should not to pop up the floating toolbar when using IME input
if (editor.isComposing()) {
return;
}
const selection = $getSelection();
const nativeSelection = window.getSelection();
const rootElement = editor.getRootElement();
if (
nativeSelection !== null &&
(!$isRangeSelection(selection) ||
rootElement === null ||
!rootElement.contains(nativeSelection.anchorNode))
) {
setIsText(false);
return;
}
if (!$isRangeSelection(selection)) {
return;
}
const anchorNode = selection.anchor.getNode();
let element =
anchorNode.getKey() === 'root'
? anchorNode
: $findMatchingParent(anchorNode, (e) => {
const parent = e.getParent();
return parent !== null && $isRootOrShadowRoot(parent);
});
if (element === null) {
element = anchorNode.getTopLevelElementOrThrow();
}
const elementKey = element.getKey();
const elementDOM = editor.getElementByKey(elementKey);
const node = getSelectedNode(selection);
// Update text format
setIsBold(selection.hasFormat('bold'));
setIsItalic(selection.hasFormat('italic'));
setIsUnderline(selection.hasFormat('underline'));
setIsStrikethrough(selection.hasFormat('strikethrough'));
setIsCode(selection.hasFormat('code'));
if (elementDOM !== null) {
if ($isListNode(element)) {
const parentList = $getNearestNodeOfType<ListNode>(
anchorNode,
ListNode,
);
const type = parentList
? parentList.getListType()
: element.getListType();
setBlockType(type);
} else {
const type = $isHeadingNode(element)
? element.getTag()
: element.getType();
if (type in blockTypeToBlockName) {
setBlockType(type as keyof typeof blockTypeToBlockName);
}
}
}
// Update links
const parent = node.getParent();
if ($isLinkNode(parent) || $isLinkNode(node)) {
setIsLink(true);
} else {
setIsLink(false);
}
if (!$isCodeHighlightNode(selection.anchor.getNode()) && selection.getTextContent() !== '') {
setIsText($isTextNode(node));
} else {
setIsText(false);
}
});
}, [editor]);
useEffect(() => {
document.addEventListener('selectionchange', updatePopup);
return () => {
document.removeEventListener('selectionchange', updatePopup);
};
}, [updatePopup]);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(() => {
updatePopup();
}),
editor.registerRootListener(() => {
if (editor.getRootElement() === null) {
setIsText(false);
}
}),
);
}, [editor, updatePopup]);
if (!isText || isLink) {
return null;
}
return createPortal(
<TextFormatFloatingToolbar
editor={editor}
anchorElem={anchorElem}
blockType={blockType}
isLink={isLink}
isBold={isBold}
isItalic={isItalic}
isStrikethrough={isStrikethrough}
isUnderline={isUnderline}
isCode={isCode}
/>,
anchorElem,
);
};
const FloatingTextFormatToolbarPlugin = ({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement
}): JSX.Element | null => {
const [editor] = useLexicalComposerContext();
return useFloatingTextFormatToolbar(editor, anchorElem);
};
export default FloatingTextFormatToolbarPlugin;

View File

@ -0,0 +1,38 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { COMMAND_PRIORITY_NORMAL, createCommand, type LexicalCommand } from 'lexical';
import { useEffect } from 'react';
interface IFocusPlugin {
autoFocus?: boolean
}
export const FOCUS_EDITOR_COMMAND: LexicalCommand<void> = createCommand();
const FocusPlugin: React.FC<IFocusPlugin> = ({ autoFocus }) => {
const [editor] = useLexicalComposerContext();
const focus = () => {
editor.dispatchCommand(FOCUS_EDITOR_COMMAND, undefined);
};
useEffect(() => editor.registerCommand(FOCUS_EDITOR_COMMAND, () => {
editor.focus(
() => {
const activeElement = document.activeElement;
const rootElement = editor.getRootElement();
if (rootElement !== null && (activeElement === null || !rootElement.contains(activeElement))) {
rootElement.focus({ preventScroll: true });
}
}, { defaultSelection: 'rootEnd' },
);
return true;
}, COMMAND_PRIORITY_NORMAL));
useEffect(() => {
if (autoFocus) focus();
}, []);
return null;
};
export default FocusPlugin;

View File

@ -0,0 +1,16 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { LinkPlugin as LexicalLinkPlugin } from '@lexical/react/LexicalLinkPlugin';
import * as React from 'react';
import { validateUrl } from '../utils/url';
const LinkPlugin = (): JSX.Element => {
return <LexicalLinkPlugin validateUrl={validateUrl} />;
};
export default LinkPlugin;

View File

@ -0,0 +1,60 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useLexicalTextEntity } from '@lexical/react/useLexicalTextEntity';
import { useCallback, useEffect } from 'react';
import { $createMentionNode, MentionNode } from '../nodes/mention-node';
import type { TextNode } from 'lexical';
const MENTION_REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i');
const getMentionMatch = (text: string) => {
const matchArr = MENTION_REGEX.exec(text);
if (!matchArr) return null;
return matchArr;
};
const MentionPlugin = (): JSX.Element | null => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!editor.hasNodes([MentionNode])) {
throw new Error('MentionPlugin: MentionNode not registered on editor');
}
}, [editor]);
const createMentionNode = useCallback((textNode: TextNode): MentionNode => {
return $createMentionNode(textNode.getTextContent());
}, []);
const getEntityMatch = useCallback((text: string) => {
const matchArr = getMentionMatch(text);
if (!matchArr) return null;
const mentionLength = matchArr[3].length + 1;
const startOffset = matchArr.index + matchArr[1].length;
const endOffset = startOffset + mentionLength;
return {
end: endOffset,
start: startOffset,
};
}, []);
useLexicalTextEntity<MentionNode>(
getEntityMatch,
MentionNode,
createMentionNode,
);
return null;
};
export default MentionPlugin;

View File

@ -0,0 +1,33 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { KEY_ENTER_COMMAND } from 'lexical';
import { useEffect } from 'react';
import { setEditorState } from 'soapbox/actions/compose';
import { useAppDispatch } from 'soapbox/hooks';
interface IStatePlugin {
composeId: string
handleSubmit?: () => void
}
const StatePlugin = ({ composeId, handleSubmit }: IStatePlugin) => {
const dispatch = useAppDispatch();
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (handleSubmit) editor.registerCommand(KEY_ENTER_COMMAND, (event) => {
if (event?.ctrlKey) {
handleSubmit();
return true;
}
return false;
}, 1);
editor.registerUpdateListener(({ editorState }) => {
dispatch(setEditorState(composeId, editorState.isEmpty() ? null : JSON.stringify(editorState.toJSON())));
});
}, [editor]);
return null;
};
export default StatePlugin;

View File

@ -0,0 +1,28 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
/* eslint-disable eqeqeq */
export const getDOMRangeRect = (
nativeSelection: Selection,
rootElement: HTMLElement,
): DOMRect => {
const domRange = nativeSelection.getRangeAt(0);
let rect;
if (nativeSelection.anchorNode === rootElement) {
let inner = rootElement;
while (inner.firstElementChild != null) {
inner = inner.firstElementChild as HTMLElement;
}
rect = inner.getBoundingClientRect();
} else {
rect = domRange.getBoundingClientRect();
}
return rect;
};

View File

@ -0,0 +1,26 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { $isAtNodeEnd } from '@lexical/selection';
import { ElementNode, RangeSelection, TextNode } from 'lexical';
export const getSelectedNode = (
selection: RangeSelection,
): TextNode | ElementNode => {
const anchor = selection.anchor;
const focus = selection.focus;
const anchorNode = selection.anchor.getNode();
const focusNode = selection.focus.getNode();
if (anchorNode === focusNode) {
return anchorNode;
}
const isBackward = selection.isBackward();
if (isBackward) {
return $isAtNodeEnd(focus) ? anchorNode : focusNode;
} else {
return $isAtNodeEnd(anchor) ? focusNode : anchorNode;
}
};

View File

@ -0,0 +1,4 @@
const isHTMLElement = (x: unknown): x is HTMLElement => x instanceof HTMLElement;
export default isHTMLElement;
export { isHTMLElement };

View File

@ -0,0 +1,57 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
class Point {
private readonly _x: number;
private readonly _y: number;
constructor(x: number, y: number) {
this._x = x;
this._y = y;
}
get x(): number {
return this._x;
}
get y(): number {
return this._y;
}
public equals({ x, y }: Point): boolean {
return this.x === x && this.y === y;
}
public calcDeltaXTo({ x }: Point): number {
return this.x - x;
}
public calcDeltaYTo({ y }: Point): number {
return this.y - y;
}
public calcHorizontalDistanceTo(point: Point): number {
return Math.abs(this.calcDeltaXTo(point));
}
public calcVerticalDistance(point: Point): number {
return Math.abs(this.calcDeltaYTo(point));
}
public calcDistanceTo(point: Point): number {
return Math.sqrt(
Math.pow(this.calcDeltaXTo(point), 2) +
Math.pow(this.calcDeltaYTo(point), 2),
);
}
}
const isPoint = (x: unknown): x is Point => x instanceof Point;
export default Point;
export { Point, isPoint };

View File

@ -0,0 +1,163 @@
/* eslint-disable no-dupe-class-members */
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
import { isPoint, Point } from './point';
type ContainsPointReturn = {
result: boolean
reason: {
isOnTopSide: boolean
isOnBottomSide: boolean
isOnLeftSide: boolean
isOnRightSide: boolean
}
};
class Rect {
private readonly _left: number;
private readonly _top: number;
private readonly _right: number;
private readonly _bottom: number;
constructor(left: number, top: number, right: number, bottom: number) {
const [physicTop, physicBottom] =
top <= bottom ? [top, bottom] : [bottom, top];
const [physicLeft, physicRight] =
left <= right ? [left, right] : [right, left];
this._top = physicTop;
this._right = physicRight;
this._left = physicLeft;
this._bottom = physicBottom;
}
get top(): number {
return this._top;
}
get right(): number {
return this._right;
}
get bottom(): number {
return this._bottom;
}
get left(): number {
return this._left;
}
get width(): number {
return Math.abs(this._left - this._right);
}
get height(): number {
return Math.abs(this._bottom - this._top);
}
public equals({ top, left, bottom, right }: Rect): boolean {
return (
top === this._top &&
bottom === this._bottom &&
left === this._left &&
right === this._right
);
}
public contains({ x, y }: Point): ContainsPointReturn;
public contains({ top, left, bottom, right }: Rect): boolean;
public contains(target: Point | Rect): boolean | ContainsPointReturn {
if (isPoint(target)) {
const { x, y } = target;
const isOnTopSide = y < this._top;
const isOnBottomSide = y > this._bottom;
const isOnLeftSide = x < this._left;
const isOnRightSide = x > this._right;
const result =
!isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide;
return {
reason: {
isOnBottomSide,
isOnLeftSide,
isOnRightSide,
isOnTopSide,
},
result,
};
} else {
const { top, left, bottom, right } = target;
return (
top >= this._top &&
top <= this._bottom &&
bottom >= this._top &&
bottom <= this._bottom &&
left >= this._left &&
left <= this._right &&
right >= this._left &&
right <= this._right
);
}
}
public intersectsWith(rect: Rect): boolean {
const { left: x1, top: y1, width: w1, height: h1 } = rect;
const { left: x2, top: y2, width: w2, height: h2 } = this;
const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2;
const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2;
const minX = x1 <= x2 ? x1 : x2;
const minY = y1 <= y2 ? y1 : y2;
return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2;
}
public generateNewRect({
left = this.left,
top = this.top,
right = this.right,
bottom = this.bottom,
}): Rect {
return new Rect(left, top, right, bottom);
}
static fromLTRB(
left: number,
top: number,
right: number,
bottom: number,
): Rect {
return new Rect(left, top, right, bottom);
}
static fromLWTH(
left: number,
width: number,
top: number,
height: number,
): Rect {
return new Rect(left, top, left + width, top + height);
}
static fromPoints(startPoint: Point, endPoint: Point): Rect {
const { y: top, x: left } = startPoint;
const { y: bottom, x: right } = endPoint;
return Rect.fromLTRB(left, top, right, bottom);
}
static fromDOM(dom: HTMLElement): Rect {
const { top, width, left, height } = dom.getBoundingClientRect();
return Rect.fromLWTH(left, width, top, height);
}
}
export default Rect;
export { Rect };

View File

@ -0,0 +1,45 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
const VERTICAL_GAP = 10;
const HORIZONTAL_OFFSET = 5;
export const setFloatingElemPosition = (
targetRect: ClientRect | null,
floatingElem: HTMLElement,
anchorElem: HTMLElement,
verticalGap: number = VERTICAL_GAP,
horizontalOffset: number = HORIZONTAL_OFFSET,
): void => {
const scrollerElem = anchorElem.parentElement;
if (targetRect === null || !scrollerElem) {
floatingElem.style.opacity = '0';
floatingElem.style.transform = 'translate(-10000px, -10000px)';
return;
}
const floatingElemRect = floatingElem.getBoundingClientRect();
const anchorElementRect = anchorElem.getBoundingClientRect();
const editorScrollerRect = scrollerElem.getBoundingClientRect();
let top = targetRect.top - floatingElemRect.height - verticalGap;
let left = targetRect.left - horizontalOffset;
if (top < editorScrollerRect.top) {
top += floatingElemRect.height + targetRect.height + verticalGap * 2;
}
if (left + floatingElemRect.width > editorScrollerRect.right) {
left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset;
}
top -= anchorElementRect.top;
left -= anchorElementRect.left;
floatingElem.style.opacity = '1';
floatingElem.style.transform = `translate(${left}px, ${top}px)`;
};

View File

@ -0,0 +1,32 @@
/**
* This source code is derived from code from Meta Platforms, Inc.
* and affiliates, licensed under the MIT license located in the
* LICENSE file in the /app/soapbox/features/compose/editor directory.
*/
export const sanitizeUrl = (url: string): string => {
/** A pattern that matches safe URLs. */
const SAFE_URL_PATTERN =
/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi;
/** A pattern that matches safe data URLs. */
const DATA_URL_PATTERN =
/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i;
url = String(url).trim();
if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url;
return 'https://';
};
// Source: https://stackoverflow.com/a/8234912/2013580
const urlRegExp = new RegExp(
/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/,
);
export const validateUrl = (url: string): boolean => {
// TODO Fix UI for link insertion; it should never default to an invalid URL such as https://.
// Maybe show a dialog where they user can type the URL before inserting it.
return url === 'https://' || urlRegExp.test(url);
};

View File

@ -0,0 +1,9 @@
import { urlRegex } from './url-regex';
const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx';
export function countableText(inputText: string) {
return inputText
.replace(urlRegex, urlPlaceholder)
.replace(/(^|[^/\w])@(([a-z0-9_]+)@[a-z0-9.-]+[a-z0-9]+)/ig, '$1@$3');
}

View File

@ -0,0 +1,197 @@
const regexen: { [x: string]: string | RegExp } = {};
const regexSupplant = function(regex: string | RegExp, flags = '') {
if (typeof regex !== 'string') {
if (regex.global && flags.indexOf('g') < 0) {
flags += 'g';
}
if (regex.ignoreCase && flags.indexOf('i') < 0) {
flags += 'i';
}
if (regex.multiline && flags.indexOf('m') < 0) {
flags += 'm';
}
regex = regex.source;
}
return new RegExp(regex.replace(/#\{(\w+)\}/g, function(match, name) {
let newRegex = regexen[name] || '';
if (typeof newRegex !== 'string') {
newRegex = newRegex.source;
}
return newRegex;
}), flags);
};
const stringSupplant = function(str: string, values: { [x: string]: any }) {
return str.replace(/#\{(\w+)\}/g, function(match, name) {
return values[name] || '';
});
};
export const urlRegex = (function() {
regexen.spaces_group = /\x09-\x0D\x20\x85\xA0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000/; // eslint-disable-line no-control-regex
regexen.invalid_chars_group = /\uFFFE\uFEFF\uFFFF\u202A-\u202E/;
// eslint-disable-next-line no-useless-escape
regexen.punct = /!'#%&@,:;<=>_~{}\$\?\^\*\+\-\.\(\)\[\]\|\/\\/;
regexen.validUrlPrecedingChars = regexSupplant(/(?:[^A-Za-z0-9@$##{invalid_chars_group}]|^)/);
regexen.invalidDomainChars = stringSupplant('#{punct}#{spaces_group}#{invalid_chars_group}', regexen);
regexen.validDomainChars = regexSupplant(/[^#{invalidDomainChars}]/);
regexen.validSubdomain = regexSupplant(/(?:(?:#{validDomainChars}(?:[_-]|#{validDomainChars})*)?#{validDomainChars}\.)/);
regexen.validDomainName = regexSupplant(/(?:(?:#{validDomainChars}(?:-|#{validDomainChars})*)?#{validDomainChars}\.)/);
regexen.validGTLD = regexSupplant(RegExp(
'(?:(?:' +
'삼성|닷컴|닷넷|香格里拉|餐厅|食品|飞利浦|電訊盈科|集团|通販|购物|谷歌|诺基亚|联通|网络|网站|网店|网址|组织机构|移动|珠宝|点看|游戏|淡马锡|机构|書籍|时尚|新闻|政府|' +
'政务|手表|手机|我爱你|慈善|微博|广东|工行|家電|娱乐|天主教|大拿|大众汽车|在线|嘉里大酒店|嘉里|商标|商店|商城|公益|公司|八卦|健康|信息|佛山|企业|中文网|中信|世界|' +
'ポイント|ファッション|セール|ストア|コム|グーグル|クラウド|みんな|คอม|संगठन|नेट|कॉम|همراه|موقع|موبايلي|كوم|كاثوليك|عرب|شبكة|' +
'بيتك|بازار|العليان|ارامكو|اتصالات|ابوظبي|קום|сайт|рус|орг|онлайн|москва|ком|католик|дети|' +
'zuerich|zone|zippo|zip|zero|zara|zappos|yun|youtube|you|yokohama|yoga|yodobashi|yandex|yamaxun|' +
'yahoo|yachts|xyz|xxx|xperia|xin|xihuan|xfinity|xerox|xbox|wtf|wtc|wow|world|works|work|woodside|' +
'wolterskluwer|wme|winners|wine|windows|win|williamhill|wiki|wien|whoswho|weir|weibo|wedding|wed|' +
'website|weber|webcam|weatherchannel|weather|watches|watch|warman|wanggou|wang|walter|walmart|' +
'wales|vuelos|voyage|voto|voting|vote|volvo|volkswagen|vodka|vlaanderen|vivo|viva|vistaprint|' +
'vista|vision|visa|virgin|vip|vin|villas|viking|vig|video|viajes|vet|versicherung|' +
'vermögensberatung|vermögensberater|verisign|ventures|vegas|vanguard|vana|vacations|ups|uol|uno|' +
'university|unicom|uconnect|ubs|ubank|tvs|tushu|tunes|tui|tube|trv|trust|travelersinsurance|' +
'travelers|travelchannel|travel|training|trading|trade|toys|toyota|town|tours|total|toshiba|' +
'toray|top|tools|tokyo|today|tmall|tkmaxx|tjx|tjmaxx|tirol|tires|tips|tiffany|tienda|tickets|' +
'tiaa|theatre|theater|thd|teva|tennis|temasek|telefonica|telecity|tel|technology|tech|team|tdk|' +
'tci|taxi|tax|tattoo|tatar|tatamotors|target|taobao|talk|taipei|tab|systems|symantec|sydney|' +
'swiss|swiftcover|swatch|suzuki|surgery|surf|support|supply|supplies|sucks|style|study|studio|' +
'stream|store|storage|stockholm|stcgroup|stc|statoil|statefarm|statebank|starhub|star|staples|' +
'stada|srt|srl|spreadbetting|spot|spiegel|space|soy|sony|song|solutions|solar|sohu|software|' +
'softbank|social|soccer|sncf|smile|smart|sling|skype|sky|skin|ski|site|singles|sina|silk|shriram|' +
'showtime|show|shouji|shopping|shop|shoes|shiksha|shia|shell|shaw|sharp|shangrila|sfr|sexy|sex|' +
'sew|seven|ses|services|sener|select|seek|security|secure|seat|search|scot|scor|scjohnson|' +
'science|schwarz|schule|school|scholarships|schmidt|schaeffler|scb|sca|sbs|sbi|saxo|save|sas|' +
'sarl|sapo|sap|sanofi|sandvikcoromant|sandvik|samsung|samsclub|salon|sale|sakura|safety|safe|' +
'saarland|ryukyu|rwe|run|ruhr|rugby|rsvp|room|rogers|rodeo|rocks|rocher|rmit|rip|rio|ril|' +
'rightathome|ricoh|richardli|rich|rexroth|reviews|review|restaurant|rest|republican|report|' +
'repair|rentals|rent|ren|reliance|reit|reisen|reise|rehab|redumbrella|redstone|red|recipes|' +
'realty|realtor|realestate|read|raid|radio|racing|qvc|quest|quebec|qpon|pwc|pub|prudential|pru|' +
'protection|property|properties|promo|progressive|prof|productions|prod|pro|prime|press|praxi|' +
'pramerica|post|porn|politie|poker|pohl|pnc|plus|plumbing|playstation|play|place|pizza|pioneer|' +
'pink|ping|pin|pid|pictures|pictet|pics|piaget|physio|photos|photography|photo|phone|philips|phd|' +
'pharmacy|pfizer|pet|pccw|pay|passagens|party|parts|partners|pars|paris|panerai|panasonic|' +
'pamperedchef|page|ovh|ott|otsuka|osaka|origins|orientexpress|organic|org|orange|oracle|open|ooo|' +
'onyourside|online|onl|ong|one|omega|ollo|oldnavy|olayangroup|olayan|okinawa|office|off|observer|' +
'obi|nyc|ntt|nrw|nra|nowtv|nowruz|now|norton|northwesternmutual|nokia|nissay|nissan|ninja|nikon|' +
'nike|nico|nhk|ngo|nfl|nexus|nextdirect|next|news|newholland|new|neustar|network|netflix|netbank|' +
'net|nec|nba|navy|natura|nationwide|name|nagoya|nadex|nab|mutuelle|mutual|museum|mtr|mtpc|mtn|' +
'msd|movistar|movie|mov|motorcycles|moto|moscow|mortgage|mormon|mopar|montblanc|monster|money|' +
'monash|mom|moi|moe|moda|mobily|mobile|mobi|mma|mls|mlb|mitsubishi|mit|mint|mini|mil|microsoft|' +
'miami|metlife|merckmsd|meo|menu|men|memorial|meme|melbourne|meet|media|med|mckinsey|mcdonalds|' +
'mcd|mba|mattel|maserati|marshalls|marriott|markets|marketing|market|map|mango|management|man|' +
'makeup|maison|maif|madrid|macys|luxury|luxe|lupin|lundbeck|ltda|ltd|lplfinancial|lpl|love|lotto|' +
'lotte|london|lol|loft|locus|locker|loans|loan|lixil|living|live|lipsy|link|linde|lincoln|limo|' +
'limited|lilly|like|lighting|lifestyle|lifeinsurance|life|lidl|liaison|lgbt|lexus|lego|legal|' +
'lefrak|leclerc|lease|lds|lawyer|law|latrobe|latino|lat|lasalle|lanxess|landrover|land|lancome|' +
'lancia|lancaster|lamer|lamborghini|ladbrokes|lacaixa|kyoto|kuokgroup|kred|krd|kpn|kpmg|kosher|' +
'komatsu|koeln|kiwi|kitchen|kindle|kinder|kim|kia|kfh|kerryproperties|kerrylogistics|kerryhotels|' +
'kddi|kaufen|juniper|juegos|jprs|jpmorgan|joy|jot|joburg|jobs|jnj|jmp|jll|jlc|jio|jewelry|jetzt|' +
'jeep|jcp|jcb|java|jaguar|iwc|iveco|itv|itau|istanbul|ist|ismaili|iselect|irish|ipiranga|' +
'investments|intuit|international|intel|int|insure|insurance|institute|ink|ing|info|infiniti|' +
'industries|immobilien|immo|imdb|imamat|ikano|iinet|ifm|ieee|icu|ice|icbc|ibm|hyundai|hyatt|' +
'hughes|htc|hsbc|how|house|hotmail|hotels|hoteles|hot|hosting|host|hospital|horse|honeywell|' +
'honda|homesense|homes|homegoods|homedepot|holiday|holdings|hockey|hkt|hiv|hitachi|hisamitsu|' +
'hiphop|hgtv|hermes|here|helsinki|help|healthcare|health|hdfcbank|hdfc|hbo|haus|hangout|hamburg|' +
'hair|guru|guitars|guide|guge|gucci|guardian|group|grocery|gripe|green|gratis|graphics|grainger|' +
'gov|got|gop|google|goog|goodyear|goodhands|goo|golf|goldpoint|gold|godaddy|gmx|gmo|gmbh|gmail|' +
'globo|global|gle|glass|glade|giving|gives|gifts|gift|ggee|george|genting|gent|gea|gdn|gbiz|' +
'garden|gap|games|game|gallup|gallo|gallery|gal|fyi|futbol|furniture|fund|fun|fujixerox|fujitsu|' +
'ftr|frontier|frontdoor|frogans|frl|fresenius|free|fox|foundation|forum|forsale|forex|ford|' +
'football|foodnetwork|food|foo|fly|flsmidth|flowers|florist|flir|flights|flickr|fitness|fit|' +
'fishing|fish|firmdale|firestone|fire|financial|finance|final|film|fido|fidelity|fiat|ferrero|' +
'ferrari|feedback|fedex|fast|fashion|farmers|farm|fans|fan|family|faith|fairwinds|fail|fage|' +
'extraspace|express|exposed|expert|exchange|everbank|events|eus|eurovision|etisalat|esurance|' +
'estate|esq|erni|ericsson|equipment|epson|epost|enterprises|engineering|engineer|energy|emerck|' +
'email|education|edu|edeka|eco|eat|earth|dvr|dvag|durban|dupont|duns|dunlop|duck|dubai|dtv|drive|' +
'download|dot|doosan|domains|doha|dog|dodge|doctor|docs|dnp|diy|dish|discover|discount|directory|' +
'direct|digital|diet|diamonds|dhl|dev|design|desi|dentist|dental|democrat|delta|deloitte|dell|' +
'delivery|degree|deals|dealer|deal|dds|dclk|day|datsun|dating|date|data|dance|dad|dabur|cyou|' +
'cymru|cuisinella|csc|cruises|cruise|crs|crown|cricket|creditunion|creditcard|credit|courses|' +
'coupons|coupon|country|corsica|coop|cool|cookingchannel|cooking|contractors|contact|consulting|' +
'construction|condos|comsec|computer|compare|company|community|commbank|comcast|com|cologne|' +
'college|coffee|codes|coach|clubmed|club|cloud|clothing|clinique|clinic|click|cleaning|claims|' +
'cityeats|city|citic|citi|citadel|cisco|circle|cipriani|church|chrysler|chrome|christmas|chloe|' +
'chintai|cheap|chat|chase|channel|chanel|cfd|cfa|cern|ceo|center|ceb|cbs|cbre|cbn|cba|catholic|' +
'catering|cat|casino|cash|caseih|case|casa|cartier|cars|careers|career|care|cards|caravan|car|' +
'capitalone|capital|capetown|canon|cancerresearch|camp|camera|cam|calvinklein|call|cal|cafe|cab|' +
'bzh|buzz|buy|business|builders|build|bugatti|budapest|brussels|brother|broker|broadway|' +
'bridgestone|bradesco|box|boutique|bot|boston|bostik|bosch|boots|booking|book|boo|bond|bom|bofa|' +
'boehringer|boats|bnpparibas|bnl|bmw|bms|blue|bloomberg|blog|blockbuster|blanco|blackfriday|' +
'black|biz|bio|bingo|bing|bike|bid|bible|bharti|bet|bestbuy|best|berlin|bentley|beer|beauty|' +
'beats|bcn|bcg|bbva|bbt|bbc|bayern|bauhaus|basketball|baseball|bargains|barefoot|barclays|' +
'barclaycard|barcelona|bar|bank|band|bananarepublic|banamex|baidu|baby|azure|axa|aws|avianca|' +
'autos|auto|author|auspost|audio|audible|audi|auction|attorney|athleta|associates|asia|asda|arte|' +
'art|arpa|army|archi|aramco|arab|aquarelle|apple|app|apartments|aol|anz|anquan|android|analytics|' +
'amsterdam|amica|amfam|amex|americanfamily|americanexpress|alstom|alsace|ally|allstate|allfinanz|' +
'alipay|alibaba|alfaromeo|akdn|airtel|airforce|airbus|aigo|aig|agency|agakhan|africa|afl|' +
'afamilycompany|aetna|aero|aeg|adult|ads|adac|actor|active|aco|accountants|accountant|accenture|' +
'academy|abudhabi|abogado|able|abc|abbvie|abbott|abb|abarth|aarp|aaa|onion' +
')(?=[^0-9a-zA-Z@]|$))'));
regexen.validCCTLD = regexSupplant(RegExp(
'(?:(?:' +
'한국|香港|澳門|新加坡|台灣|台湾|中國|中国|გე|ไทย|ලංකා|ഭാരതം|ಭಾರತ|భారత్|சிங்கப்பூர்|இலங்கை|இந்தியா|ଭାରତ|ભારત|ਭਾਰਤ|' +
'ভাৰত|ভারত|বাংলা|भारोत|भारतम्|भारत|ڀارت|پاکستان|مليسيا|مصر|قطر|فلسطين|عمان|عراق|سورية|سودان|تونس|' +
'بھارت|بارت|ایران|امارات|المغرب|السعودية|الجزائر|الاردن|հայ|қаз|укр|срб|рф|мон|мкд|ею|бел|бг|ελ|' +
'zw|zm|za|yt|ye|ws|wf|vu|vn|vi|vg|ve|vc|va|uz|uy|us|um|uk|ug|ua|tz|tw|tv|tt|tr|tp|to|tn|tm|tl|tk|' +
'tj|th|tg|tf|td|tc|sz|sy|sx|sv|su|st|ss|sr|so|sn|sm|sl|sk|sj|si|sh|sg|se|sd|sc|sb|sa|rw|ru|rs|ro|' +
're|qa|py|pw|pt|ps|pr|pn|pm|pl|pk|ph|pg|pf|pe|pa|om|nz|nu|nr|np|no|nl|ni|ng|nf|ne|nc|na|mz|my|mx|' +
'mw|mv|mu|mt|ms|mr|mq|mp|mo|mn|mm|ml|mk|mh|mg|mf|me|md|mc|ma|ly|lv|lu|lt|ls|lr|lk|li|lc|lb|la|kz|' +
'ky|kw|kr|kp|kn|km|ki|kh|kg|ke|jp|jo|jm|je|it|is|ir|iq|io|in|im|il|ie|id|hu|ht|hr|hn|hm|hk|gy|gw|' +
'gu|gt|gs|gr|gq|gp|gn|gm|gl|gi|gh|gg|gf|ge|gd|gb|ga|fr|fo|fm|fk|fj|fi|eu|et|es|er|eh|eg|ee|ec|dz|' +
'do|dm|dk|dj|de|cz|cy|cx|cw|cv|cu|cr|co|cn|cm|cl|ck|ci|ch|cg|cf|cd|cc|ca|bz|by|bw|bv|bt|bs|br|bq|' +
'bo|bn|bm|bl|bj|bi|bh|bg|bf|be|bd|bb|ba|az|ax|aw|au|at|as|ar|aq|ao|an|am|al|ai|ag|af|ae|ad|ac' +
')(?=[^0-9a-zA-Z@]|$))'));
regexen.validPunycode = /(?:xn--[0-9a-z]+)/;
regexen.validSpecialCCTLD = /(?:(?:co|tv)(?=[^0-9a-zA-Z@]|$))/;
regexen.validDomain = regexSupplant(/(?:#{validSubdomain}*#{validDomainName}(?:#{validGTLD}|#{validCCTLD}|#{validPunycode}))/);
regexen.validPortNumber = /[0-9]+/;
regexen.pd = /\u002d\u058a\u05be\u1400\u1806\u2010-\u2015\u2e17\u2e1a\u2e3a\u2e40\u301c\u3030\u30a0\ufe31\ufe58\ufe63\uff0d/;
regexen.validGeneralUrlPathChars = regexSupplant(/[^#{spaces_group}()?]/i);
// Allow URL paths to contain up to two nested levels of balanced parens
// 1. Used in Wikipedia URLs like /Primer_(film)
// 2. Used in IIS sessions like /S(dfd346)/
// 3. Used in Rdio URLs like /track/We_Up_(Album_Version_(Edited))/
regexen.validUrlBalancedParens = regexSupplant(
'\\(' +
'(?:' +
'#{validGeneralUrlPathChars}+' +
'|' +
// allow one nested level of balanced parentheses
'(?:' +
'#{validGeneralUrlPathChars}*' +
'\\(' +
'#{validGeneralUrlPathChars}+' +
'\\)' +
'#{validGeneralUrlPathChars}*' +
')' +
')' +
'\\)',
'i');
// Valid end-of-path characters (so /foo. does not gobble the period).
// 1. Allow =&# for empty URL parameters and other URL-join artifacts
regexen.validUrlPathEndingChars = regexSupplant(/[^#{spaces_group}()?!*';:=,.$%[\]#{pd}~&|@]|(?:#{validUrlBalancedParens})/i);
// Allow @ in a url, but only in the middle. Catch things like http://example.com/@user/
regexen.validUrlPath = regexSupplant('(?:' +
'(?:' +
'#{validGeneralUrlPathChars}*' +
'(?:#{validUrlBalancedParens}#{validGeneralUrlPathChars}*)*' +
'#{validUrlPathEndingChars}' +
// eslint-disable-next-line no-useless-escape
')|(?:@#{validGeneralUrlPathChars}+\/)' +
')', 'i');
regexen.validUrlQueryChars = /[a-z0-9!?*'@();:&=+$/%#[\]\-_.,~|]/i;
regexen.validUrlQueryEndingChars = /[a-z0-9_&=#/]/i;
regexen.validUrl = regexSupplant(
'(' + // $1 URL
'(https?:\\/\\/)' + // $2 Protocol
'(#{validDomain})' + // $3 Domain(s)
'(?::(#{validPortNumber}))?' + // $4 Port number (optional)
'(\\/#{validUrlPath}*)?' + // $5 URL Path
'(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?' + // $6 Query String
')',
'gi');
return regexen.validUrl;
}());