From cbe4d2668123f0f581f8dd414c7e553d0459dad6 Mon Sep 17 00:00:00 2001 From: Ben Schwartz Date: Thu, 15 Aug 2024 10:50:10 -0400 Subject: [PATCH 1/2] feat(context): add a RequestDetailContext which wraps everything on the request detail page --- src/components/index.ts | 1 - src/components/wallet/ConnectWalletButton.tsx | 9 +- src/components/wallet/ConnectWalletDialog.tsx | 94 ------------------- src/contexts/RequestDetailContext.tsx | 94 +++++++++++++++++++ src/hooks/useComments.tsx | 62 ++++++------ src/hooks/useRequestDetail.tsx | 11 +++ src/hooks/useSubmissions.tsx | 64 ++++++++----- src/pages/request/[id].tsx | 45 ++++----- 8 files changed, 196 insertions(+), 184 deletions(-) delete mode 100644 src/components/wallet/ConnectWalletDialog.tsx create mode 100644 src/contexts/RequestDetailContext.tsx create mode 100644 src/hooks/useRequestDetail.tsx diff --git a/src/components/index.ts b/src/components/index.ts index 8b165ab..c5befa8 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -9,7 +9,6 @@ export { default as LoadingOverlay } from './common/LoadingOverlay'; export { default as FullScreenLoader } from './common/FullScreenLoader'; export { default as RequireWallet } from './wallet/RequireWallet'; -export { default as ConnectWalletDialog } from './wallet/ConnectWalletDialog'; export { default as ConnectWalletButton } from './wallet/ConnectWalletButton'; export { default as RequestList } from './request/RequestList'; diff --git a/src/components/wallet/ConnectWalletButton.tsx b/src/components/wallet/ConnectWalletButton.tsx index 7ecdc53..f11affa 100644 --- a/src/components/wallet/ConnectWalletButton.tsx +++ b/src/components/wallet/ConnectWalletButton.tsx @@ -1,14 +1,11 @@ -import { useWalletInfo, useWeb3Modal, useWeb3ModalAccount } from '@web3modal/ethers/react'; - import { FMPButton } from '@/components'; +import { useWallet } from '@/hooks/useWallet'; const ConnectWalletButton: React.FC = () => { - const { open } = useWeb3Modal(); - const { isConnected } = useWeb3ModalAccount(); - const { walletInfo } = useWalletInfo(); + const { isConnected, connectWallet } = useWallet(); return ( - open()} sx={{ marginTop: '16px' }}> + connectWallet()} sx={{ marginTop: '16px' }}> {isConnected ? 'Disconnect Wallet' : 'Connect Wallet'} ); diff --git a/src/components/wallet/ConnectWalletDialog.tsx b/src/components/wallet/ConnectWalletDialog.tsx deleted file mode 100644 index d2f8ebf..0000000 --- a/src/components/wallet/ConnectWalletDialog.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { - Avatar, - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - List, - ListItem, - ListItemAvatar, - ListItemText, - Typography, -} from '@mui/material'; -import React from 'react'; - -import { FMPTypography } from '@/components'; -import { useWallet } from '@/hooks/useWallet'; -import { EIP6963ProviderDetail } from '@/types/eip6963'; - -interface ConnectWalletDialogProps { - open: boolean; - onClose: () => void; -} - -const ConnectWalletDialog: React.FC = ({ open, onClose }) => { - const { wallets, connectWallet } = useWallet(); - - const handleConnectWallet = async (walletRdns: string) => { - await connectWallet(walletRdns); - onClose(); - }; - - return ( - - - - Connect Wallet - - - - - {Object.keys(wallets).length > 0 ? ( - Object.values(wallets).map((provider: EIP6963ProviderDetail) => ( - handleConnectWallet(provider.info.rdns)} - key={provider.info.uuid} - sx={{ - color: '#000000', - border: '2px solid #000000', - borderRadius: '8px', - marginBottom: '8px', - cursor: 'pointer', - '&:hover': { - backgroundColor: '#f0f0f0', - }, - display: 'flex', - alignItems: 'center', - padding: '8px 16px', - textAlign: 'left', - backgroundColor: 'transparent', - width: '100%', - outline: 'none', - }} - > - - - - - - )) - ) : ( - - - There are no announced providers - - } - /> - - )} - - - - - - - ); -}; - -export default ConnectWalletDialog; diff --git a/src/contexts/RequestDetailContext.tsx b/src/contexts/RequestDetailContext.tsx new file mode 100644 index 0000000..4be705f --- /dev/null +++ b/src/contexts/RequestDetailContext.tsx @@ -0,0 +1,94 @@ +import React, { createContext, ReactNode, useEffect, useState } from 'react'; + +import { execute, GetPictureRequestDocument } from '@/graphql/client'; +import { CreateRequestCommentParams, useComments } from '@/hooks/useComments'; +import { useIpfs } from '@/hooks/useIpfs'; +import { CreateRequestSubmissionParams, useSubmissions } from '@/hooks/useSubmissions'; +import { RequestComment } from '@/types/comment'; +import { Request } from '@/types/request'; +import { RequestSubmission } from '@/types/submission'; +import { mapPictureRequest } from '@/utils/mappers'; + +export interface RequestDetailContextType { + request: Request | null; + comments: RequestComment[]; + submissions: RequestSubmission[]; + loading: boolean; + fetchRequest: (id: string) => void; + createComment: (params: CreateRequestCommentParams) => Promise; + createSubmission: (params: CreateRequestSubmissionParams) => Promise; +} + +interface RequestDetailProviderProps { + children: ReactNode; + requestId: string; +} + +export const RequestDetailContext = createContext(undefined); + +export const RequestDetailProvider = ({ children, requestId }: RequestDetailProviderProps) => { + const [loading, setLoading] = useState(true); + const [request, setRequest] = useState(null); + const [comments, setComments] = useState([]); + const [submissions, setSubmissions] = useState([]); + + const { fetchIPFSData } = useIpfs(); + const { createRequestComment, pollForNewComment, fetchComments } = useComments(); + const { createRequestSubmission, pollForNewSubmission, fetchSubmissions } = useSubmissions(); + + const fetchRequest = async (id: string) => { + try { + const result = await execute(GetPictureRequestDocument, { id }); + const request = result?.data?.pictureRequest; + if (request) { + const ipfsData = await fetchIPFSData(request.ipfsHash); + return mapPictureRequest({ ...request, ...ipfsData }); + } + } catch (error) { + console.error('Error fetching request:', error); + } + }; + + useEffect(() => { + const fetchAllRequestDetails = async () => { + if (requestId) { + setLoading(true); + + const [request, comments, submissions] = await Promise.all([ + fetchRequest(requestId), + fetchComments(requestId), + fetchSubmissions(requestId), + ]); + + setRequest(request); + setComments(comments); + setSubmissions(submissions); + setLoading(false); + } + }; + + fetchAllRequestDetails(); + }, [requestId]); + + const createSubmission = async (params: CreateRequestSubmissionParams): Promise => { + const optimisticSubmission = await createRequestSubmission(params); + setSubmissions((prevSubmissions) => [...prevSubmissions, optimisticSubmission]); + pollForNewSubmission(optimisticSubmission.id); + return optimisticSubmission; + }; + + const createComment = async (params: CreateRequestCommentParams): Promise => { + const optimisticComment = await createRequestComment(params); + setComments((prevComments) => [...prevComments, optimisticComment]); + pollForNewComment(optimisticComment.id); + return optimisticComment; + }; + + return ( + + {children} + + ); +}; diff --git a/src/hooks/useComments.tsx b/src/hooks/useComments.tsx index e26b623..a26ddb0 100644 --- a/src/hooks/useComments.tsx +++ b/src/hooks/useComments.tsx @@ -1,44 +1,52 @@ -import { useState } from 'react'; - import { execute, GetRequestCommentDocument, GetRequestCommentsDocument } from '@/graphql/client'; import { useContractService } from '@/hooks/useContractService'; import { CreateRequestCommentParams as ContractCreateCommentParams } from '@/services/contractService'; +import { RequestComment } from '@/types/comment'; import { pollWithRetry } from '@/utils/delay'; import { mapRequestComment } from '@/utils/mappers'; import { useIpfs } from './useIpfs'; -interface CreateRequestCommentParams extends Omit { +export interface CreateRequestCommentParams extends Omit { text: string; requestId: string; setStatus?: (status: string) => void; } export const useComments = () => { - const { fetchIPFSData } = useIpfs(); - const { uploadRequestComment } = useIpfs(); const { contractService } = useContractService(); - - const [loading, setLoading] = useState(false); + const { fetchIPFSData, uploadRequestComment } = useIpfs(); const loadIPFSAndTransform = async (comment: any) => { const ipfsData = await fetchIPFSData(comment.ipfsHash); return { ...comment, ...ipfsData }; }; - const fetchComments = async (requestId: string) => { - const result = await execute(GetRequestCommentsDocument, { requestId }); - const comments = result?.data?.requestComments || []; - const transformedComments = await Promise.all(comments.map(loadIPFSAndTransform)); - return transformedComments.map(mapRequestComment); + const fetchComments = async (requestId: string): Promise => { + try { + const result = await execute(GetRequestCommentsDocument, { requestId }); + const comments = result?.data?.requestComments || []; + const transformedComments = await Promise.all(comments.map(loadIPFSAndTransform)); + return transformedComments.map(mapRequestComment); + } catch (e) { + console.error('Error fetching comments:', e); + return []; + } }; - const pollForNewComment = async (id: string): Promise => { - return pollWithRetry({ + const pollForNewComment = async (id: string): Promise => { + const fetchedComment = await pollWithRetry({ callback: async () => { const result = await execute(GetRequestCommentDocument, { id }); return result?.data?.requestComment || null; }, }); + + if (!fetchedComment) { + return null; + } + + const commentWithIpfData = await loadIPFSAndTransform(fetchedComment); + return mapRequestComment(commentWithIpfData); }; const createRequestComment = async ({ @@ -47,9 +55,7 @@ export const useComments = () => { account, requestId, setStatus, - }: CreateRequestCommentParams): Promise => { - setLoading(true); - + }: CreateRequestCommentParams): Promise => { try { setStatus?.('Uploading comment...'); const ipfsHash = await uploadRequestComment({ text }); @@ -62,22 +68,22 @@ export const useComments = () => { requestAddress: requestId, }); - let created = false; - if (requestCommentAddress) { - // Try to fetch data from the subgraph until the new comment appears - setStatus?.('Waiting for confirmation...'); - await pollForNewComment(requestCommentAddress); - created = true; - } - - if (!created) { + if (!requestCommentAddress) { throw new Error('Failed to create request comment'); } + + const optimisticComment: RequestComment = { + id: requestCommentAddress, + text, + commenter: account, + createdAt: Math.floor(Date.now() / 1000), + }; + + return optimisticComment; } finally { setStatus?.(''); - setLoading(false); } }; - return { createRequestComment, fetchComments, loading }; + return { createRequestComment, fetchComments, pollForNewComment }; }; diff --git a/src/hooks/useRequestDetail.tsx b/src/hooks/useRequestDetail.tsx new file mode 100644 index 0000000..90c3efa --- /dev/null +++ b/src/hooks/useRequestDetail.tsx @@ -0,0 +1,11 @@ +import { useContext } from 'react'; + +import { RequestDetailContext, RequestDetailContextType } from '@/contexts/RequestDetailContext'; + +export const useRequestDetail = (): RequestDetailContextType => { + const context = useContext(RequestDetailContext); + if (!context) { + throw new Error('useRequestDetail must be used within a RequestDetailProvider'); + } + return context; +}; diff --git a/src/hooks/useSubmissions.tsx b/src/hooks/useSubmissions.tsx index 47002f2..c86923d 100644 --- a/src/hooks/useSubmissions.tsx +++ b/src/hooks/useSubmissions.tsx @@ -1,13 +1,13 @@ -import { useState } from 'react'; - import { execute, GetRequestSubmissionDocument, GetRequestSubmissionsDocument } from '@/graphql/client'; import { useContractService } from '@/hooks/useContractService'; import { CreateRequestSubmissionParams as ContractCreateSubmissionParams } from '@/services/contractService'; +import { RequestSubmission } from '@/types/submission'; import { pollWithRetry } from '@/utils/delay'; import { mapRequestSubmission } from '@/utils/mappers'; import { useIpfs } from './useIpfs'; -interface CreateRequestSubmissionParams extends Omit { +export interface CreateRequestSubmissionParams + extends Omit { requestId: string; description: string; freeImageId: string; @@ -17,11 +17,8 @@ interface CreateRequestSubmissionParams extends Omit { - const { fetchIPFSData } = useIpfs(); - const { uploadRequestSubmission } = useIpfs(); const { contractService } = useContractService(); - - const [loading, setLoading] = useState(false); + const { fetchIPFSData, uploadRequestSubmission } = useIpfs(); const loadIPFSAndTransform = async (submission: any) => { const ipfsData = await fetchIPFSData(submission.ipfsHash); @@ -29,19 +26,31 @@ export const useSubmissions = () => { }; const fetchSubmissions = async (requestId: string) => { - const result = await execute(GetRequestSubmissionsDocument, { requestId }); - const submissions = result?.data?.requestSubmissions || []; - const transformedSubmissions = await Promise.all(submissions.map(loadIPFSAndTransform)); - return transformedSubmissions.map(mapRequestSubmission); + try { + const result = await execute(GetRequestSubmissionsDocument, { requestId }); + const submissions = result?.data?.requestSubmissions || []; + const transformedSubmissions = await Promise.all(submissions.map(loadIPFSAndTransform)); + return transformedSubmissions.map(mapRequestSubmission); + } catch (e) { + console.error('Error fetching submissions:', e); + return []; + } }; - const pollForNewSubmission = async (id: string): Promise => { - return pollWithRetry({ + const pollForNewSubmission = async (id: string): Promise => { + const fetchedSubmission = await pollWithRetry({ callback: async () => { const result = await execute(GetRequestSubmissionDocument, { id }); return result?.data?.requestSubmission; }, }); + + if (!fetchedSubmission) { + return null; + } + + const submissionWithIpfsData = await loadIPFSAndTransform(fetchedSubmission); + return mapRequestSubmission(submissionWithIpfsData); }; const createRequestSubmission = async ({ @@ -55,8 +64,6 @@ export const useSubmissions = () => { encryptedImageId, watermarkedImageId, }: CreateRequestSubmissionParams) => { - setLoading(true); - try { setStatus?.('Uploading metadata...'); const ipfsHash = await uploadRequestSubmission({ @@ -75,22 +82,27 @@ export const useSubmissions = () => { requestAddress: requestId, }); - let created = false; - if (requestSubmissionAddress) { - // Try to fetch data from the subgraph until the new submission appears - setStatus?.('Waiting for confirmation...'); - await pollForNewSubmission(requestSubmissionAddress); - created = true; + if (!requestSubmissionAddress) { + throw new Error('Failed to create request comment'); } - if (!created) { - throw new Error('Failed to create request submission'); - } + const optimisticSubmission: RequestSubmission = { + id: requestSubmissionAddress, + description, + price, + purchases: [], + submitter: account, + freePictureId: freeImageId, + encryptedPictureId: encryptedImageId, + watermarkedPictureId: watermarkedImageId, + createdAt: Math.floor(Date.now() / 1000), + }; + + return optimisticSubmission; } finally { setStatus?.(''); - setLoading(false); } }; - return { createRequestSubmission, fetchSubmissions, loading }; + return { createRequestSubmission, pollForNewSubmission, fetchSubmissions }; }; diff --git a/src/pages/request/[id].tsx b/src/pages/request/[id].tsx index 940df3c..ed22294 100644 --- a/src/pages/request/[id].tsx +++ b/src/pages/request/[id].tsx @@ -1,36 +1,13 @@ import { useRouter } from 'next/router'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { FullScreenLoader } from '@/components'; -import { useRequests } from '@/hooks/useRequests'; -import { Request } from '@/types/request'; +import { RequestDetailProvider } from '@/contexts/RequestDetailContext'; +import { useRequestDetail } from '@/hooks/useRequestDetail'; import RequestDetailView from '@/views/request/RequestDetailView'; -const RequestDetailsPage: React.FC = () => { - const router = useRouter(); - const { fetchRequest } = useRequests(); - - const [request, setRequest] = useState(null); - const [loading, setLoading] = useState(true); - - const requestId = router.query.id as string; - - useEffect(() => { - if (requestId) { - const loadRequest = async () => { - setLoading(true); // Set loading to true when starting to load the request - const fetchedRequest = await fetchRequest(requestId); - if (fetchedRequest) { - setRequest(fetchedRequest); - } - setLoading(false); // Set loading to false after fetching the request - }; - - loadRequest(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [requestId]); - +const RequestDetailPageContainer: React.FC = () => { + const { loading, request } = useRequestDetail(); if (loading) { return ; } @@ -42,4 +19,14 @@ const RequestDetailsPage: React.FC = () => { return ; }; -export default RequestDetailsPage; +const RequestDetailPage: React.FC = () => { + const router = useRouter(); + + return ( + + + + ); +}; + +export default RequestDetailPage; From 858cdf3c8e38a009618c796c0398d6155b336b55 Mon Sep 17 00:00:00 2001 From: Ben Schwartz Date: Thu, 15 Aug 2024 11:16:45 -0400 Subject: [PATCH 2/2] feat(optimistic): use a callback to update the comment and submission state variables after polling --- src/contexts/RequestDetailContext.tsx | 20 +++++++++++++++++--- src/hooks/useComments.tsx | 8 +++++--- src/hooks/useSubmissions.tsx | 13 +++++++++---- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/contexts/RequestDetailContext.tsx b/src/contexts/RequestDetailContext.tsx index 4be705f..6dfcf34 100644 --- a/src/contexts/RequestDetailContext.tsx +++ b/src/contexts/RequestDetailContext.tsx @@ -60,7 +60,9 @@ export const RequestDetailProvider = ({ children, requestId }: RequestDetailProv fetchSubmissions(requestId), ]); - setRequest(request); + if (request) { + setRequest(request); + } setComments(comments); setSubmissions(submissions); setLoading(false); @@ -73,14 +75,26 @@ export const RequestDetailProvider = ({ children, requestId }: RequestDetailProv const createSubmission = async (params: CreateRequestSubmissionParams): Promise => { const optimisticSubmission = await createRequestSubmission(params); setSubmissions((prevSubmissions) => [...prevSubmissions, optimisticSubmission]); - pollForNewSubmission(optimisticSubmission.id); + + pollForNewSubmission(optimisticSubmission.id, (polledSubmission: RequestSubmission) => { + setSubmissions((prevSubmissions) => + prevSubmissions.map((submission) => (submission.id === optimisticSubmission.id ? polledSubmission : submission)) + ); + }); + return optimisticSubmission; }; const createComment = async (params: CreateRequestCommentParams): Promise => { const optimisticComment = await createRequestComment(params); setComments((prevComments) => [...prevComments, optimisticComment]); - pollForNewComment(optimisticComment.id); + + pollForNewComment(optimisticComment.id, (polledComment: RequestComment) => { + setComments((prevComments) => + prevComments.map((comment) => (comment.id === optimisticComment.id ? polledComment : comment)) + ); + }); + return optimisticComment; }; diff --git a/src/hooks/useComments.tsx b/src/hooks/useComments.tsx index a26ddb0..52ccb49 100644 --- a/src/hooks/useComments.tsx +++ b/src/hooks/useComments.tsx @@ -33,7 +33,7 @@ export const useComments = () => { } }; - const pollForNewComment = async (id: string): Promise => { + const pollForNewComment = async (id: string, onCommentFound: (comment: RequestComment) => void): Promise => { const fetchedComment = await pollWithRetry({ callback: async () => { const result = await execute(GetRequestCommentDocument, { id }); @@ -42,11 +42,13 @@ export const useComments = () => { }); if (!fetchedComment) { - return null; + return; } const commentWithIpfData = await loadIPFSAndTransform(fetchedComment); - return mapRequestComment(commentWithIpfData); + const finalComment = mapRequestComment(commentWithIpfData); + + onCommentFound(finalComment); }; const createRequestComment = async ({ diff --git a/src/hooks/useSubmissions.tsx b/src/hooks/useSubmissions.tsx index c86923d..77b8c4c 100644 --- a/src/hooks/useSubmissions.tsx +++ b/src/hooks/useSubmissions.tsx @@ -25,7 +25,7 @@ export const useSubmissions = () => { return { ...submission, ...ipfsData }; }; - const fetchSubmissions = async (requestId: string) => { + const fetchSubmissions = async (requestId: string): Promise => { try { const result = await execute(GetRequestSubmissionsDocument, { requestId }); const submissions = result?.data?.requestSubmissions || []; @@ -37,7 +37,10 @@ export const useSubmissions = () => { } }; - const pollForNewSubmission = async (id: string): Promise => { + const pollForNewSubmission = async ( + id: string, + onSubmissionFound: (submission: RequestSubmission) => void + ): Promise => { const fetchedSubmission = await pollWithRetry({ callback: async () => { const result = await execute(GetRequestSubmissionDocument, { id }); @@ -46,11 +49,13 @@ export const useSubmissions = () => { }); if (!fetchedSubmission) { - return null; + return; } const submissionWithIpfsData = await loadIPFSAndTransform(fetchedSubmission); - return mapRequestSubmission(submissionWithIpfsData); + const finalSubmission = mapRequestSubmission(submissionWithIpfsData); + + onSubmissionFound(finalSubmission); }; const createRequestSubmission = async ({