diff --git a/src/api/hooks/captcha/useCaptcha.ts b/src/api/hooks/captcha/useCaptcha.ts new file mode 100644 index 000000000..8085376db --- /dev/null +++ b/src/api/hooks/captcha/useCaptcha.ts @@ -0,0 +1,112 @@ +import { AxiosError } from 'axios'; +import { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { closeModal } from 'soapbox/actions/modals'; +import { useApi, useAppDispatch, useInstance } from 'soapbox/hooks'; +import { captchaSchema, type CaptchaData } from 'soapbox/schemas/captcha'; +import toast from 'soapbox/toast'; + + + +const messages = defineMessages({ + sucessMessage: { id: 'nostr_signup.captcha_message.sucess', defaultMessage: 'Incredible! You\'ve successfully completed the captcha. Let\'s move on to the next step!' }, + wrongMessage: { id: 'nostr_signup.captcha_message.wrong', defaultMessage: 'Oops! It looks like your captcha response was incorrect. Please try again.' }, + errorMessage: { id: 'nostr_signup.captcha_message.error', defaultMessage: 'It seems an error has occurred. Please try again. If the problem persists, please contact us.' }, + misbehavingMessage: { id: 'nostr_signup.captcha_message.misbehaving', defaultMessage: 'It looks like we\'re experiencing issues with the {instance}. Please try again. If the error persists, try again later.' }, +}); + +function getRandomNumber(min: number, max: number): number { + return Number((Math.random() * (max - min) + min).toFixed()); +} + +const useCaptcha = () => { + const api = useApi(); + const instance = useInstance(); + const dispatch = useAppDispatch(); + const intl = useIntl(); + const [captcha, setCaptcha] = useState(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [tryAgain, setTryAgain] = useState(false); + const [yPosition, setYPosition] = useState(); + const [xPosition, setXPosition] = useState(); + + const loadCaptcha = async () => { + try { + const topI = getRandomNumber(0, (356 - 61)); + const leftI = getRandomNumber(0, (330 - 61)); + const { data } = await api.get('/api/v1/ditto/captcha'); + if (data) { + const normalizedData = captchaSchema.parse(data); + setCaptcha(normalizedData); + setYPosition(topI); + setXPosition(leftI); + } + + } catch (error) { + toast.error('Error loading captcha:'); + } + }; + + useEffect(() => { + loadCaptcha(); + }, []); + + const handleChangePosition = (point: { x: number; y: number }) => { + setXPosition(point.x); + setYPosition(point.y); + }; + + const handleSubmit = async () => { + setIsSubmitting(true); + + if (captcha) { + const result = { + x: xPosition, + y: yPosition, + }; + + try { + await api.post(`/api/v1/ditto/captcha/${captcha.id}/verify`, result).then(() => { + setTryAgain(true); + + dispatch(closeModal('CAPTCHA')); + toast.success(messages.sucessMessage); + }); + } catch (e) { + setTryAgain(true); + const error = e as AxiosError; + const status = error.request?.status; + + let message; + + switch (status) { + case 400: + message = intl.formatMessage(messages.wrongMessage); + break; + case 422: + message = intl.formatMessage(messages.misbehavingMessage, { instance: instance.title }); + break; + default: + message = intl.formatMessage(messages.errorMessage); + break; + } + toast.error(message); + } + setIsSubmitting(false); + } + }; + + return { + captcha, + loadCaptcha, + handleChangePosition, + handleSubmit, + isSubmitting, + tryAgain, + yPosition, + xPosition, + }; +}; + +export default useCaptcha; \ No newline at end of file diff --git a/src/components/modal-root.tsx b/src/components/modal-root.tsx index 84b7830e1..0612b90ad 100644 --- a/src/components/modal-root.tsx +++ b/src/components/modal-root.tsx @@ -111,6 +111,8 @@ const ModalRoot: React.FC = ({ children, onCancel, onClose, type }) })); } else if ((hasComposeContent || hasEventComposeContent) && type === 'CONFIRM') { dispatch(closeModal('CONFIRM')); + } else if (type === 'CAPTCHA') { + return; } else { onClose(); } diff --git a/src/features/ui/components/modal-root.tsx b/src/features/ui/components/modal-root.tsx index 890ad9c08..b7d27472d 100644 --- a/src/features/ui/components/modal-root.tsx +++ b/src/features/ui/components/modal-root.tsx @@ -46,6 +46,7 @@ import { ZapSplitModal, ZapInvoiceModal, ZapsModal, + CaptchaModal, } from 'soapbox/features/ui/util/async-components'; import ModalLoading from './modal-loading'; @@ -56,6 +57,7 @@ const MODAL_COMPONENTS: Record> = { 'ACTIONS': ActionsModal, 'BIRTHDAYS': BirthdaysModal, 'BOOST': BoostModal, + 'CAPTCHA': CaptchaModal, 'COMPARE_HISTORY': CompareHistoryModal, 'COMPONENT': ComponentModal, 'COMPOSE': ComposeModal, diff --git a/src/features/ui/components/modals/captcha-modal/captcha-modal.tsx b/src/features/ui/components/modals/captcha-modal/captcha-modal.tsx new file mode 100644 index 000000000..5dac02d2d --- /dev/null +++ b/src/features/ui/components/modals/captcha-modal/captcha-modal.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import useCaptcha from 'soapbox/api/hooks/captcha/useCaptcha'; +import { Modal, Button, Spinner, Stack, Text } from 'soapbox/components/ui'; + +import { PuzzleCaptcha } from './components/puzzle'; + +interface ICaptchaModal { + onClose: (type?: string) => void; +} + +const CaptchaModal: React.FC = ({ onClose }) => { + const { + captcha, + loadCaptcha, + handleChangePosition, + handleSubmit, + isSubmitting, + tryAgain, + yPosition, + xPosition, + } = useCaptcha(); + + return ( + } width='sm' + > + + + + + + +
+ {captcha ? : } +
+ + + + + +
+
+
+ ); +}; + +export default CaptchaModal; \ No newline at end of file diff --git a/src/features/ui/components/modals/captcha-modal/components/puzzle.tsx b/src/features/ui/components/modals/captcha-modal/components/puzzle.tsx new file mode 100644 index 000000000..cb7303c6d --- /dev/null +++ b/src/features/ui/components/modals/captcha-modal/components/puzzle.tsx @@ -0,0 +1,64 @@ +import React, { useRef } from 'react'; + +interface IPuzzleCaptcha { + bg: string; + puzzle: string; + position: { x: number; y: number }; + onChange(point: { x: number; y: number }): void; +} + +export const PuzzleCaptcha: React.FC = ({ bg, puzzle, position, onChange }) => { + const ref = useRef(null); + + const calculateNewPosition = ( + clientX: number, + clientY: number, + elementWidth: number, + elementHeight: number, + dropArea: DOMRect, + ) => { + const newX = Math.min(Math.max(clientX - dropArea.left - elementWidth / 2, 0), dropArea.width - elementWidth); + const newY = Math.min(Math.max(clientY - dropArea.top - elementHeight / 2, 0), dropArea.height - elementHeight); + return { x: newX, y: newY }; + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!e.currentTarget.hasPointerCapture(e.pointerId)) return; + const dropArea = ref.current?.getBoundingClientRect(); + if (!dropArea) return; + + const newPosition = calculateNewPosition(e.clientX, e.clientY, e.currentTarget.width, e.currentTarget.height, dropArea); + onChange(newPosition); + }; + + const handleTouchMove = (event: React.TouchEvent) => { + const touch = event.touches[0]; + const dropArea = ref.current?.getBoundingClientRect(); + if (!dropArea) return; + + const newPosition = calculateNewPosition(touch.clientX, touch.clientY, 61, 61, dropArea); + onChange(newPosition); + }; + + return ( +
+ e.currentTarget.setPointerCapture(e.pointerId)} + onPointerMove={handlePointerMove} + onPointerUp={(e) => e.currentTarget.releasePointerCapture(e.pointerId)} + onTouchMove={handleTouchMove} + style={{ + filter: 'drop-shadow(2px 0 0 #fff) drop-shadow(0 2px 0 #fff) drop-shadow(-2px 0 0 #fff) drop-shadow(0 -2px 0 #fff)', + left: position.x, + top: position.y, + }} + draggable={false} + /> + + +
+ ); +}; diff --git a/src/features/ui/components/modals/nostr-signup-modal/steps/keygen-step.tsx b/src/features/ui/components/modals/nostr-signup-modal/steps/keygen-step.tsx index f4692f05b..1d6274ce8 100644 --- a/src/features/ui/components/modals/nostr-signup-modal/steps/keygen-step.tsx +++ b/src/features/ui/components/modals/nostr-signup-modal/steps/keygen-step.tsx @@ -4,13 +4,14 @@ import { FormattedMessage } from 'react-intl'; import { fetchAccount } from 'soapbox/actions/accounts'; import { logInNostr } from 'soapbox/actions/nostr'; -import { startOnboarding } from 'soapbox/actions/onboarding'; +import { closeSidebar } from 'soapbox/actions/sidebar'; import CopyableInput from 'soapbox/components/copyable-input'; import EmojiGraphic from 'soapbox/components/emoji-graphic'; import { Button, Stack, Modal, FormGroup, Text, Tooltip, HStack } from 'soapbox/components/ui'; import { useNostr } from 'soapbox/contexts/nostr-context'; import { NKeys } from 'soapbox/features/nostr/keys'; import { useAppDispatch, useInstance } from 'soapbox/hooks'; +import { useIsMobile } from 'soapbox/hooks/useIsMobile'; import { download } from 'soapbox/utils/download'; import { slugify } from 'soapbox/utils/input'; @@ -21,6 +22,7 @@ interface IKeygenStep { const KeygenStep: React.FC = ({ onClose }) => { const instance = useInstance(); const dispatch = useAppDispatch(); + const isMobile = useIsMobile(); const { relay } = useNostr(); const secretKey = useMemo(() => generateSecretKey(), []); @@ -62,9 +64,13 @@ const KeygenStep: React.FC = ({ onClose }) => { await Promise.all(events.map((event) => relay?.event(event))); await dispatch(logInNostr(pubkey)); - dispatch(startOnboarding()); onClose(); + + if (isMobile) { + dispatch(closeSidebar()); + } + }; return ( @@ -82,7 +88,6 @@ const KeygenStep: React.FC = ({ onClose }) => { -