- {React.cloneElement(children, {
- onClick: handleClick,
- ref: setReferenceElement,
- })}
-
- {selector}
+
+ { children}
+ {
+ visible && (
+ ReactDOM.createPortal((
+
+
+
+ ), document.body)
+
+ )
+ }
);
};
diff --git a/app/soapbox/components/emoji_picker.tsx b/app/soapbox/components/emoji_picker.tsx
index 2625c61ef..fbc8882e3 100644
--- a/app/soapbox/components/emoji_picker.tsx
+++ b/app/soapbox/components/emoji_picker.tsx
@@ -3,7 +3,9 @@ import Picker from '@emoji-mart/react';
import classNames from 'classnames';
import { List as ImmutableList } from 'immutable';
import React, { MouseEventHandler } from 'react';
+import ReactDOM from 'react-dom';
import { defineMessages, useIntl } from 'react-intl';
+import { createSelector } from 'reselect';
import { IconButton } from 'soapbox/components/ui';
import { useTheme } from 'soapbox/hooks';
@@ -27,16 +29,30 @@ const messages = defineMessages({
skins: { id: 'emoji_button.skins', defaultMessage: 'Skins' },
});
+export const getCustomEmojis = createSelector([
+ (state: any) => state.get('custom_emojis'),
+], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
+ const aShort = a.get('shortcode').toLowerCase();
+ const bShort = b.get('shortcode').toLowerCase();
+
+ if (aShort < bShort) {
+ return -1;
+ } else if (aShort > bShort) {
+ return 1;
+ } else {
+ return 0;
+ }
+}));
+
interface IWrapper {
- target: any,
show: boolean,
onClose: MouseEventHandler,
children: React.ReactNode,
}
-const Wrapper: React.FC
= ({ target, show, onClose, children }) => {
+const Wrapper: React.FC = ({ show, onClose, children }) => {
if (!show) return null;
- return (
+ return ReactDOM.createPortal(
@@ -49,22 +65,17 @@ const Wrapper: React.FC = ({ target, show, onClose, children }) => {
{ children }
- );
+ , document.body);
};
-interface IEmojiPicker {
- custom_emojis?: ImmutableList
,
- button?: React.ReactNode,
- onPickEmoji: Function,
+interface IEmojiPickerModal {
+ custom_emojis?: ImmutableList,
+ onPickEmoji: Function,
+ active: boolean,
+ onClose: Function,
}
-const EmojiPickerUI : React.FC = ({
- custom_emojis = ImmutableList(),
- button,
- onPickEmoji,
-}) => {
- const root = React.useRef(null);
- const [active, setActive] = React.useState(false);
+export const EmojiPickerModal: React.FC = ({ custom_emojis = ImmutableList(), active, onClose, onPickEmoji }) => {
const intl = useIntl();
const theme = useTheme();
@@ -72,21 +83,9 @@ const EmojiPickerUI : React.FC = ({
if (e) {
e.stopPropagation();
}
- setActive(false);
+ onClose();
}, []);
- const handleToggle = React.useCallback((e) => {
- e.stopPropagation();
- if (e.key === 'Escape') {
- setActive(false);
- return;
- }
-
- if ((!e.key || e.key === 'Enter')) {
- setActive(!active);
- }
- }, [active]);
-
const buildCustomEmojis = React.useCallback((custom_emojis: ImmutableList) => {
const emojis = custom_emojis.map((emoji) => (
{
@@ -108,6 +107,71 @@ const EmojiPickerUI : React.FC = ({
handleClose();
}, [handleClose, onPickEmoji]);
+ return (
+
+
+
+ );
+};
+
+interface IEmojiPicker {
+ custom_emojis?: ImmutableList,
+ button?: React.ReactNode,
+ onPickEmoji: Function,
+}
+
+const EmojiPickerUI : React.FC = ({
+ custom_emojis = ImmutableList(),
+ button,
+ onPickEmoji,
+}) => {
+ const root = React.useRef(null);
+ const [active, setActive] = React.useState(false);
+
+ const handleClose = React.useCallback(() => {
+ setActive(false);
+ }, []);
+
+ const handleToggle = React.useCallback((e) => {
+ e.stopPropagation();
+ if (e.key === 'Escape') {
+ setActive(false);
+ return;
+ }
+
+ if ((!e.key || e.key === 'Enter')) {
+ setActive(!active);
+ }
+ }, [active]);
+
return (
<>
@@ -117,42 +181,18 @@ const EmojiPickerUI : React.FC = ({
className={classNames({
'text-gray-400 hover:text-gray-600': true,
})}
+ // @ts-expect-error alt
alt='😀'
src={require('@tabler/icons/mood-happy.svg')}
/>
}
-
-
-
+
>
);
diff --git a/app/soapbox/components/emoji_selector.tsx b/app/soapbox/components/emoji_selector.tsx
deleted file mode 100644
index da8249fa9..000000000
--- a/app/soapbox/components/emoji_selector.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-// import classNames from 'classnames';
-import React from 'react';
-import { HotKeys } from 'react-hotkeys';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { connect } from 'react-redux';
-
-import { getSoapboxConfig } from 'soapbox/actions/soapbox';
-import { EmojiSelector as RealEmojiSelector } from 'soapbox/components/ui';
-
-import type { List as ImmutableList } from 'immutable';
-import type { RootState } from 'soapbox/store';
-
-const mapStateToProps = (state: RootState) => ({
- allowedEmoji: getSoapboxConfig(state).allowedEmoji,
-});
-
-interface IEmojiSelector {
- allowedEmoji: ImmutableList,
- onReact: (emoji: string) => void,
- onUnfocus: () => void,
- visible: boolean,
- focused?: boolean,
-}
-
-class EmojiSelector extends ImmutablePureComponent {
-
- static defaultProps: Partial = {
- onReact: () => {},
- onUnfocus: () => {},
- visible: false,
- }
-
- node?: HTMLDivElement = undefined;
-
- handleBlur: React.FocusEventHandler = e => {
- const { focused, onUnfocus } = this.props;
-
- if (focused && (!e.currentTarget || !e.currentTarget.classList.contains('emoji-react-selector__emoji'))) {
- onUnfocus();
- }
- }
-
- _selectPreviousEmoji = (i: number): void => {
- if (!this.node) return;
-
- if (i !== 0) {
- const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`);
- button?.focus();
- } else {
- const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:last-child');
- button?.focus();
- }
- };
-
- _selectNextEmoji = (i: number) => {
- if (!this.node) return;
-
- if (i !== this.props.allowedEmoji.size - 1) {
- const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`);
- button?.focus();
- } else {
- const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:first-child');
- button?.focus();
- }
- };
-
- handleKeyDown = (i: number): React.KeyboardEventHandler => e => {
- const { onUnfocus } = this.props;
-
- switch (e.key) {
- case 'Tab':
- e.preventDefault();
- if (e.shiftKey) this._selectPreviousEmoji(i);
- else this._selectNextEmoji(i);
- break;
- case 'Left':
- case 'ArrowLeft':
- this._selectPreviousEmoji(i);
- break;
- case 'Right':
- case 'ArrowRight':
- this._selectNextEmoji(i);
- break;
- case 'Escape':
- onUnfocus();
- break;
- }
- }
-
- handleReact = (emoji: string) => (): void => {
- const { onReact, focused, onUnfocus } = this.props;
-
- onReact(emoji);
-
- if (focused) {
- onUnfocus();
- }
- }
-
- handlers = {
- open: () => {},
- };
-
- setRef = (c: HTMLDivElement): void => {
- this.node = c;
- }
-
- render() {
- const { visible, focused, allowedEmoji, onReact } = this.props;
-
- return (
-
- {/*
- {allowedEmoji.map((emoji, i) => (
-
- ))}
-
*/}
-
-
- );
- }
-
-}
-
-export default connect(mapStateToProps)(EmojiSelector);
diff --git a/app/soapbox/components/modal_root.js b/app/soapbox/components/modal_root.js
index 7eb25b9aa..60ecbe078 100644
--- a/app/soapbox/components/modal_root.js
+++ b/app/soapbox/components/modal_root.js
@@ -219,7 +219,7 @@ class ModalRoot extends React.PureComponent {
{
const badges = [];
if (account.admin) {
- badges.push(
);
+ badges.push(
} />);
} else if (account.moderator) {
- badges.push(
);
+ badges.push(
} />);
}
if (account.getIn(['patron', 'is_patron'])) {
- badges.push(
);
+ badges.push(
} />);
}
if (account.donor) {
- badges.push(
);
+ badges.push(
} />);
}
return badges;
diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx
index 428362a15..8429afcf5 100644
--- a/app/soapbox/components/status-action-bar.tsx
+++ b/app/soapbox/components/status-action-bar.tsx
@@ -103,8 +103,6 @@ const StatusActionBar: React.FC
= ({
const settings = useSettings();
const soapboxConfig = useSoapboxConfig();
- const { allowedEmoji } = soapboxConfig;
-
const account = useOwnAccount();
const isStaff = account ? account.staff : false;
const isAdmin = account ? account.admin : false;
@@ -530,10 +528,10 @@ const StatusActionBar: React.FC = ({
(status.pleroma.get('emoji_reactions') || ImmutableList()) as ImmutableList,
favouriteCount,
status.favourited,
- allowedEmoji,
+ null,
).reduce((acc, cur) => acc + cur.get('count'), 0);
- const meEmojiReact = getReactForStatus(status, allowedEmoji) as keyof typeof reactMessages | undefined;
+ const meEmojiReact = getReactForStatus(status, null);
const reactMessages = {
'👍': messages.reactionLike,
@@ -545,7 +543,7 @@ const StatusActionBar: React.FC = ({
'': messages.favourite,
};
- const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite);
+ const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact?.get('name') || ''] || messages.favourite);
const menu = _makeMenu(publicStatus);
let reblogIcon = require('@tabler/icons/repeat.svg');
@@ -565,21 +563,9 @@ const StatusActionBar: React.FC = ({
text: intl.formatMessage(messages.quotePost),
action: handleQuoteClick,
icon: require('@tabler/icons/quote.svg'),
+ disabled: status.visibility !== 'public' && status.visibility !== 'unlisted',
}];
- const reblogButton = (
-
- );
-
if (!status.in_reply_to_id) {
replyTitle = intl.formatMessage(messages.reply);
} else {
@@ -609,10 +595,27 @@ const StatusActionBar: React.FC = ({
disabled={!publicStatus}
onShiftClick={handleReblogClick}
>
- {reblogButton}
+
) : (
- reblogButton
+
)}
{features.emojiReacts ? (
diff --git a/app/soapbox/components/status-action-button.tsx b/app/soapbox/components/status-action-button.tsx
index 2d41fe5b7..19b05a9d4 100644
--- a/app/soapbox/components/status-action-button.tsx
+++ b/app/soapbox/components/status-action-button.tsx
@@ -1,7 +1,8 @@
import classNames from 'classnames';
import React from 'react';
-import { Text, Icon, Emoji } from 'soapbox/components/ui';
+import { Text, Icon, EmojiReact, Emoji } from 'soapbox/components/ui';
+import { EmojiReact as EmojiReactType } from 'soapbox/utils/emoji_reacts';
import { shortNumberFormat } from 'soapbox/utils/numbers';
const COLORS = {
@@ -31,7 +32,7 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes
-
+ { typeof emoji === 'string' ? (
+
+ ) : (
+
+ )}
);
} else {
diff --git a/app/soapbox/components/ui/emoji-react/emoji-react.tsx b/app/soapbox/components/ui/emoji-react/emoji-react.tsx
index 89d79c040..7dfa3624d 100644
--- a/app/soapbox/components/ui/emoji-react/emoji-react.tsx
+++ b/app/soapbox/components/ui/emoji-react/emoji-react.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { EmojiReact as EmojiReactType } from 'soapbox/utils/emoji_reacts';
+
import Emoji from '../emoji/emoji';
interface IEmojiReact extends React.ImgHTMLAttributes {
@@ -12,18 +13,18 @@ interface IEmojiReact extends React.ImgHTMLAttributes {
const EmojiReact: React.FC = (props): JSX.Element | null => {
const { emoji, alt, ...rest } = props;
- if(emoji.get("url")) {
+ if (emoji.get('url')) {
return (
-
- );
+
+ );
}
- return
+ return ;
};
export default EmojiReact;
diff --git a/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx
index e17f2a1d4..2d46af346 100644
--- a/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx
+++ b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx
@@ -1,7 +1,12 @@
+/* eslint-disable jsx-a11y/no-static-element-interactions */
import classNames from 'classnames';
import React from 'react';
+import { EmojiPickerModal, getCustomEmojis } from 'soapbox/components/emoji_picker';
import { Emoji, HStack } from 'soapbox/components/ui';
+import { useAppSelector, useFeatures } from 'soapbox/hooks';
+import { EmojiReact } from 'soapbox/utils/emoji_reacts';
+
interface IEmojiButton {
/** Unicode emoji character. */
@@ -27,38 +32,101 @@ interface IEmojiSelector {
/** List of Unicode emoji characters. */
emojis: Iterable,
/** Event handler when an emoji is clicked. */
- onReact: (emoji: string) => void,
+ onReact: (emoji?: string) => void,
/** Whether the selector should be visible. */
visible?: boolean,
/** Whether the selector should be focused. */
focused?: boolean,
+ /** Reaction already applied to the related item */
+ meEmojiReact?: EmojiReact,
}
/** Panel with a row of emoji buttons. */
-const EmojiSelector: React.FC = ({ emojis, onReact, visible = false, focused = false }): JSX.Element => {
+const EmojiSelector: React.FC = ({ emojis, onReact, visible = false, focused = false, meEmojiReact }): JSX.Element => {
+ const [modalActive, setModalActive] = React.useState(false);
+ const custom_emojis = useAppSelector((state) => getCustomEmojis(state));
+ const features = useFeatures();
- const handleReact = (emoji: string): React.EventHandler => {
+ const handleReact = React.useCallback((emoji: string): React.EventHandler => {
return (e) => {
- onReact(emoji);
e.preventDefault();
e.stopPropagation();
+ onReact(emoji);
};
- };
+ }, [onReact]);
+
+ const handleOpenCustomReact: React.MouseEventHandler = React.useCallback((e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setModalActive(true);
+ }, []);
+
+ const handleUnReact: React.MouseEventHandler = React.useCallback((e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onReact(meEmojiReact.get('name'));
+ }, [onReact, meEmojiReact]);
+
+ const handleCustomReact = React.useCallback((emoji) => {
+ onReact(emoji.native);
+ }, [onReact]);
+
+ const onCloseModal = React.useCallback(() => {
+ setModalActive(false);
+ onReact(null);
+ }, []);
return (
-
- {Array.from(emojis).map((emoji, i) => (
-
- ))}
-
+ <>
+
+ {Array.from(emojis).map((emoji, i) => (
+
+ ))}
+ {
+ features.emojiCustomReacts && (
+ <>
+
+ {
+ !meEmojiReact ? (
+
+ ) : (
+
+ )
+ }
+ >
+ )
+ }
+
+ {
+ features.emojiCustomReacts && (
+ e.stopPropagation()} >
+
+
+ )
+ }
+
+ >
);
};
diff --git a/app/soapbox/containers/emoji_picker_dropdown_container.js b/app/soapbox/containers/emoji_picker_dropdown_container.js
index 500ec31c7..00b28ed59 100644
--- a/app/soapbox/containers/emoji_picker_dropdown_container.js
+++ b/app/soapbox/containers/emoji_picker_dropdown_container.js
@@ -1,24 +1,7 @@
import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
import { useEmoji } from '../actions/emojis';
-import EmojiPicker from '../components/emoji_picker';
-
-
-const getCustomEmojis = createSelector([
- state => state.get('custom_emojis'),
-], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
- const aShort = a.get('shortcode').toLowerCase();
- const bShort = b.get('shortcode').toLowerCase();
-
- if (aShort < bShort) {
- return -1;
- } else if (aShort > bShort) {
- return 1;
- } else {
- return 0;
- }
-}));
+import EmojiPicker, { getCustomEmojis } from '../components/emoji_picker';
const mapStateToProps = state => ({
custom_emojis: getCustomEmojis(state),
diff --git a/app/soapbox/features/compose/components/compose_form.js b/app/soapbox/features/compose/components/compose_form.js
index ff7e69ee2..a32f7a759 100644
--- a/app/soapbox/features/compose/components/compose_form.js
+++ b/app/soapbox/features/compose/components/compose_form.js
@@ -91,6 +91,7 @@ class ComposeForm extends ImmutablePureComponent {
scheduledAt: PropTypes.instanceOf(Date),
features: PropTypes.object.isRequired,
spoilerForced: PropTypes.bool,
+ scheduledStatus: PropTypes.array,
};
static defaultProps = {
@@ -263,7 +264,8 @@ class ComposeForm extends ImmutablePureComponent {
}
render() {
- const { intl, onPaste, showSearch, anyMedia, shouldCondense, autoFocus, isModalOpen, maxTootChars, scheduledStatusCount, features, spoilerForced } = this.props;
+ const { intl, onPaste, showSearch, anyMedia, shouldCondense, autoFocus, isModalOpen, maxTootChars, scheduledStatus, features, spoilerForced } = this.props;
+
const condensed = shouldCondense && !this.state.composeFocused && this.isEmpty() && !this.props.isUploading;
const disabled = this.props.isSubmitting;
const text = [this.props.spoilerText, countableText(this.props.text)].join('');
@@ -298,7 +300,7 @@ class ComposeForm extends ImmutablePureComponent {
return (
- {scheduledStatusCount > 0 && (
+ {scheduledStatus.size > 0 && (
{
+const mapStateToProps = (state: ImmutableMap) => {
const instance = state.get('instance');
+ const now = new Date().getTime();
+
return {
text: state.getIn(['compose', 'text']),
suggestions: state.getIn(['compose', 'suggestions']),
@@ -34,11 +37,12 @@ const mapStateToProps = state => {
isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
isUploading: state.getIn(['compose', 'is_uploading']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
- anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
+ anyMedia: (state.getIn(['compose', 'media_attachments']) as ImmutableMap).size > 0,
isModalOpen: Boolean(state.get('modals').size && state.get('modals').last().modalType === 'COMPOSE'),
maxTootChars: state.getIn(['instance', 'configuration', 'statuses', 'max_characters']),
scheduledAt: state.getIn(['compose', 'schedule']),
- scheduledStatusCount: state.get('scheduled_statuses').size,
+ // we only want to keep scheduled status that werent sent, since server does not push that information to client when scheduled status are posted
+ scheduledStatus: (state.get('scheduled_statuses') as ImmutableMap).filter((s) => new Date(s.scheduled_at).getTime() > now),
features: getFeatures(instance),
};
};
diff --git a/app/soapbox/features/directory/index.tsx b/app/soapbox/features/directory/index.tsx
index 6d3ca032a..afad5195b 100644
--- a/app/soapbox/features/directory/index.tsx
+++ b/app/soapbox/features/directory/index.tsx
@@ -6,6 +6,7 @@ import { useLocation } from 'react-router-dom';
import { fetchDirectory, expandDirectory } from 'soapbox/actions/directory';
import LoadMore from 'soapbox/components/load_more';
+import { Spinner } from 'soapbox/components/ui';
import Toggle from 'soapbox/components/ui/toggle/toggle';
import Column from 'soapbox/features/ui/components/column';
import { useAppSelector } from 'soapbox/hooks';
@@ -19,6 +20,7 @@ const messages = defineMessages({
newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
+ description: { id: 'directory.description', defaultMessage: 'Only accounts that have consented to appear here are displayed.' },
});
const Directory = () => {
@@ -27,6 +29,8 @@ const Directory = () => {
const { search } = useLocation();
const params = new URLSearchParams(search);
+ const [loading, setLoading] = React.useState(true);
+
const accountIds = useAppSelector((state) => state.user_lists.directory.items);
const title = useAppSelector((state) => state.instance.get('title'));
const features = useAppSelector((state) => getFeatures(state.instance));
@@ -34,10 +38,19 @@ const Directory = () => {
const [order, setOrder] = useState(params.get('order') || 'active');
const [local, setLocal] = useState(!!params.get('local'));
- useEffect(() => {
- dispatch(fetchDirectory({ order: order || 'active', local: local || false }));
+ const load = React.useCallback(async() => {
+ setLoading(true);
+ try {
+ await dispatch(fetchDirectory({ order: order || 'active', local: local || false }));
+ } finally {
+ setLoading(false);
+ }
}, [order, local]);
+ useEffect(() => {
+ load();
+ }, [load]);
+
const handleChangeOrder: React.ChangeEventHandler = e => {
if (e.target.checked) setOrder('new');
else setOrder('active');
@@ -48,12 +61,20 @@ const Directory = () => {
else setLocal(false);
};
- const handleLoadMore = () => {
- dispatch(expandDirectory({ order: order || 'active', local: local || false }));
- };
+ const handleLoadMore = React.useCallback(async() => {
+ setLoading(true);
+ try {
+ await dispatch(expandDirectory({ order: order || 'active', local: local || false }));
+ } finally {
+ setLoading(false);
+ }
+ }, [order, local]);
return (
+
+ { intl.formatMessage(messages.description) }
+
@@ -70,8 +91,10 @@ const Directory = () => {
{accountIds.map((accountId) =>
)}
-
-
+ { loading &&
}
+
+
+
);
diff --git a/app/soapbox/features/edit_email/index.tsx b/app/soapbox/features/edit_email/index.tsx
index bd9becc2e..a30cf9cd5 100644
--- a/app/soapbox/features/edit_email/index.tsx
+++ b/app/soapbox/features/edit_email/index.tsx
@@ -15,6 +15,7 @@ const messages = defineMessages({
passwordFieldLabel: { id: 'security.fields.password.label', defaultMessage: 'Password' },
submit: { id: 'security.submit', defaultMessage: 'Save changes' },
cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
+ description: { id: 'edit_email.description', defaultMessage: 'To change your email, you must re-enter your password' },
});
const initialState = { email: '', password: '' };
@@ -41,9 +42,10 @@ const EditEmail = () => {
dispatch(snackbar.success(intl.formatMessage(messages.updateEmailSuccess)));
}).finally(() => {
setLoading(false);
- }).catch(() => {
+ }).catch((msg) => {
+ console.error(msg);
setState((prevState) => ({ ...prevState, password: '' }));
- dispatch(snackbar.error(intl.formatMessage(messages.updateEmailFail)));
+ dispatch(snackbar.error(`${intl.formatMessage(messages.updateEmailFail)}: ${msg}`));
});
}, [email, password, dispatch, intl]);
@@ -61,6 +63,11 @@ const EditEmail = () => {
+
+ {
+ intl.formatMessage(messages.description)
+ }
+