diff --git a/.eslintrc.cjs b/.eslintrc.cjs index eea505a45..0df30b234 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -55,6 +55,7 @@ module.exports = { }, polyfills: [ 'es:all', // core-js + 'fetch', // not polyfilled, but ignore it 'IntersectionObserver', // npm:intersection-observer 'Promise', // core-js 'ResizeObserver', // npm:resize-observer-polyfill diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a4937018..ee32cbb92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Posts: Support posts filtering on recent Mastodon versions - Reactions: Support custom emoji reactions - Compatbility: Support Mastodon v2 timeline filters. +- Posts: Support dislikes on Friendica. +- UI: added a character counter to some textareas. ### Changed - Posts: truncate Nostr pubkeys in reply mentions. @@ -24,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Profile: fix "load more" button height on account gallery page. - 18n: fixed Chinese language being detected from the browser. - Conversations: fixed pagination (Mastodon). +- Compatibility: fix version parsing for Friendica. ## [3.2.0] - 2023-02-15 diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index 9e43d0f40..40d981139 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -20,6 +20,10 @@ const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST'; const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; +const DISLIKE_REQUEST = 'DISLIKE_REQUEST'; +const DISLIKE_SUCCESS = 'DISLIKE_SUCCESS'; +const DISLIKE_FAIL = 'DISLIKE_FAIL'; + const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; @@ -28,6 +32,10 @@ const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST'; const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; +const UNDISLIKE_REQUEST = 'UNDISLIKE_REQUEST'; +const UNDISLIKE_SUCCESS = 'UNDISLIKE_SUCCESS'; +const UNDISLIKE_FAIL = 'UNDISLIKE_FAIL'; + const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST'; const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; @@ -36,6 +44,10 @@ const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; +const DISLIKES_FETCH_REQUEST = 'DISLIKES_FETCH_REQUEST'; +const DISLIKES_FETCH_SUCCESS = 'DISLIKES_FETCH_SUCCESS'; +const DISLIKES_FETCH_FAIL = 'DISLIKES_FETCH_FAIL'; + const REACTIONS_FETCH_REQUEST = 'REACTIONS_FETCH_REQUEST'; const REACTIONS_FETCH_SUCCESS = 'REACTIONS_FETCH_SUCCESS'; const REACTIONS_FETCH_FAIL = 'REACTIONS_FETCH_FAIL'; @@ -96,7 +108,7 @@ const unreblog = (status: StatusEntity) => }; const toggleReblog = (status: StatusEntity) => - (dispatch: AppDispatch, getState: () => RootState) => { + (dispatch: AppDispatch) => { if (status.reblogged) { dispatch(unreblog(status)); } else { @@ -169,7 +181,7 @@ const unfavourite = (status: StatusEntity) => }; const toggleFavourite = (status: StatusEntity) => - (dispatch: AppDispatch, getState: () => RootState) => { + (dispatch: AppDispatch) => { if (status.favourited) { dispatch(unfavourite(status)); } else { @@ -215,6 +227,79 @@ const unfavouriteFail = (status: StatusEntity, error: AxiosError) => ({ skipLoading: true, }); +const dislike = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(dislikeRequest(status)); + + api(getState).post(`/api/friendica/statuses/${status.get('id')}/dislike`).then(function() { + dispatch(dislikeSuccess(status)); + }).catch(function(error) { + dispatch(dislikeFail(status, error)); + }); + }; + +const undislike = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(undislikeRequest(status)); + + api(getState).post(`/api/friendica/statuses/${status.get('id')}/undislike`).then(() => { + dispatch(undislikeSuccess(status)); + }).catch(error => { + dispatch(undislikeFail(status, error)); + }); + }; + +const toggleDislike = (status: StatusEntity) => + (dispatch: AppDispatch) => { + if (status.disliked) { + dispatch(undislike(status)); + } else { + dispatch(dislike(status)); + } + }; + +const dislikeRequest = (status: StatusEntity) => ({ + type: DISLIKE_REQUEST, + status: status, + skipLoading: true, +}); + +const dislikeSuccess = (status: StatusEntity) => ({ + type: DISLIKE_SUCCESS, + status: status, + skipLoading: true, +}); + +const dislikeFail = (status: StatusEntity, error: AxiosError) => ({ + type: DISLIKE_FAIL, + status: status, + error: error, + skipLoading: true, +}); + +const undislikeRequest = (status: StatusEntity) => ({ + type: UNDISLIKE_REQUEST, + status: status, + skipLoading: true, +}); + +const undislikeSuccess = (status: StatusEntity) => ({ + type: UNDISLIKE_SUCCESS, + status: status, + skipLoading: true, +}); + +const undislikeFail = (status: StatusEntity, error: AxiosError) => ({ + type: UNDISLIKE_FAIL, + status: status, + error: error, + skipLoading: true, +}); + const bookmark = (status: StatusEntity) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(bookmarkRequest(status)); @@ -351,6 +436,38 @@ const fetchFavouritesFail = (id: string, error: AxiosError) => ({ error, }); +const fetchDislikes = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchDislikesRequest(id)); + + api(getState).get(`/api/friendica/statuses/${id}/disliked_by`).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + dispatch(fetchDislikesSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchDislikesFail(id, error)); + }); + }; + +const fetchDislikesRequest = (id: string) => ({ + type: DISLIKES_FETCH_REQUEST, + id, +}); + +const fetchDislikesSuccess = (id: string, accounts: APIEntity[]) => ({ + type: DISLIKES_FETCH_SUCCESS, + id, + accounts, +}); + +const fetchDislikesFail = (id: string, error: AxiosError) => ({ + type: DISLIKES_FETCH_FAIL, + id, + error, +}); + const fetchReactions = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(fetchReactionsRequest(id)); @@ -498,18 +615,27 @@ export { FAVOURITE_REQUEST, FAVOURITE_SUCCESS, FAVOURITE_FAIL, + DISLIKE_REQUEST, + DISLIKE_SUCCESS, + DISLIKE_FAIL, UNREBLOG_REQUEST, UNREBLOG_SUCCESS, UNREBLOG_FAIL, UNFAVOURITE_REQUEST, UNFAVOURITE_SUCCESS, UNFAVOURITE_FAIL, + UNDISLIKE_REQUEST, + UNDISLIKE_SUCCESS, + UNDISLIKE_FAIL, REBLOGS_FETCH_REQUEST, REBLOGS_FETCH_SUCCESS, REBLOGS_FETCH_FAIL, FAVOURITES_FETCH_REQUEST, FAVOURITES_FETCH_SUCCESS, FAVOURITES_FETCH_FAIL, + DISLIKES_FETCH_REQUEST, + DISLIKES_FETCH_SUCCESS, + DISLIKES_FETCH_FAIL, REACTIONS_FETCH_REQUEST, REACTIONS_FETCH_SUCCESS, REACTIONS_FETCH_FAIL, @@ -546,6 +672,15 @@ export { unfavouriteRequest, unfavouriteSuccess, unfavouriteFail, + dislike, + undislike, + toggleDislike, + dislikeRequest, + dislikeSuccess, + dislikeFail, + undislikeRequest, + undislikeSuccess, + undislikeFail, bookmark, unbookmark, toggleBookmark, @@ -563,6 +698,10 @@ export { fetchFavouritesRequest, fetchFavouritesSuccess, fetchFavouritesFail, + fetchDislikes, + fetchDislikesRequest, + fetchDislikesSuccess, + fetchDislikesFail, fetchReactions, fetchReactionsRequest, fetchReactionsSuccess, diff --git a/app/soapbox/actions/moderation.tsx b/app/soapbox/actions/moderation.tsx index 5b0a4a5f2..c236a2986 100644 --- a/app/soapbox/actions/moderation.tsx +++ b/app/soapbox/actions/moderation.tsx @@ -112,27 +112,6 @@ const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () = })); }; -const rejectUserModal = (intl: IntlShape, accountId: string, afterConfirm = () => {}) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - const acct = state.accounts.get(accountId)!.acct; - const name = state.accounts.get(accountId)!.username; - - dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/user-off.svg'), - heading: intl.formatMessage(messages.rejectUserHeading, { acct }), - message: intl.formatMessage(messages.rejectUserPrompt, { acct }), - confirm: intl.formatMessage(messages.rejectUserConfirm, { name }), - onConfirm: () => { - dispatch(deleteUsers([accountId])) - .then(() => { - afterConfirm(); - }) - .catch(() => {}); - }, - })); - }; - const toggleStatusSensitivityModal = (intl: IntlShape, statusId: string, sensitive: boolean, afterConfirm = () => {}) => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); @@ -178,7 +157,6 @@ const deleteStatusModal = (intl: IntlShape, statusId: string, afterConfirm = () export { deactivateUserModal, deleteUserModal, - rejectUserModal, toggleStatusSensitivityModal, deleteStatusModal, }; diff --git a/app/soapbox/components/authorize-reject-buttons.tsx b/app/soapbox/components/authorize-reject-buttons.tsx index 66d8d1d4a..9edd44189 100644 --- a/app/soapbox/components/authorize-reject-buttons.tsx +++ b/app/soapbox/components/authorize-reject-buttons.tsx @@ -1,4 +1,5 @@ -import React, { useState } from 'react'; +import clsx from 'clsx'; +import React, { useEffect, useRef, useState } from 'react'; import { FormattedMessage } from 'react-intl'; import { HStack, IconButton, Text } from 'soapbox/components/ui'; @@ -6,67 +7,133 @@ import { HStack, IconButton, Text } from 'soapbox/components/ui'; interface IAuthorizeRejectButtons { onAuthorize(): Promise | unknown onReject(): Promise | unknown + countdown?: number } /** Buttons to approve or reject a pending item, usually an account. */ -const AuthorizeRejectButtons: React.FC = ({ onAuthorize, onReject }) => { - const [state, setState] = useState<'authorized' | 'rejected' | 'pending'>('pending'); +const AuthorizeRejectButtons: React.FC = ({ onAuthorize, onReject, countdown }) => { + const [state, setState] = useState<'authorizing' | 'rejecting' | 'authorized' | 'rejected' | 'pending'>('pending'); + const timeout = useRef(); - async function handleAuthorize() { - try { - await onAuthorize(); - setState('authorized'); - } catch (e) { - console.error(e); + function handleAction( + present: 'authorizing' | 'rejecting', + past: 'authorized' | 'rejected', + action: () => Promise | unknown, + ): void { + if (state === present) { + if (timeout.current) { + clearTimeout(timeout.current); + } + setState('pending'); + } else { + const doAction = async () => { + try { + await action(); + setState(past); + } catch (e) { + console.error(e); + } + }; + if (typeof countdown === 'number') { + setState(present); + timeout.current = setTimeout(doAction, countdown); + } else { + doAction(); + } } } - async function handleReject() { - try { - await onReject(); - setState('rejected'); - } catch (e) { - console.error(e); - } - } + const handleAuthorize = async () => handleAction('authorizing', 'authorized', onAuthorize); + const handleReject = async () => handleAction('rejecting', 'rejected', onReject); + + useEffect(() => { + return () => { + if (timeout.current) { + clearTimeout(timeout.current); + } + }; + }, []); switch (state) { - case 'pending': - return ( - - - - - ); case 'authorized': return ( -
- - - -
+ } /> ); case 'rejected': return ( -
- - - -
+ } /> + ); + default: + return ( + + + + ); } }; +interface IActionEmblem { + text: React.ReactNode +} + +const ActionEmblem: React.FC = ({ text }) => { + return ( +
+ + {text} + +
+ ); +}; + +interface IAuthorizeRejectButton { + theme: 'primary' | 'danger' + icon: string + action(): void + isLoading?: boolean + disabled?: boolean +} + +const AuthorizeRejectButton: React.FC = ({ theme, icon, action, isLoading, disabled }) => { + return ( +
+ + {(isLoading) && ( +
+ )} +
+ ); +}; + export { AuthorizeRejectButtons }; \ No newline at end of file diff --git a/app/soapbox/components/group-card.tsx b/app/soapbox/components/group-card.tsx index 6d616acba..7b9fa6458 100644 --- a/app/soapbox/components/group-card.tsx +++ b/app/soapbox/components/group-card.tsx @@ -43,7 +43,13 @@ const GroupCard: React.FC = ({ group }) => { {/* Group Info */} - + + + + {group.relationship?.pending_requests && ( +
+ )} + diff --git a/app/soapbox/components/groups/popover/group-popover.tsx b/app/soapbox/components/groups/popover/group-popover.tsx new file mode 100644 index 000000000..776506f99 --- /dev/null +++ b/app/soapbox/components/groups/popover/group-popover.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; + +import { Button, Divider, HStack, Popover, Stack, Text } from 'soapbox/components/ui'; +import GroupMemberCount from 'soapbox/features/group/components/group-member-count'; +import GroupPrivacy from 'soapbox/features/group/components/group-privacy'; + +import GroupAvatar from '../group-avatar'; + +import type { Group } from 'soapbox/schemas'; + +interface IGroupPopoverContainer { + children: React.ReactElement> + isEnabled: boolean + group: Group +} + +const messages = defineMessages({ + title: { id: 'group.popover.title', defaultMessage: 'Membership required' }, + summary: { id: 'group.popover.summary', defaultMessage: 'You must be a member of the group in order to reply to this status.' }, + action: { id: 'group.popover.action', defaultMessage: 'View Group' }, +}); + +const GroupPopover = (props: IGroupPopoverContainer) => { + const { children, group, isEnabled } = props; + + const intl = useIntl(); + + if (!isEnabled) { + return children; + } + + return ( + + + {/* Group Cover Image */} + + {group.header && ( + + )} + + + {/* Group Avatar */} +
+ +
+ + {/* Group Info */} + + + + + + + + +
+ + + + + + {intl.formatMessage(messages.title)} + + + {intl.formatMessage(messages.summary)} + + + +
+ + + +
+ + } + isFlush + children={ +
{children}
+ } + /> + ); +}; + +export default GroupPopover; \ No newline at end of file diff --git a/app/soapbox/components/icon.tsx b/app/soapbox/components/icon.tsx index 300265ea5..421d937dd 100644 --- a/app/soapbox/components/icon.tsx +++ b/app/soapbox/components/icon.tsx @@ -14,6 +14,9 @@ export interface IIcon extends React.HTMLAttributes { className?: string } +/** + * @deprecated Use the UI Icon component directly. + */ const Icon: React.FC = ({ src, alt, className, ...rest }) => { return (
= ({ label, hint, children, onClick, onSelec return (
- {label} + {label} {hint ? ( {hint} @@ -83,12 +82,26 @@ const ListItem: React.FC = ({ label, hint, children, onClick, onSelec
{children} - {isSelected ? ( +
- ) : null} +
) : null} diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 479095e66..2938e91e7 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -8,7 +8,7 @@ import { launchChat } from 'soapbox/actions/chats'; import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose'; import { editEvent } from 'soapbox/actions/events'; import { groupBlock, groupDeleteStatus, groupKick } from 'soapbox/actions/groups'; -import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions'; +import { toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation'; import { initMuteModal } from 'soapbox/actions/mutes'; @@ -24,6 +24,8 @@ import { isLocal, isRemote } from 'soapbox/utils/accounts'; import copy from 'soapbox/utils/copy'; import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts'; +import GroupPopover from './groups/popover/group-popover'; + import type { Menu } from 'soapbox/components/dropdown-menu'; import type { Account, Group, Status } from 'soapbox/types/entities'; @@ -45,6 +47,7 @@ const messages = defineMessages({ cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' }, favourite: { id: 'status.favourite', defaultMessage: 'Like' }, + disfavourite: { id: 'status.disfavourite', defaultMessage: 'Disike' }, open: { id: 'status.open', defaultMessage: 'Expand this post' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, @@ -161,6 +164,14 @@ const StatusActionBar: React.FC = ({ } }; + const handleDislikeClick: React.EventHandler = (e) => { + if (me) { + dispatch(toggleDislike(status)); + } else { + onOpenUnauthorizedModal('DISLIKE'); + } + }; + const handleBookmarkClick: React.EventHandler = (e) => { dispatch(toggleBookmark(status)); }; @@ -608,14 +619,19 @@ const StatusActionBar: React.FC = ({ grow={space === 'expand'} onClick={e => e.stopPropagation()} > - + + + {(features.quotePosts && me) ? ( = ({ ) : ( = ({ /> )} + {features.dislikes && ( + + )} + {canShare && ( { const buttonStyle = clsx({ - 'inline-flex items-center border font-medium rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 appearance-none transition-all': true, + 'inline-flex items-center place-content-center border font-medium rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 appearance-none transition-all': true, 'select-none disabled:opacity-75 disabled:cursor-default': disabled, [`${themes[theme]}`]: true, [`${sizes[size]}`]: true, diff --git a/app/soapbox/components/ui/carousel/carousel.tsx b/app/soapbox/components/ui/carousel/carousel.tsx index ddb10b37a..441c1b1d1 100644 --- a/app/soapbox/components/ui/carousel/carousel.tsx +++ b/app/soapbox/components/ui/carousel/carousel.tsx @@ -13,16 +13,19 @@ interface ICarousel { itemCount: number /** The minimum width per item */ itemWidth: number + /** Should the controls be disabled? */ + isDisabled?: boolean } /** * Carousel */ const Carousel: React.FC = (props): JSX.Element => { - const { children, controlsHeight, itemCount, itemWidth } = props; + const { children, controlsHeight, isDisabled, itemCount, itemWidth } = props; // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_ref, setContainerRef, { width: containerWidth }] = useDimensions(); + const [ref, setContainerRef, { width: finalContainerWidth }] = useDimensions(); + const containerWidth = finalContainerWidth || ref?.clientWidth; const [pageSize, setPageSize] = useState(0); const [currentPage, setCurrentPage] = useState(1); @@ -62,7 +65,7 @@ const Carousel: React.FC = (props): JSX.Element => { data-testid='prev-page' onClick={handlePrevPage} className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25' - disabled={!hasPrevPage} + disabled={!hasPrevPage || isDisabled} > = (props): JSX.Element => { data-testid='next-page' onClick={handleNextPage} className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25' - disabled={!hasNextPage} + disabled={!hasNextPage || isDisabled} > = (props) => { )}
+ {hintText && ( +

+ {hintText} +

+ )} + {firstChild} {inputChildren.filter((_, i) => i !== 0)} @@ -97,12 +103,6 @@ const FormGroup: React.FC = (props) => { {errors.join(', ')}

)} - - {hintText && ( -

- {hintText} -

- )}
); diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index 2de6eb566..bb3f9957d 100644 --- a/app/soapbox/components/ui/input/input.tsx +++ b/app/soapbox/components/ui/input/input.tsx @@ -84,8 +84,10 @@ const Input = React.forwardRef( type={revealed ? 'text' : type} ref={ref} className={clsx('text-base placeholder:text-gray-600 dark:placeholder:text-gray-600', { - 'text-gray-900 dark:text-gray-100 block w-full sm:text-sm dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500': + 'block w-full sm:text-sm dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500': ['normal', 'search'].includes(theme), + 'text-gray-900 dark:text-gray-100': !props.disabled, + 'text-gray-600': props.disabled, 'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal', 'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search', 'pr-7 rtl:pl-7 rtl:pr-3': isPassword || append, diff --git a/app/soapbox/components/ui/popover/popover.tsx b/app/soapbox/components/ui/popover/popover.tsx index 51dc19b4c..7700f8149 100644 --- a/app/soapbox/components/ui/popover/popover.tsx +++ b/app/soapbox/components/ui/popover/popover.tsx @@ -1,18 +1,28 @@ import { arrow, + autoPlacement, FloatingArrow, offset, useClick, useDismiss, useFloating, + useHover, useInteractions, useTransitionStyles, } from '@floating-ui/react'; +import clsx from 'clsx'; import React, { useRef, useState } from 'react'; interface IPopover { children: React.ReactElement> + /** The content of the popover */ content: React.ReactNode + /** Should we remove padding on the Popover */ + isFlush?: boolean + /** Should the popover trigger via click or hover */ + interaction?: 'click' | 'hover' + /** Add a class to the reference (trigger) element */ + referenceElementClassName?: string } /** @@ -22,7 +32,7 @@ interface IPopover { * of information. */ const Popover: React.FC = (props) => { - const { children, content } = props; + const { children, content, referenceElementClassName, interaction = 'hover', isFlush = false } = props; const [isOpen, setIsOpen] = useState(false); @@ -33,6 +43,9 @@ const Popover: React.FC = (props) => { onOpenChange: setIsOpen, placement: 'top', middleware: [ + autoPlacement({ + allowedPlacements: ['top', 'bottom'], + }), offset(10), arrow({ element: arrowRef, @@ -40,8 +53,6 @@ const Popover: React.FC = (props) => { ], }); - const click = useClick(context); - const dismiss = useDismiss(context); const { isMounted, styles } = useTransitionStyles(context, { initial: { opacity: 0, @@ -53,8 +64,13 @@ const Popover: React.FC = (props) => { }, }); + const click = useClick(context, { enabled: interaction === 'click' }); + const hover = useHover(context, { enabled: interaction === 'hover' }); + const dismiss = useDismiss(context); + const { getReferenceProps, getFloatingProps } = useInteractions([ click, + hover, dismiss, ]); @@ -63,7 +79,7 @@ const Popover: React.FC = (props) => { {React.cloneElement(children, { ref: refs.setReference, ...getReferenceProps(), - className: 'cursor-help', + className: clsx(children.props.className, referenceElementClassName), })} {(isMounted) && ( @@ -75,12 +91,22 @@ const Popover: React.FC = (props) => { left: x ?? 0, ...styles, }} - className='rounded-lg bg-white p-6 shadow-2xl dark:bg-gray-900 dark:ring-2 dark:ring-primary-700' + className={ + clsx({ + 'z-40 rounded-lg bg-white shadow-2xl dark:bg-gray-900 dark:ring-2 dark:ring-primary-700': true, + 'p-6': !isFlush, + }) + } {...getFloatingProps()} > {content} - +
)} diff --git a/app/soapbox/components/ui/textarea/textarea.tsx b/app/soapbox/components/ui/textarea/textarea.tsx index 03ddda81d..2b3f54897 100644 --- a/app/soapbox/components/ui/textarea/textarea.tsx +++ b/app/soapbox/components/ui/textarea/textarea.tsx @@ -1,5 +1,9 @@ import clsx from 'clsx'; import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Stack from '../stack/stack'; +import Text from '../text/text'; interface ITextarea extends Pick, 'maxLength' | 'onChange' | 'onKeyDown' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> { /** Put the cursor into the input on mount. */ @@ -28,6 +32,8 @@ interface ITextarea extends Pick) => { + const length = value?.length || 0; const [rows, setRows] = useState(autoGrow ? 1 : 4); const handleChange = (event: React.ChangeEvent) => { @@ -70,20 +79,35 @@ const Textarea = React.forwardRef(({ }; return ( -