feat: add iframe challenges (e.g. mintpass)

This commit is contained in:
plebeius
2025-11-26 22:09:03 +01:00
parent b1ebf0b4ae
commit a7dc46fe2e
4 changed files with 348 additions and 99 deletions
+4
View File
@@ -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 = () => {
+3 -4
View File
@@ -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);
}
};