feat: add challenge modal

This commit is contained in:
plebeius.eth
2024-04-26 19:13:23 +02:00
parent 3ce3f090f5
commit cc5d042889
10 changed files with 463 additions and 29 deletions
@@ -0,0 +1,79 @@
.container {
position: fixed;
top: calc(50% - 150px);
left: calc(50% - 150px);
display: block;
padding: 2px;
font-size: 10pt;
border-left: none;
border-top: none;
z-index: 1000;
background-color: var(--challenge-modal-background-color);
border: var(--challenge-modal-border);
}
.title {
font-size: var(--challenge-modal-title-font-size);
text-align: center;
margin-bottom: 1px;
padding: 0;
height: 18px;
line-height: 18px;
cursor: move;
font-weight: var(--challenge-modal-title-font-weight);
background-color: var(--challenge-modal-title-background-color);
color: var(--challenge-modal-title-text-color);
border: var(--challenge-modal-title-border);
}
.closeIcon {
all: unset;
float: right;
cursor: pointer;
margin-bottom: -4px;
width: 18px;
height: 18px;
border: none;
image-rendering: pixelated;
background-image: var(--close-button-background-image);
}
.container input, .container textarea {
border: 1px solid #aaa;
font-family: Arial, Helvetica, sans-serif;
font-size: 10pt;
outline: medium none;
width: 298px;
padding: 2px;
margin-bottom: 1px;
}
.content textarea {
vertical-align: top;
}
.challengeAnswer {
font-size: 11px !important;
padding: 0px 2px;
font-family: monospace !important;
}
.challengeMediaWrapper {
display: flex;
justify-content: center;
align-items: center;
margin: 5px 0;
}
.challengeFooter {
display: flex;
justify-content: space-between;
align-items: center;
box-sizing: border-box;
}
.challengeFooter button {
margin: 5px 5px 0 0;
cursor: pointer;
text-transform: capitalize;
}
@@ -0,0 +1,125 @@
import { useState } from 'react';
import { Challenge as ChallengeType, useComment } from '@plebbit/plebbit-react-hooks';
import { useTranslation } from 'react-i18next';
import useChallenges from '../../hooks/use-challenges';
import styles from './challenge-modal.module.css';
import { getPublicationType } from '../../lib/utils/challenge-utils';
import Draggable from 'react-draggable';
interface ChallengeProps {
challenge: ChallengeType;
closeModal: () => void;
}
const Challenge = ({ challenge, closeModal }: ChallengeProps) => {
const { t } = useTranslation();
const challenges = challenge?.[0]?.challenges;
const publication = challenge?.[1];
const publicationType = getPublicationType(publication);
const { author, content, link, title } = publication || {};
const { displayName } = author || {};
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 (challenges[currentChallengeIndex + 1]) {
setCurrentChallengeIndex((prev) => prev + 1);
} else {
onSubmit();
}
};
return (
<Draggable handle='.challengeHandle'>
<div className={styles.container}>
<div className={`challengeHandle ${styles.title}`}>
Challenge for {publicationType}
<button className={styles.closeIcon} onClick={closeModal} title='close' />
</div>
<div className={styles.publication}>
<div className={styles.name}>
<input type='text' value={displayName || '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={t('loading')} 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}>{t('submit')}</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>
</div>
</Draggable>
);
};
const ChallengeModal = () => {
const { challenges, removeChallenge } = useChallenges();
const isOpen = !!challenges.length;
const closeModal = () => removeChallenge();
return isOpen && <Challenge challenge={challenges[0]} closeModal={closeModal} />;
};
export default ChallengeModal;
+1
View File
@@ -0,0 +1 @@
export { default } from './challenge-modal';
+107 -16
View File
@@ -1,11 +1,55 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useParams } from 'react-router-dom';
import { useAccount, useComment } from '@plebbit/plebbit-react-hooks';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { PublishCommentOptions, setAccount, useAccount, useAccountComment, useComment, usePublishComment } from '@plebbit/plebbit-react-hooks';
import { create } from 'zustand';
import styles from './post-form.module.css';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import { getLinkMediaInfo } from '../../lib/utils/media-utils';
import { isValidURL } from '../../lib/utils/url-utils';
import { isDescriptionView, isPostPageView, isRulesView } from '../../lib/utils/view-utils';
import styles from './post-form.module.css';
import challengesStore from '../../hooks/use-challenges';
type SubmitState = {
subplebbitAddress: string | undefined;
title: string | undefined;
content: string | undefined;
link: string | undefined;
publishCommentOptions: PublishCommentOptions;
setSubmitStore: (data: Partial<SubmitState>) => void;
resetSubmitStore: () => void;
};
const { addChallenge } = challengesStore.getState();
const useSubmitStore = create<SubmitState>((set) => ({
subplebbitAddress: undefined,
title: undefined,
content: undefined,
link: undefined,
publishCommentOptions: {},
setSubmitStore: ({ subplebbitAddress, title, content, link }) =>
set((state) => {
const nextState = { ...state };
if (subplebbitAddress !== undefined) nextState.subplebbitAddress = subplebbitAddress;
if (title !== undefined) nextState.title = title || undefined;
if (content !== undefined) nextState.content = content || undefined;
if (link !== undefined) nextState.link = link || undefined;
nextState.publishCommentOptions = {
...nextState,
onChallenge: (...args: any) => addChallenge(args),
onChallengeVerification: alertChallengeVerificationFailed,
onError: (error: Error) => {
console.error(error);
let errorMessage = error.message;
alert(errorMessage);
},
};
return nextState;
}),
resetSubmitStore: () => set({ subplebbitAddress: undefined, title: undefined, content: undefined, link: undefined, publishCommentOptions: {} }),
}));
const LinkTypePreviewer = ({ link }: { link: string }) => {
const mediaInfo = getLinkMediaInfo(link);
@@ -14,9 +58,44 @@ const LinkTypePreviewer = ({ link }: { link: string }) => {
const PostFormTable = () => {
const account = useAccount();
const { displayName } = account || {};
const { displayName } = account?.author || {};
const [link, setLink] = useState('');
const [url, setUrl] = useState('');
const { title, content, link, publishCommentOptions, setSubmitStore, resetSubmitStore } = useSubmitStore();
const { index, publishComment } = usePublishComment(publishCommentOptions);
const onPublish = () => {
if (!title && !content && !link) {
alert(`Cannot post empty comment`);
return;
}
if (link && !isValidURL(link)) {
alert(`Invalid link`);
return;
}
publishComment();
};
const params = useParams();
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
const subplebbitAddress = params?.subplebbitAddress || accountComment?.subplebbitAddress;
useEffect(() => {
if (subplebbitAddress) {
setSubmitStore({ subplebbitAddress });
}
}, [subplebbitAddress, setSubmitStore]);
// redirect to pending page when pending comment is created
const navigate = useNavigate();
useEffect(() => {
if (typeof index === 'number') {
resetSubmitStore();
navigate(`/profile/${index}`);
}
}, [index, resetSubmitStore, navigate]);
return (
<table className={styles.postFormTable}>
@@ -24,30 +103,44 @@ const PostFormTable = () => {
<tr>
<td>Name</td>
<td>
<input type='text' placeholder={!displayName ? 'Anonymous' : undefined} defaultValue={displayName || undefined} />
<input
type='text'
placeholder={!displayName ? 'Anonymous' : undefined}
defaultValue={displayName || undefined}
onChange={(e) => setAccount({ ...account, author: { ...account?.author, displayName: e.target.value } })}
/>
</td>
</tr>
<tr>
<td>Subject</td>
<td>
<input type='text' />
<button>Post</button>
<input type='text' onChange={(e) => setSubmitStore({ title: e.target.value })} />
<button onClick={onPublish}>Post</button>
</td>
</tr>
<tr>
<td>Comment</td>
<td>
<textarea cols={48} rows={4} wrap='soft' />
<textarea cols={48} rows={4} wrap='soft' onChange={(e) => setSubmitStore({ content: e.target.value })} />
</td>
</tr>
<tr>
<td>Link</td>
<td>
<input type='text' onChange={(e) => setLink(e.target.value)} />
<input
type='text'
autoCorrect='off'
autoComplete='off'
spellCheck='false'
onChange={(e) => {
setUrl(e.target.value);
setSubmitStore({ link: e.target.value });
}}
/>
<span className={styles.linkType}>
{link && (
{url && (
<>
(<LinkTypePreviewer link={link} />)
(<LinkTypePreviewer link={url} />)
</>
)}
</span>
@@ -66,9 +159,7 @@ const PostForm = () => {
const isInDescriptionView = isDescriptionView(location.pathname, params);
const isInRulesView = isRulesView(location.pathname, params);
const { subplebbitAddress, commentCid } = params || {};
const comment = useComment({ commentCid });
const comment = useComment({ commentCid: useParams().commentCid });
const { deleted, locked, removed } = comment || {};
const isThreadClosed = deleted || locked || removed || isInDescriptionView || isInRulesView;
+10
View File
@@ -303,6 +303,11 @@
text-align: right;
}
.postMobile .postNumLink a {
color: unset;
text-decoration: unset;
}
.postMobile .postMessage {
padding: 10px;
font-size: var(--post-mobile-content-font-size);
@@ -416,6 +421,11 @@
text-transform: capitalize;
}
.pendingCid {
color: red !important;
font-weight: 700 !important;
}
@media (max-width: 640px) {
.postDesktop {
display: none;
+39 -10
View File
@@ -5,7 +5,7 @@ import { Role, useSubplebbit } from '@plebbit/plebbit-react-hooks';
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
import { getCommentMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
import { getFormattedDate } from '../../lib/utils/time-utils';
import { isPostPageView } from '../../lib/utils/view-utils';
import { isPostPageView, isPendingPostView } from '../../lib/utils/view-utils';
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
import useReplies from '../../hooks/use-replies';
import useWindowWidth from '../../hooks/use-window-width';
@@ -24,13 +24,16 @@ interface PostProps {
const PostDesktop = ({ post, roles, showAllReplies }: PostProps) => {
const { t } = useTranslation();
const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, postCid, replyCount, shortCid, subplebbitAddress, timestamp, title } = post || {};
const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, postCid, replyCount, shortCid, state, subplebbitAddress, timestamp, title } = post || {};
const { address, displayName, shortAddress } = author || {};
const authorRole = roles?.[address]?.role;
const { isDescription, isRules } = post || {}; // custom properties, not from api
const isInPostPage = isPostPageView(useLocation().pathname, useParams());
const params = useParams();
const location = useLocation();
const isInPostPage = isPostPageView(location.pathname, params);
const isInPendingPostPage = isPendingPostView(location.pathname, params);
const displayTitle = title && title.length > 75 ? title?.slice(0, 75) + '...' : title;
const displayContent = content && !isInPostPage && content.length > 1000 ? content?.slice(0, 1000) + '(...)' : content;
@@ -120,12 +123,21 @@ const PostDesktop = ({ post, roles, showAllReplies }: PostProps) => {
<span className={styles.postNum}>
{!(isDescription || isRules) && (
<span className={styles.postNumLink}>
<Link to={`/p/${subplebbitAddress}/${cid}`} className={styles.linkToPost} title={t('link_to_post')}>
<Link
to={`/p/${subplebbitAddress}/${cid}`}
className={styles.linkToPost}
title={t('link_to_post')}
onClick={(e) => isInPendingPostPage && e.preventDefault()}
>
c/
</Link>
{isInPendingPostPage ? (
<span className={styles.pendingCid}>{state === 'failed' ? 'Failed' : 'Pending'}</span>
) : (
<span className={styles.replyToPost} title={t('reply_to_post')}>
{shortCid}
</span>
)}
</span>
)}
{pinned && (
@@ -305,14 +317,18 @@ const ReplyDesktop = ({ reply, roles }: PostProps) => {
const PostMobile = ({ post, roles, showAllReplies }: PostProps) => {
const { t } = useTranslation();
const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, replyCount, shortCid, subplebbitAddress, timestamp, title } = post || {};
const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, replyCount, shortCid, state, subplebbitAddress, timestamp, title } = post || {};
const { address, displayName, shortAddress } = author || {};
const authorRole = roles?.[address]?.role;
const { isDescription, isRules } = post || {}; // custom properties, not from api
const params = useParams();
const location = useLocation();
const isInPostPage = isPostPageView(location.pathname, params);
const isInPendingPostPage = isPendingPostView(location.pathname, params);
const linkCount = useCountLinksInReplies(post);
const isInPostPage = isPostPageView(useLocation().pathname, useParams());
const displayTitle = title && title.length > 30 ? title?.slice(0, 30) + '(...)' : title;
const displayContent = content && !isInPostPage && content.length > 1000 ? content?.slice(0, 1000) : content;
@@ -360,10 +376,23 @@ const PostMobile = ({ post, roles, showAllReplies }: PostProps) => {
<span className={styles.dateTimePostNum}>
{getFormattedDate(timestamp)}{' '}
{!(isDescription || isRules) && (
<>
<span className={styles.linkToPost}>c/</span>
<span className={styles.replyToPost}>{shortCid}</span>
</>
<span className={styles.postNumLink}>
<Link
to={`/p/${subplebbitAddress}/${cid}`}
className={styles.linkToPost}
title={t('link_to_post')}
onClick={(e) => isInPendingPostPage && e.preventDefault()}
>
c/
</Link>
{isInPendingPostPage ? (
<span className={styles.pendingCid}>{state === 'failed' ? 'Failed' : 'Pending'}</span>
) : (
<span className={styles.replyToPost} title={t('reply_to_post')}>
{shortCid}
</span>
)}
</span>
)}
</span>
</div>
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand';
import { Challenge } from '@plebbit/plebbit-react-hooks';
interface State {
challenges: Challenge[];
addChallenge: (challenge: Challenge) => void;
removeChallenge: () => void;
}
const useChallengesStore = create<State>((set) => ({
challenges: [],
addChallenge: (challenge: Challenge) => {
set((state) => ({ challenges: [...state.challenges, challenge] }));
},
removeChallenge: () => {
set((state) => {
const challenges = [...state.challenges];
challenges.shift();
return { challenges };
});
},
}));
export default useChallengesStore;
+63
View File
@@ -0,0 +1,63 @@
import { ChallengeVerification } from '@plebbit/plebbit-react-hooks';
export const alertChallengeVerificationFailed = (challengeVerification: ChallengeVerification, publication: any) => {
if (challengeVerification?.challengeSuccess === false) {
console.warn(challengeVerification, publication);
alert(`p/${publication?.subplebbitAddress} challenge error: ${[...(challengeVerification?.challengeErrors || []), challengeVerification?.reason].join(' ')}`);
} else {
console.log(challengeVerification, publication);
}
};
export const getPublicationType = (publication: any) => {
if (!publication) {
return;
}
if (typeof publication.vote === 'number') {
return 'vote';
}
if (publication.parentCid) {
return 'reply';
}
if (publication.commentCid) {
return 'edit';
}
return 'post';
};
export const getVotePreview = (publication: any) => {
if (typeof publication?.vote !== 'number') {
return '';
}
let votePreview = '';
if (publication.vote === -1) {
votePreview += ' -1';
} else {
votePreview += ` +${publication.vote}`;
}
return votePreview;
};
export const getPublicationPreview = (publication: any) => {
if (!publication) {
return '';
}
let publicationPreview = '';
if (publication.title) {
publicationPreview += publication.title;
}
if (publication.content) {
if (publicationPreview) {
publicationPreview += ': ';
}
publicationPreview += publication.content;
}
if (!publicationPreview && publication.link) {
publicationPreview += publication.link;
}
if (publicationPreview.length > 50) {
publicationPreview = publicationPreview.substring(0, 50) + '...';
}
return publicationPreview;
};
+2
View File
@@ -6,3 +6,5 @@ declare module '*.module.css' {
declare module 'ext-name';
declare module 'lodash';
declare module 'react-draggable';
+10
View File
@@ -30,9 +30,19 @@
--catalog-post-menu-btn-opacity: 0.5;
--catalog-post-menu-btn-hover-opacity: 1;
/* challenge modal */
--challenge-modal-background-color: #f0e0d6;
--challenge-modal-border: 1px solid #d9bfb7;
--challenge-modal-title-background-color: #ea8;
--challenge-modal-title-text-color: #800;
--challenge-modal-title-border: 1px solid #800;
--challenge-modal-title-font-size: 10pt;
--challenge-modal-title-font-weight: 700;
/* desktop buttons */
--button-desktop-text-color: #00e;
--button-desktop-text-color-hover: red;
--close-button-background-image: url("/public/assets/buttons/cross-red.png");
/* mobile buttons */
--button-font-size-mobile: 10pt;