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..9b1fcbc63 --- /dev/null +++ b/src/features/ui/components/modals/captcha-modal/captcha-modal.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { Modal, Stack } from 'soapbox/components/ui'; + +import Captcha from './captcha'; + +interface ICaptchaModal { + onClose: (type?: string) => void; +} + +const CaptchaModal: React.FC = ({ onClose }) => { + return ( + } onClose={onClose} width='sm'> + + + + + ); +}; + +export default CaptchaModal; \ No newline at end of file diff --git a/src/features/ui/components/modals/captcha-modal/captcha.tsx b/src/features/ui/components/modals/captcha-modal/captcha.tsx new file mode 100644 index 000000000..bc85d3f88 --- /dev/null +++ b/src/features/ui/components/modals/captcha-modal/captcha.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { Button, Spinner, Stack, Text } from 'soapbox/components/ui'; + +import { PuzzleCaptcha } from './puzzle'; +import useCaptcha from './useCaptcha'; + +const Captcha: React.FC = () => { + const { + captcha, + loadCaptcha, + handleChangePosition, + handleSubmit, + isSubmitting, + tryAgain, + yPosition, + xPosition, + } = useCaptcha(); + + return ( + + + + + +
+ {captcha ? : } +
+ + + + + +
+ ); +}; + +export default Captcha; \ No newline at end of file diff --git a/src/features/ui/components/modals/captcha-modal/puzzle.tsx b/src/features/ui/components/modals/captcha-modal/puzzle.tsx new file mode 100644 index 000000000..1d44cc9d1 --- /dev/null +++ b/src/features/ui/components/modals/captcha-modal/puzzle.tsx @@ -0,0 +1,109 @@ +import React, { useRef, useState } 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 [touchOffsetX, setTouchOffsetX] = useState(0); + const [touchOffsetY, setTouchOffsetY] = useState(0); + + const handlePointerDown = (e: React.PointerEvent) => { + e.currentTarget.setPointerCapture(e.pointerId); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!e.currentTarget.hasPointerCapture(e.pointerId)) return; + + const rect = ref.current?.getBoundingClientRect(); + if (!rect) return; + + const newPosition = { + x: e.clientX - rect.left - e.currentTarget.width / 2, + y: e.clientY - rect.top - e.currentTarget.height / 2, + }; + + onChange(newPosition); + }; + + const handleTouchStart = (event: React.TouchEvent) => { + const imageElement = event.currentTarget.getBoundingClientRect(); + const touch = event.touches[0]; + + const offsetX = touch.clientX - imageElement.left; + const offsetY = touch.clientY - imageElement.top; + + setTouchOffsetX(offsetX); + setTouchOffsetY(offsetY); + }; + + + const handleTouchMove = (event: React.TouchEvent) => { + const touch = event.touches[0]; + + const dropArea = document.getElementById('drop-area')?.getBoundingClientRect(); + if (!dropArea) return; + + const newLeft = touch.clientX - dropArea.left - touchOffsetX; + const newTop = touch.clientY - dropArea.top - touchOffsetY; + + const imageWidth = 61; + const imageHeight = 61; + const limitedLeft = Math.min(Math.max(newLeft, 0), dropArea.width - imageWidth); + const limitedTop = Math.min(Math.max(newTop, 0), dropArea.height - imageHeight); + + onChange({ x: limitedLeft, y: limitedTop }); + }; + + const handlePointerUp = (e: React.PointerEvent) => { + e.currentTarget.releasePointerCapture(e.pointerId); + }; + + const handleTouchDrop = (event: React.TouchEvent) => { + event.preventDefault(); + + const dropArea = document.getElementById('drop-area')?.getBoundingClientRect(); + if (!dropArea) return; + + const touch = event.changedTouches[0]; + + const newLeft = touch.clientX - dropArea.left - touchOffsetX; + const newTop = touch.clientY - dropArea.top - touchOffsetY; + + const imageWidth = 61; + const imageHeight = 61; + + const limitedLeft = Math.min(Math.max(newLeft, 0), dropArea.width - imageWidth); + const limitedTop = Math.min(Math.max(newTop, 0), dropArea.height - imageHeight); + + onChange({ x: limitedLeft, y: limitedTop }); + + }; + + return ( +
+ + + +
+ ); +}; diff --git a/src/features/ui/components/modals/captcha-modal/useCaptcha.ts b/src/features/ui/components/modals/captcha-modal/useCaptcha.ts new file mode 100644 index 000000000..8f891e6c9 --- /dev/null +++ b/src/features/ui/components/modals/captcha-modal/useCaptcha.ts @@ -0,0 +1,114 @@ +import { AxiosError } from 'axios'; +import { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { closeModal } from 'soapbox/actions/modals'; +import { startOnboarding } from 'soapbox/actions/onboarding'; +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); + dispatch(startOnboarding()); + }); + } 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