mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat: add iframe challenges (e.g. mintpass)
This commit is contained in:
@@ -100,6 +100,10 @@ To have your board appear in a directory on the 5chan homepage:
|
||||
- **Web client and electron client**: `yarn electron:start`
|
||||
- **Web client and electron client** (don't delete data): `yarn electron:start:no-delete-data`
|
||||
|
||||
### Challenge Types
|
||||
|
||||
Subplebbits can require challengers to solve one or more prompts before a publication is accepted. 5chan already supported text and image prompts, and now also handles `url/iframe` challenges so Mintpass communities can run their iframe flow directly inside the modal. The modal first shows a hostname confirmation (showing only the host for mintpass.org, full URL otherwise), then opens the HTTPS iframe with the current theme, replaces `{userAddress}` tokens with the signed-in address, and submits automatically when the user finishes.
|
||||
|
||||
### Build
|
||||
|
||||
The Linux/Windows/macOS/Android build scripts are in [.github/workflows/release.yml](https://github.com/plebbit/5chan/blob/master/.github/workflows/release.yml)
|
||||
|
||||
@@ -98,8 +98,55 @@
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.iframeContainer {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.iframeChallengeWarning {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.iframeWrapper {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: var(--challenge-modal-border);
|
||||
background-color: var(--challenge-modal-background-color);
|
||||
}
|
||||
|
||||
.iframeFooter {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.iframeInstruction {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.iframeCloseButton {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.title {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.iframeWrapper {
|
||||
width: 100vw;
|
||||
height: 70vh;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Challenge as ChallengeType } from '@plebbit/plebbit-react-hooks';
|
||||
import { getPublicationType } from '../../lib/utils/challenge-utils';
|
||||
import { Challenge as ChallengeType, useAccount, useComment } from '@plebbit/plebbit-react-hooks';
|
||||
import { getPublicationPreview, getPublicationType, getVotePreview } from '../../lib/utils/challenge-utils';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import useChallengesStore from '../../stores/use-challenges-store';
|
||||
import useTheme from '../../hooks/use-theme';
|
||||
import styles from './challenge-modal.module.css';
|
||||
import _ from 'lodash';
|
||||
import { useSpring, animated } from '@react-spring/web';
|
||||
import { useDrag } from '@use-gesture/react';
|
||||
|
||||
const useParentAddress = (parentCid?: string) => {
|
||||
const parentComment = useComment({ commentCid: parentCid, onlyIfCached: true });
|
||||
return parentComment?.author?.shortAddress;
|
||||
};
|
||||
|
||||
interface ChallengeProps {
|
||||
challenge: ChallengeType;
|
||||
closeModal: () => void;
|
||||
@@ -16,53 +22,28 @@ interface ChallengeProps {
|
||||
|
||||
const Challenge = ({ challenge, closeModal }: ChallengeProps) => {
|
||||
const { t } = useTranslation();
|
||||
const account = useAccount();
|
||||
const [theme] = useTheme();
|
||||
|
||||
const challenges = challenge?.[0]?.challenges;
|
||||
const publication = challenge?.[1];
|
||||
const publicationTarget = challenge?.[2];
|
||||
|
||||
const publicationType = getPublicationType(publication);
|
||||
const { author, content, link, title } = publication || {};
|
||||
const publicationContent = publicationType === 'vote' ? getPublicationPreview(publicationTarget) : getPublicationPreview(publication);
|
||||
const votePreview = getVotePreview(publication);
|
||||
|
||||
const { author, content, link, title, parentCid, shortSubplebbitAddress, subplebbitAddress } = publication || {};
|
||||
const { displayName } = author || {};
|
||||
const parentAddress = useParentAddress(parentCid);
|
||||
const subplebbit = shortSubplebbitAddress || subplebbitAddress;
|
||||
|
||||
const [currentChallengeIndex, setCurrentChallengeIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<string[]>([]);
|
||||
|
||||
const isTextChallenge = challenges[currentChallengeIndex].type === 'text/plain';
|
||||
const isImageChallenge = challenges[currentChallengeIndex].type === 'image/png';
|
||||
|
||||
const onAnswersChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setAnswers((prevAnswers) => {
|
||||
const updatedAnswers = [...prevAnswers];
|
||||
updatedAnswers[currentChallengeIndex] = e.target.value;
|
||||
return updatedAnswers;
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
publication.publishChallengeAnswers(answers);
|
||||
setAnswers([]);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const onEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (!answers[currentChallengeIndex]) return;
|
||||
if (challenges[currentChallengeIndex + 1]) {
|
||||
setCurrentChallengeIndex((prev) => prev + 1);
|
||||
} else {
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onEscapeKey);
|
||||
return () => document.removeEventListener('keydown', onEscapeKey);
|
||||
}, [closeModal]);
|
||||
const [showIframeConfirmation, setShowIframeConfirmation] = useState(true);
|
||||
const [iframeUrlState, setIframeUrl] = useState('');
|
||||
const [iframeOrigin, setIframeOrigin] = useState('');
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
const isMobile = useIsMobile();
|
||||
@@ -72,6 +53,17 @@ const Challenge = ({ challenge, closeModal }: ChallengeProps) => {
|
||||
y: window.innerHeight / 2 - 200,
|
||||
}));
|
||||
|
||||
const currentChallenge = challenges?.[currentChallengeIndex];
|
||||
const isTextChallenge = currentChallenge?.type === 'text/plain';
|
||||
const isImageChallenge = currentChallenge?.type === 'image/png';
|
||||
const isIframeChallenge = currentChallenge?.type === 'url/iframe';
|
||||
|
||||
useEffect(() => {
|
||||
setShowIframeConfirmation(true);
|
||||
setIframeUrl('');
|
||||
setIframeOrigin('');
|
||||
}, [currentChallengeIndex, currentChallenge?.type]);
|
||||
|
||||
const bind = useDrag(
|
||||
({ active, event, offset: [ox, oy] }) => {
|
||||
if (active) {
|
||||
@@ -91,9 +83,172 @@ const Challenge = ({ challenge, closeModal }: ChallengeProps) => {
|
||||
},
|
||||
);
|
||||
|
||||
const modalContent = (
|
||||
const isValidAnswer = (index: number) => !!answers[index] && answers[index].trim() !== '';
|
||||
|
||||
const onAnswersChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setAnswers((prevAnswers) => {
|
||||
const updatedAnswers = [...prevAnswers];
|
||||
updatedAnswers[currentChallengeIndex] = e.target.value;
|
||||
return updatedAnswers;
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!publication) {
|
||||
return;
|
||||
}
|
||||
publication.publishChallengeAnswers(answers);
|
||||
setAnswers([]);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const onIframeClose = useCallback(() => {
|
||||
if (!publication) {
|
||||
return;
|
||||
}
|
||||
publication.publishChallengeAnswers(['']);
|
||||
closeModal();
|
||||
}, [closeModal, publication]);
|
||||
|
||||
const onEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (!isValidAnswer(currentChallengeIndex)) return;
|
||||
if (challenges?.[currentChallengeIndex + 1]) {
|
||||
setCurrentChallengeIndex((prev) => prev + 1);
|
||||
} else {
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (isIframeChallenge) {
|
||||
onIframeClose();
|
||||
} else {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onEscapeKey);
|
||||
return () => document.removeEventListener('keydown', onEscapeKey);
|
||||
}, [closeModal, isIframeChallenge, onIframeClose]);
|
||||
|
||||
const getChallengeUrl = useCallback(() => {
|
||||
try {
|
||||
const iframeUrl = currentChallenge?.challenge;
|
||||
if (!iframeUrl) {
|
||||
return '';
|
||||
}
|
||||
const url = new URL(iframeUrl);
|
||||
if (url.hostname === 'mintpass.org') {
|
||||
return url.hostname;
|
||||
}
|
||||
return url.href;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [currentChallenge]);
|
||||
|
||||
const handleLoadIframe = useCallback(() => {
|
||||
const iframeUrl = currentChallenge?.challenge;
|
||||
if (!iframeUrl) {
|
||||
return;
|
||||
}
|
||||
const rawUserAddress = account?.author?.address?.trim();
|
||||
const requiresUserAddress = iframeUrl.includes('{userAddress}');
|
||||
|
||||
if (requiresUserAddress && !rawUserAddress) {
|
||||
alert('Error: Unable to load challenge without your address. Please sign in and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedAddress = rawUserAddress ? encodeURIComponent(rawUserAddress) : undefined;
|
||||
const replacedUrl = requiresUserAddress && encodedAddress ? iframeUrl.replace(/\{userAddress\}/g, encodedAddress) : iframeUrl;
|
||||
|
||||
try {
|
||||
const validatedUrl = new URL(replacedUrl);
|
||||
if (validatedUrl.protocol !== 'https:') {
|
||||
throw new Error('Only HTTPS iframe challenges are supported');
|
||||
}
|
||||
validatedUrl.pathname = validatedUrl.pathname.replace(/\/{2,}/g, '/');
|
||||
validatedUrl.searchParams.set('theme', theme);
|
||||
const finalUrl = validatedUrl.toString();
|
||||
setIframeUrl(finalUrl);
|
||||
setIframeOrigin(validatedUrl.origin);
|
||||
setShowIframeConfirmation(false);
|
||||
} catch (error) {
|
||||
console.error('Invalid iframe challenge URL', { error });
|
||||
alert('Error: Invalid URL for authentication challenge');
|
||||
closeModal();
|
||||
}
|
||||
}, [account, closeModal, currentChallenge, theme]);
|
||||
|
||||
const sendThemeToIframe = useCallback(() => {
|
||||
if (!iframeRef.current || !iframeOrigin) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
iframeRef.current.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'plebbit-theme',
|
||||
theme,
|
||||
source: 'plebbit-5chan',
|
||||
},
|
||||
iframeOrigin,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Could not send theme to iframe:', error);
|
||||
}
|
||||
}, [iframeOrigin, theme]);
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
sendThemeToIframe();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (iframeRef.current && iframeUrlState && iframeOrigin && !showIframeConfirmation) {
|
||||
sendThemeToIframe();
|
||||
}
|
||||
}, [iframeOrigin, iframeUrlState, sendThemeToIframe, showIframeConfirmation]);
|
||||
|
||||
if (!challenges?.length || !publication || !currentChallenge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const containerClasses = [styles.container];
|
||||
if (isIframeChallenge) {
|
||||
containerClasses.push(styles.iframeContainer);
|
||||
}
|
||||
|
||||
const extraTitleParts: string[] = [];
|
||||
if (subplebbit) {
|
||||
extraTitleParts.push(`p/${subplebbit}`);
|
||||
}
|
||||
if (publicationType === 'vote' && votePreview) {
|
||||
extraTitleParts.push(votePreview.trim());
|
||||
}
|
||||
if (publication?.parentCid) {
|
||||
extraTitleParts.push(parentAddress ? `reply ${parentAddress}` : 'reply');
|
||||
}
|
||||
if (publicationContent && publicationType !== 'vote') {
|
||||
extraTitleParts.push(publicationContent);
|
||||
}
|
||||
const readableUrl = (() => {
|
||||
const url = getChallengeUrl();
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(url);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
className={styles.container}
|
||||
className={containerClasses.join(' ')}
|
||||
ref={nodeRef}
|
||||
style={{
|
||||
x: isMobile ? window.innerWidth / 2 - 150 : x,
|
||||
@@ -106,63 +261,107 @@ const Challenge = ({ challenge, closeModal }: ChallengeProps) => {
|
||||
<button className={styles.closeIcon} onClick={closeModal} title='close' />
|
||||
</div>
|
||||
<div className={styles.publication}>
|
||||
<div className={styles.name}>
|
||||
<input type='text' value={displayName || _.capitalize(t('anonymous'))} disabled />
|
||||
</div>
|
||||
{title && (
|
||||
<div className={styles.subject}>
|
||||
<input type='text' value={title} disabled />
|
||||
</div>
|
||||
)}
|
||||
{content && (
|
||||
<div className={styles.content}>
|
||||
<textarea value={content} disabled cols={48} rows={4} wrap='soft' />
|
||||
</div>
|
||||
)}
|
||||
{link && (
|
||||
<div className={styles.link}>
|
||||
<input type='text' value={link} disabled />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.challengeContainer}>
|
||||
<input
|
||||
className={styles.challengeAnswer}
|
||||
type='text'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
spellCheck='false'
|
||||
placeholder='TYPE THE ANSWER HERE AND PRESS ENTER'
|
||||
onKeyDown={onEnterKey}
|
||||
onChange={onAnswersChange}
|
||||
value={answers[currentChallengeIndex] || ''}
|
||||
autoFocus
|
||||
/>
|
||||
<div className={styles.challengeMediaWrapper}>
|
||||
{isTextChallenge && <div className={styles.challengeMedia}>{challenges[currentChallengeIndex]?.challenge}</div>}
|
||||
{isImageChallenge && <img alt='' className={styles.challengeMedia} src={`data:image/png;base64,${challenges[currentChallengeIndex]?.challenge}`} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.challengeFooter}>
|
||||
<div className={styles.counter}>{t('challenge_counter', { index: currentChallengeIndex + 1, total: challenges?.length })}</div>
|
||||
<span className={styles.buttons}>
|
||||
{!challenges[currentChallengeIndex + 1] && (
|
||||
<button onClick={onSubmit} disabled={!answers[currentChallengeIndex]}>
|
||||
{t('submit')}
|
||||
</button>
|
||||
{isIframeChallenge && !showIframeConfirmation ? null : (
|
||||
<>
|
||||
<div className={styles.name}>
|
||||
<input type='text' value={displayName || _.capitalize(t('anonymous'))} disabled />
|
||||
</div>
|
||||
{title && (
|
||||
<div className={styles.subject}>
|
||||
<input type='text' value={title} disabled />
|
||||
</div>
|
||||
)}
|
||||
{challenges.length > 1 && (
|
||||
<button disabled={!challenges[currentChallengeIndex - 1]} onClick={() => setCurrentChallengeIndex((prev) => prev - 1)}>
|
||||
{t('previous')}
|
||||
</button>
|
||||
{content && (
|
||||
<div className={styles.content}>
|
||||
<textarea value={content} disabled cols={48} rows={4} wrap='soft' />
|
||||
</div>
|
||||
)}
|
||||
{challenges[currentChallengeIndex + 1] && <button onClick={() => setCurrentChallengeIndex((prev) => prev + 1)}>{t('next')}</button>}
|
||||
</span>
|
||||
</div>
|
||||
{link && (
|
||||
<div className={styles.link}>
|
||||
<input type='text' value={link} disabled />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isIframeChallenge ? (
|
||||
<>
|
||||
{showIframeConfirmation ? (
|
||||
<>
|
||||
<div className={styles.challengeMediaWrapper}>
|
||||
<div className={`${styles.challengeMedia} ${styles.iframeChallengeWarning}`}>board wants to open {readableUrl || 'an external site'}</div>
|
||||
</div>
|
||||
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
|
||||
<span className={styles.buttons}>
|
||||
<button onClick={handleLoadIframe}>Open</button>
|
||||
<button onClick={closeModal}>Cancel</button>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={`${styles.challengeMediaWrapper} ${styles.iframeWrapper}`}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={iframeUrlState}
|
||||
sandbox='allow-scripts allow-forms allow-popups allow-same-origin allow-top-navigation-by-user-activation'
|
||||
onLoad={handleIframeLoad}
|
||||
className={styles.iframe}
|
||||
title='Challenge authentication'
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
|
||||
{/* <div className={styles.iframeInstruction}>
|
||||
Complete the challenge in the box above. Keep this window open until done.
|
||||
</div> */}
|
||||
<div className={styles.iframeCloseButton}>
|
||||
<button onClick={onIframeClose}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.challengeContainer}>
|
||||
<input
|
||||
className={styles.challengeAnswer}
|
||||
type='text'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
spellCheck='false'
|
||||
placeholder='TYPE THE ANSWER HERE AND PRESS ENTER'
|
||||
onKeyDown={onEnterKey}
|
||||
onChange={onAnswersChange}
|
||||
value={answers[currentChallengeIndex] || ''}
|
||||
autoFocus
|
||||
/>
|
||||
<div className={styles.challengeMediaWrapper}>
|
||||
{isTextChallenge && <div className={styles.challengeMedia}>{currentChallenge?.challenge}</div>}
|
||||
{isImageChallenge && <img alt='' className={styles.challengeMedia} src={`data:image/png;base64,${currentChallenge?.challenge}`} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.challengeFooter}>
|
||||
<div className={styles.counter}>{t('challenge_counter', { index: currentChallengeIndex + 1, total: challenges?.length })}</div>
|
||||
<span className={styles.buttons}>
|
||||
{!challenges?.[currentChallengeIndex + 1] && (
|
||||
<button onClick={onSubmit} disabled={!isValidAnswer(currentChallengeIndex)}>
|
||||
{t('submit')}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={closeModal}>Cancel</button>
|
||||
{challenges?.length > 1 && (
|
||||
<button disabled={!challenges?.[currentChallengeIndex - 1]} onClick={() => setCurrentChallengeIndex((prev) => prev - 1)}>
|
||||
{t('previous')}
|
||||
</button>
|
||||
)}
|
||||
{challenges?.[currentChallengeIndex + 1] && <button onClick={() => setCurrentChallengeIndex((prev) => prev + 1)}>{t('next')}</button>}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</animated.div>
|
||||
);
|
||||
|
||||
return modalContent;
|
||||
};
|
||||
|
||||
const ChallengeModal = () => {
|
||||
|
||||
@@ -11,10 +11,8 @@ export const alertChallengeVerificationFailed = (challengeVerification: Challeng
|
||||
challengeVerification.challengeErrors !== null &&
|
||||
!Array.isArray(challengeVerification.challengeErrors)
|
||||
) {
|
||||
// Handle challengeErrors as object (extract values and filter for strings)
|
||||
errorMessages = Object.values(challengeVerification.challengeErrors).filter((val): val is string => typeof val === 'string');
|
||||
} else if (Array.isArray(challengeVerification.challengeErrors)) {
|
||||
// Handle challengeErrors as array
|
||||
errorMessages = [...challengeVerification.challengeErrors];
|
||||
} else {
|
||||
console.warn('challengeVerification.challengeErrors is not an object or array:', challengeVerification.challengeErrors);
|
||||
@@ -26,9 +24,10 @@ export const alertChallengeVerificationFailed = (challengeVerification: Challeng
|
||||
}
|
||||
|
||||
const finalMessage = errorMessages.filter(Boolean).join(' ');
|
||||
alert(`p/${publication?.subplebbitAddress} challenge error: ${finalMessage || 'unknown error'}`);
|
||||
|
||||
alert(`p/${publication?.subplebbitAddress} error: ${finalMessage || 'unknown error'}`);
|
||||
} else {
|
||||
console.log(challengeVerification, publication);
|
||||
console.warn('Challenge verification succeeded but no action taken:', challengeVerification);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user