diff --git a/lib/hooks/useResettingState.js b/lib/hooks/useResettingState.js new file mode 100644 index 000000000..5f1021eeb --- /dev/null +++ b/lib/hooks/useResettingState.js @@ -0,0 +1,26 @@ +// @flow + +import _debounce from 'lodash/debounce.js'; +import * as React from 'react'; + +import type { SetState } from '../types/hook-types.js'; + +function useResettingState( + initialState: (() => T) | T, + duration: number, +): [T, SetState] { + const [value, setValue] = React.useState(initialState); + const resetStatusAfterTimeout = React.useRef( + _debounce(() => setValue(initialState), duration), + ); + React.useEffect(() => resetStatusAfterTimeout.current.cancel, []); + + const setNewValue = React.useCallback((newValue: (T => T) | T) => { + setValue(newValue); + resetStatusAfterTimeout.current(); + }, []); + + return React.useMemo(() => [value, setNewValue], [setNewValue, value]); +} + +export { useResettingState }; diff --git a/web/invite-links/view-invite-link-modal.react.js b/web/invite-links/view-invite-link-modal.react.js index c5c1e9477..5fa2b1909 100644 --- a/web/invite-links/view-invite-link-modal.react.js +++ b/web/invite-links/view-invite-link-modal.react.js @@ -1,57 +1,66 @@ // @flow import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { inviteLinkUrl } from 'lib/facts/links.js'; +import { useResettingState } from 'lib/hooks/useResettingState.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import type { InviteLink } from 'lib/types/link-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import css from './view-invite-link-modal.css'; import Button from '../components/button.react.js'; import Modal from '../modals/modal.react.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { +inviteLink: InviteLink, }; +const copiedMessageDurationMs = 2000; function ViewInviteLinkModal(props: Props): React.Node { const { inviteLink } = props; const threadInfo = useSelector( state => threadInfoSelector(state)[inviteLink.communityID], ); const resolvedThreadInfo = useResolvedThreadInfo(threadInfo); const { popModal } = useModalContext(); const url = inviteLinkUrl(inviteLink.name); - const copyLink = React.useCallback(() => { - navigator.clipboard.writeText(url); - }, [url]); + const [copied, setCopied] = useResettingState(false, copiedMessageDurationMs); + const copyLink = React.useCallback(async () => { + try { + await navigator.clipboard.writeText(url); + setCopied(true); + } catch (e) { + setCopied(false); + } + }, [setCopied, url]); + const buttonText = copied ? 'Copied!' : 'Copy'; return (
Use this public link to invite your friends into the community!
Public link
{url}
); } export default ViewInviteLinkModal; diff --git a/web/utils/tooltip-action-utils.js b/web/utils/tooltip-action-utils.js index 27c98c4c6..9096590ff 100644 --- a/web/utils/tooltip-action-utils.js +++ b/web/utils/tooltip-action-utils.js @@ -1,490 +1,483 @@ // @flow import invariant from 'invariant'; -import _debounce from 'lodash/debounce.js'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; +import { useResettingState } from 'lib/hooks/useResettingState.js'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { useCanEditMessage } from 'lib/shared/edit-messages-utils.js'; import { createMessageReply } from 'lib/shared/message-utils.js'; import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js'; import { threadHasPermission, useSidebarExistsOrCanBeCreated, } from 'lib/shared/thread-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { isComposableMessageType } from 'lib/types/message-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { longAbsoluteDate } from 'lib/utils/date-utils.js'; import { type MessageTooltipAction, findTooltipPosition, getMessageActionTooltipStyle, calculateTooltipSize, type TooltipSize, type TooltipPosition, } from './tooltip-utils.js'; import { useEditModalContext } from '../chat/edit-message-provider.js'; import MessageTooltip from '../chat/message-tooltip.react.js'; import type { PositionInfo } from '../chat/position-types.js'; import { useTooltipContext } from '../chat/tooltip-provider.js'; import CommIcon from '../CommIcon.react.js'; import { InputStateContext } from '../input/input-state.js'; import TogglePinModal from '../modals/chat/toggle-pin-modal.react.js'; import { useOnClickPendingSidebar, useOnClickThread, } from '../selectors/thread-selectors.js'; function useMessageTooltipSidebarAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { threadCreatedFromMessage, messageInfo } = item; const { popModal } = useModalContext(); const sidebarExists = !!threadCreatedFromMessage; const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated( threadInfo, item, ); const openThread = useOnClickThread(threadCreatedFromMessage); const openPendingSidebar = useOnClickPendingSidebar(messageInfo, threadInfo); return React.useMemo(() => { if (!sidebarExistsOrCanBeCreated) { return null; } const buttonContent = ; const onClick = (event: SyntheticEvent) => { popModal(); if (threadCreatedFromMessage) { openThread(event); } else { openPendingSidebar(event); } }; return { actionButtonContent: buttonContent, onClick, label: sidebarExists ? 'Go to thread' : 'Create thread', }; }, [ popModal, openPendingSidebar, openThread, sidebarExists, sidebarExistsOrCanBeCreated, threadCreatedFromMessage, ]); } function useMessageTooltipReplyAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { messageInfo } = item; const { popModal } = useModalContext(); const inputState = React.useContext(InputStateContext); invariant(inputState, 'inputState is required'); const { addReply } = inputState; return React.useMemo(() => { if ( !isComposableMessageType(item.messageInfo.type) || !threadHasPermission(threadInfo, threadPermissions.VOICED) ) { return null; } const buttonContent = ; const onClick = () => { popModal(); if (!messageInfo.text) { return; } addReply(createMessageReply(messageInfo.text)); }; return { actionButtonContent: buttonContent, onClick, label: 'Reply', }; }, [popModal, addReply, item.messageInfo.type, messageInfo, threadInfo]); } const copiedMessageDurationMs = 2000; function useMessageCopyAction( item: ChatMessageInfoItem, ): ?MessageTooltipAction { const { messageInfo } = item; - const [successful, setSuccessful] = React.useState(false); - const resetStatusAfterTimeout = React.useRef( - _debounce(() => setSuccessful(false), copiedMessageDurationMs), + const [successful, setSuccessful] = useResettingState( + false, + copiedMessageDurationMs, ); - const onSuccess = React.useCallback(() => { - setSuccessful(true); - resetStatusAfterTimeout.current(); - }, []); - - React.useEffect(() => resetStatusAfterTimeout.current.cancel, []); - return React.useMemo(() => { if (messageInfo.type !== messageTypes.TEXT) { return null; } const buttonContent = ; const onClick = async () => { try { await navigator.clipboard.writeText(messageInfo.text); - onSuccess(); + setSuccessful(true); } catch (e) { setSuccessful(false); } }; return { actionButtonContent: buttonContent, onClick, label: successful ? 'Copied!' : 'Copy', }; - }, [messageInfo.text, messageInfo.type, onSuccess, successful]); + }, [messageInfo.text, messageInfo.type, setSuccessful, successful]); } function useMessageReactAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { messageInfo } = item; const { setShouldRenderEmojiKeyboard } = useTooltipContext(); const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); return React.useMemo(() => { if (!canCreateReactionFromMessage) { return null; } const buttonContent = ; const onClickReact = () => { if (!setShouldRenderEmojiKeyboard) { return; } setShouldRenderEmojiKeyboard(true); }; return { actionButtonContent: buttonContent, onClick: onClickReact, label: 'React', }; }, [canCreateReactionFromMessage, setShouldRenderEmojiKeyboard]); } function useMessageTogglePinAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { pushModal } = useModalContext(); const { messageInfo, isPinned } = item; const canTogglePin = isComposableMessageType(messageInfo.type) && threadHasPermission(threadInfo, threadPermissions.MANAGE_PINS); const inputState = React.useContext(InputStateContext); return React.useMemo(() => { if (!canTogglePin) { return null; } const iconName = isPinned ? 'unpin' : 'pin'; const buttonContent = ; const onClickTogglePin = () => { pushModal( , ); }; return { actionButtonContent: buttonContent, onClick: onClickTogglePin, label: isPinned ? 'Unpin' : 'Pin', }; }, [canTogglePin, inputState, isPinned, pushModal, item, threadInfo]); } function useMessageEditAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { messageInfo } = item; const canEditMessage = useCanEditMessage(threadInfo, messageInfo); const { renderEditModal } = useEditModalContext(); const { clearTooltip } = useTooltipContext(); return React.useMemo(() => { if (!canEditMessage) { return null; } const buttonContent = ; const onClickEdit = () => { clearTooltip(); renderEditModal({ messageInfo: item, threadInfo, isError: false, editedMessageDraft: messageInfo.text, }); }; return { actionButtonContent: buttonContent, onClick: onClickEdit, label: 'Edit', }; }, [ canEditMessage, clearTooltip, item, messageInfo.text, renderEditModal, threadInfo, ]); } function useMessageTooltipActions( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): $ReadOnlyArray { const sidebarAction = useMessageTooltipSidebarAction(item, threadInfo); const replyAction = useMessageTooltipReplyAction(item, threadInfo); const copyAction = useMessageCopyAction(item); const reactAction = useMessageReactAction(item, threadInfo); const togglePinAction = useMessageTogglePinAction(item, threadInfo); const editAction = useMessageEditAction(item, threadInfo); return React.useMemo( () => [ replyAction, sidebarAction, copyAction, reactAction, togglePinAction, editAction, ].filter(Boolean), [ replyAction, sidebarAction, copyAction, reactAction, togglePinAction, editAction, ], ); } type UseMessageTooltipArgs = { +availablePositions: $ReadOnlyArray, +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, }; type UseMessageTooltipResult = { onMouseEnter: (event: SyntheticEvent) => void, onMouseLeave: ?() => mixed, }; type CreateTooltipParams = { +tooltipMessagePosition: ?PositionInfo, +tooltipSize: TooltipSize, +availablePositions: $ReadOnlyArray, +containsInlineEngagement: boolean, +tooltipActions: $ReadOnlyArray, +messageTimestamp: string, +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, }; function createTooltip(params: CreateTooltipParams) { const { tooltipMessagePosition, tooltipSize, availablePositions, containsInlineEngagement, tooltipActions, messageTimestamp, item, threadInfo, } = params; if (!tooltipMessagePosition) { return undefined; } const tooltipPosition = findTooltipPosition({ sourcePositionInfo: tooltipMessagePosition, tooltipSize, availablePositions, defaultPosition: availablePositions[0], preventDisplayingBelowSource: containsInlineEngagement, }); if (!tooltipPosition) { return undefined; } const tooltipPositionStyle = getMessageActionTooltipStyle({ tooltipPosition, sourcePositionInfo: tooltipMessagePosition, tooltipSize, }); const tooltip = ( ); return { tooltip, tooltipPositionStyle }; } function useMessageTooltip({ availablePositions, item, threadInfo, }: UseMessageTooltipArgs): UseMessageTooltipResult { const [onMouseLeave, setOnMouseLeave] = React.useState mixed>(null); const { renderTooltip } = useTooltipContext(); const tooltipActions = useMessageTooltipActions(item, threadInfo); const containsInlineEngagement = !!item.threadCreatedFromMessage; const messageTimestamp = React.useMemo(() => { const time = item.messageInfo.time; return longAbsoluteDate(time); }, [item.messageInfo.time]); const tooltipSize = React.useMemo(() => { if (typeof document === 'undefined') { return { width: 0, height: 0, }; } const tooltipLabels = tooltipActions.map(action => action.label); return calculateTooltipSize({ tooltipLabels, timestamp: messageTimestamp, }); }, [messageTimestamp, tooltipActions]); const updateTooltip = React.useRef(); const [tooltipMessagePosition, setTooltipMessagePosition] = React.useState(); const onMouseEnter = React.useCallback( (event: SyntheticEvent) => { if (!renderTooltip) { return; } const rect = event.currentTarget.getBoundingClientRect(); const { top, bottom, left, right, height, width } = rect; const messagePosition = { top, bottom, left, right, height, width }; setTooltipMessagePosition(messagePosition); const tooltipResult = createTooltip({ tooltipMessagePosition, tooltipSize, availablePositions, containsInlineEngagement, tooltipActions, messageTimestamp, item, threadInfo, }); if (!tooltipResult) { return; } const { tooltip, tooltipPositionStyle } = tooltipResult; const renderTooltipResult = renderTooltip({ newNode: tooltip, tooltipPositionStyle, }); if (renderTooltipResult) { const { onMouseLeaveCallback: callback } = renderTooltipResult; setOnMouseLeave((() => callback: () => () => mixed)); updateTooltip.current = renderTooltipResult.updateTooltip; } }, [ availablePositions, containsInlineEngagement, item, messageTimestamp, renderTooltip, threadInfo, tooltipActions, tooltipMessagePosition, tooltipSize, ], ); React.useEffect(() => { if (!updateTooltip.current) { return; } const tooltipResult = createTooltip({ tooltipMessagePosition, tooltipSize, availablePositions, containsInlineEngagement, tooltipActions, messageTimestamp, item, threadInfo, }); if (!tooltipResult) { return; } updateTooltip.current?.(tooltipResult.tooltip); }, [ availablePositions, containsInlineEngagement, item, messageTimestamp, threadInfo, tooltipActions, tooltipMessagePosition, tooltipSize, ]); return { onMouseEnter, onMouseLeave, }; } export { useMessageTooltipSidebarAction, useMessageTooltipReplyAction, useMessageReactAction, useMessageTooltipActions, useMessageTooltip, };